Overview
Implementing OAuth 2.0 (with Authorization Code Flow + PKCE for security) in a React app to obtain a bearer token (typically a JWT for stateless auth) is a common way to secure your API. This stateless approach means the backend doesn’t store sessions; instead, it validates the JWT on each request using a shared secret or public key.
I’ll assume:
- Backend: Node.js with Express.js (adaptable to other stacks like Spring Boot or Django).
- OAuth Provider: A service like Auth0, Google, or a custom OAuth server (e.g., using Node.js Passport). For simplicity, I’ll use Auth0 as an example—it’s free for basics and handles token issuance.
- Frontend: React with libraries like
react-oauth2-code-pkcefor the flow. - Stateless Security: Use JWT as the bearer token. Backend verifies it without database lookups.
Key Flow:
- User logs in via OAuth provider.
- Frontend gets authorization code, exchanges for access token (JWT).
- Frontend attaches
Authorization: Bearer <token>to API calls. - Backend validates JWT signature and claims (e.g., exp, iss) on each request.
Prerequisites:
- Sign up for an OAuth provider (e.g., Auth0 dashboard: create an app, note Client ID, Domain, and Callback URL).
- Install dependencies (detailed below).
Backend Implementation (Node.js/Express)
The backend exposes API endpoints and validates the JWT bearer token. Use jsonwebtoken for verification and express-jwt for middleware.
Step 1: Set Up Project and Dependencies
mkdir backend && cd backend
npm init -y
npm install express jsonwebtoken express-jwt cors helmet
npm install -D nodemon
express-jwt: Middleware for JWT validation.jsonwebtoken: For manual verification if needed.cors: Allow React frontend origin.helmet: Basic security headers.
Step 2: Configure Environment Variables
Create .env:
JWT_SECRET=your-super-secret-key (use a strong random string, e.g., from openssl rand -hex 32)
AUTH0_DOMAIN=your-auth0-domain.auth0.com
AUTH0_AUDIENCE=your-api-identifier (from Auth0 dashboard)
- In Auth0: Go to APIs > Create API, set Identifier (Audience), and enable RBAC if needed.
Step 3: Create Server and Protected Route
In server.js:
const express = require('express');
const jwt = require('express-jwt');
const cors = require('cors');
const helmet = require('helmet');
require('dotenv').config();
const app = express();
const PORT = 5000;
// Middleware
app.use(helmet());
app.use(cors({ origin: 'http://localhost:3000' })); // React dev server
app.use(express.json());
// JWT Middleware (stateless validation)
const authMiddleware = jwt({
secret: process.env.JWT_SECRET, // For custom JWT; for Auth0, use jwks-rsa below
audience: process.env.AUTH0_AUDIENCE,
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
algorithms: ['RS256'] // Auth0 uses RS256
});
// For Auth0, fetch public keys dynamically (recommended for stateless)
const { expressjwt: jwt } = require('express-jwt');
const jwksRsa = require('jwks-rsa');
const authMiddleware = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`
}),
audience: process.env.AUTH0_AUDIENCE,
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
algorithms: ['RS256']
});
// Public route (no auth)
app.get('/api/public', (req, res) => {
res.json({ message: 'Public endpoint' });
});
// Protected route (requires valid bearer token)
app.get('/api/protected', authMiddleware, (req, res) => {
// req.auth contains decoded JWT claims (e.g., user ID, roles)
res.json({ message: 'Protected data', user: req.auth.sub });
});
// Error handler for invalid tokens
app.use((err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
res.status(401).json({ error: 'Invalid token' });
}
});
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
- Run:
nodemon server.js.
Step 4: Handle Token Exchange (Optional: If Custom OAuth)
If not using Auth0, implement /auth/token endpoint to exchange code for JWT:
app.post('/auth/token', async (req, res) => {
const { code, code_verifier } = req.body; // From frontend PKCE
// Validate code with OAuth provider, get user info
// Then sign JWT
const token = jwt.sign({ sub: user.id, roles: user.roles }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ access_token: token });
});
- For Auth0, the frontend handles exchange directly.
Step 5: Test Backend
curl http://localhost:5000/api/public→ Works.- Without token:
curl http://localhost:5000/api/protected→ 401. - With valid token: Use Postman with
Authorization: Bearer <jwt>.
Frontend Implementation (React)
Use react-oauth2-code-pkce for secure OAuth flow (handles PKCE to prevent code interception). Store token in memory or localStorage (use httpOnly cookies for prod security).
Step 1: Set Up Project and Dependencies
npx create-react-app frontend
cd frontend
npm install react-oauth2-code-pkce axios
react-oauth2-code-pkce: Manages OAuth flow.axios: For API calls with token.
Step 2: Configure Environment Variables
In .env (React loads these):
REACT_APP_AUTH0_DOMAIN=your-auth0-domain.auth0.com
REACT_APP_AUTH0_CLIENT_ID=your-client-id
REACT_APP_AUTH0_REDIRECT_URI=http://localhost:3000/callback
REACT_APP_API_URL=http://localhost:5000/api
Step 3: Create Auth Provider and Components
Wrap app with AuthProvider in src/index.js:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from 'react-oauth2-code-pkce';
const authConfig = {
clientId: process.env.REACT_APP_AUTH0_CLIENT_ID,
authorizationEndpoint: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/authorize`,
tokenEndpoint: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/oauth/token`,
redirectUri: process.env.REACT_APP_AUTH0_REDIRECT_URI,
scope: 'openid profile email', // Adjust scopes
extraQueryParams: { audience: 'your-api-identifier' } // For API access
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<AuthProvider authConfig={authConfig}>
<App />
</AuthProvider>
);
Create src/AuthButton.js for login/logout:
import React from 'react';
import { useAuth } from 'react-oauth2-code-pkce';
export default function AuthButton() {
const { login, logOut, isAuthenticated, error, authContextLoading } = useAuth();
if (authContextLoading) return <p>Loading...</p>;
if (error) return <p>Auth Error: {error}</p>;
return isAuthenticated ? (
<>
<p>Logged in!</p>
<button onClick={() => logOut()}>Logout</button>
</>
) : (
<button onClick={() => login()}>Login with OAuth</button>
);
}
Step 4: Make Secured API Calls
In a component like src/App.js:
import React from 'react';
import AuthButton from './AuthButton';
import { useAuth } from 'react-oauth2-code-pkce';
import axios from 'axios';
export default function App() {
const { token, isAuthenticated } = useAuth();
const fetchProtectedData = async () => {
if (!isAuthenticated) return;
try {
const response = await axios.get(`${process.env.REACT_APP_API_URL}/protected`, {
headers: { Authorization: `Bearer ${token}` }
});
console.log(response.data);
} catch (error) {
console.error('API Error:', error.response?.data);
}
};
return (
<div>
<AuthButton />
{isAuthenticated && <button onClick={fetchProtectedData}>Fetch Protected Data</button>}
</div>
);
}
- The library handles redirect to
/callbackautomatically.
Step 5: Handle Callback Route
The library manages the callback, but ensure your package.json has "homepage": "." for dev.
Step 6: Test Frontend
- Run:
npm start. - Click Login → Redirect to Auth0 → Back to app with token.
- API call succeeds only if authenticated.
Additional Best Practices
- Security:
- Use HTTPS in prod.
- Short token expiry (e.g., 15-60min) + refresh tokens.
- Validate scopes/roles in backend (e.g.,
if (!req.auth.permissions.includes('read:protected')) return 403;). - Avoid storing tokens in localStorage; use in-memory or secure cookies.
- Error Handling: Add try-catch for token expiry (redirect to login).
- Custom OAuth: If building your own server, use
passport-oauth2on backend. - Deployment: Update origins/URIs in Auth0 for prod (e.g., Vercel/Netlify).
- Debugging: Check browser console for token, network tab for headers.
