OAuth 2.0 and OpenID Connect Implementation Guide
A developer-focused guide to implementing OAuth 2.0 and OpenID Connect, covering authorization flows, PKCE, token management, scope design, and API protection patterns.
OAuth 2.0 and OpenID Connect (OIDC) are the foundational protocols for modern authorization and authentication on the web. OAuth 2.0 handles authorization — granting an application limited access to a user's resources. OIDC is a layer on top of OAuth 2.0 that adds authentication — verifying the user's identity and providing profile information.
Despite their ubiquity, these protocols are frequently implemented incorrectly. Misconfigurations lead to token leakage, privilege escalation, and account takeover. This guide provides a rigorous, implementation-focused walkthrough so you can deploy OAuth 2.0 and OIDC correctly from the start.
What You Will Learn
- The OAuth 2.0 authorization flows and when to use each one
- How OIDC extends OAuth 2.0 for authentication
- Implementing the Authorization Code Flow with PKCE
- Token lifecycle management (access tokens, refresh tokens, ID tokens)
- Scope and permission design for APIs
- Securing your authorization server and resource servers
Prerequisites
- TLS everywhere. OAuth 2.0 security depends on transport-layer security. Every endpoint must be served over HTTPS.
- Authorization server. You need an OAuth 2.0/OIDC-compliant authorization server. This can be a cloud IdP (Okta, Auth0, Azure AD), a self-hosted server (KeyCloak, IdentityServer), or a custom implementation.
- Understanding of HTTP. OAuth relies on HTTP redirects, POST requests, and headers. Familiarity with HTTP fundamentals is assumed.
- Client application. A web app, mobile app, or SPA where you want to add authentication and/or API authorization.
- API or resource server. The backend API that the client will access using OAuth tokens.
Architecture Overview
The OAuth 2.0/OIDC architecture involves four roles:
- Resource Owner: The user who owns the data and grants access.
- Client: The application requesting access (your web app, mobile app, or SPA).
- Authorization Server (AS): Issues tokens after authenticating the user and obtaining consent. In OIDC, this is also the OpenID Provider (OP).
- Resource Server (RS): The API that accepts access tokens and serves protected resources.
Key endpoints on the Authorization Server:
/authorize— User-facing endpoint for authentication and consent (browser redirect)./token— Backend endpoint for exchanging authorization codes for tokens./userinfo— Returns user profile claims (OIDC)./.well-known/openid-configuration— Discovery document with all endpoint URLs and supported features.
Token types:
- Access Token: Short-lived token that grants access to a resource server. Can be opaque or a JWT.
- Refresh Token: Long-lived token used to obtain new access tokens without re-authenticating.
- ID Token: A JWT specific to OIDC that contains user identity claims (sub, name, email). Consumed by the client, not the resource server.
Step-by-Step Implementation
Step 1: Choose the Right Flow
OAuth 2.0 defines several authorization flows. Choosing the wrong one is a common source of vulnerabilities.
Authorization Code Flow with PKCE — Use this for everything.
- Web applications (server-rendered and SPAs)
- Mobile applications
- Desktop applications
- Machine-to-machine (use Client Credentials instead, see below)
The authorization code flow with PKCE is the recommended flow for all interactive clients. The implicit flow is deprecated and should never be used for new implementations.
Client Credentials Flow — Use for machine-to-machine.
- Backend services calling APIs
- Cron jobs, daemons, microservices
- No user involvement
Flow Decision Tree:
1. Is a user involved?
YES -> Authorization Code Flow with PKCE
NO -> Client Credentials Flow
Step 2: Register the Client
Register your application with the authorization server:
{
"client_name": "My Web Application",
"client_type": "confidential",
"redirect_uris": [
"https://myapp.example.com/auth/callback"
],
"post_logout_redirect_uris": [
"https://myapp.example.com/"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic"
}
Key decisions:
- Confidential vs. public client: Server-side web apps are confidential (they can securely store a
client_secret). SPAs and mobile apps are public (they cannot). Public clients must use PKCE and should not receive aclient_secret. - Redirect URIs: Register exact URIs. Never use wildcards. Include all environments (development, staging, production) as separate registrations.
- Token endpoint authentication: Confidential clients should use
client_secret_basic(HTTP Basic Auth) or, for higher security,private_key_jwt(JWT bearer assertion). Public clients usenone(no authentication) with PKCE.
Step 3: Implement Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange) protects against authorization code interception. It is required for public clients and recommended for all clients.
3a. Generate PKCE parameters:
// Generate code_verifier: a random 43-128 character string
const codeVerifier = generateSecureRandom(64).toString('base64url');
// Generate code_challenge: SHA-256 hash of the verifier
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Store code_verifier in session (server-side) or sessionStorage (SPA)
session.codeVerifier = codeVerifier;
3b. Build the authorization URL:
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', 'https://myapp.example.com/auth/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateSecureRandom(32).toString('hex'));
authUrl.searchParams.set('nonce', generateSecureRandom(32).toString('hex'));
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Redirect the user's browser to authUrl
res.redirect(authUrl.toString());
state: CSRF protection. Store it in the session and verify it when the callback arrives.nonce: Replay protection for the ID token. Include it in the authorization request and verify it appears in the returned ID token.
3c. Handle the callback:
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
// Verify state matches
if (state !== req.session.state) {
return res.status(403).send('CSRF detected');
}
if (error) {
return res.status(400).send(`Authorization error: ${error}`);
}
// Exchange code for tokens
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://myapp.example.com/auth/callback',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET, // Only for confidential clients
code_verifier: req.session.codeVerifier
})
});
const tokens = await tokenResponse.json();
// tokens contains: access_token, refresh_token, id_token, expires_in
// Validate the ID token
const idTokenClaims = await validateIdToken(tokens.id_token, {
issuer: 'https://auth.example.com',
audience: CLIENT_ID,
nonce: req.session.nonce
});
// Create application session
req.session.user = {
sub: idTokenClaims.sub,
email: idTokenClaims.email,
name: idTokenClaims.name
};
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect('/dashboard');
});
Step 4: Implement Token Management
Access token usage:
// Call the resource server with the access token
const apiResponse = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${session.accessToken}`
}
});
if (apiResponse.status === 401) {
// Token expired, use refresh token
await refreshAccessToken(session);
// Retry the request
}
Refresh token rotation:
async function refreshAccessToken(session) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
const tokens = await response.json();
// IMPORTANT: Always store the new refresh token (rotation)
session.accessToken = tokens.access_token;
if (tokens.refresh_token) {
session.refreshToken = tokens.refresh_token; // Rotated refresh token
}
}
Always enable refresh token rotation on the authorization server. With rotation, each refresh token can only be used once. If a stolen refresh token is replayed, the authorization server detects the reuse and revokes the entire token family.
Step 5: Design Scopes and Permissions
Scopes define what the client can do with the access token. Design them carefully:
# OIDC standard scopes
openid - Required for OIDC. Returns sub claim.
profile - name, family_name, given_name, etc.
email - email, email_verified
# Custom API scopes
read:orders - Read order data
write:orders - Create/update orders
delete:orders - Delete orders
admin:users - Manage user accounts
Best practices for scope design:
- Use resource-specific scopes (
read:orders) rather than broad scopes (read:all). - Follow the
action:resourcenaming convention. - Minimize the scopes your client requests. Request only what the current session needs.
- Use fine-grained scopes for sensitive operations and coarse-grained scopes for common operations.
Step 6: Protect the Resource Server
The resource server must validate the access token on every request:
// Resource server middleware
async function validateToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'missing_token' });
}
const token = authHeader.substring(7);
try {
// For JWT access tokens: validate locally
const claims = await verifyJwt(token, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
algorithms: ['RS256']
});
// Check scopes
const tokenScopes = claims.scope?.split(' ') || [];
req.tokenScopes = tokenScopes;
req.userId = claims.sub;
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_token' });
}
}
// Scope enforcement per route
function requireScope(scope) {
return (req, res, next) => {
if (!req.tokenScopes.includes(scope)) {
return res.status(403).json({ error: 'insufficient_scope' });
}
next();
};
}
app.get('/api/orders', validateToken, requireScope('read:orders'), getOrders);
app.post('/api/orders', validateToken, requireScope('write:orders'), createOrder);
For opaque access tokens, the resource server must call the authorization server's introspection endpoint (/introspect) to validate the token. This adds latency but allows the AS to revoke tokens immediately.
Step 7: Implement Client Credentials Flow
For machine-to-machine communication:
async function getM2MToken() {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: SERVICE_CLIENT_ID,
client_secret: SERVICE_CLIENT_SECRET,
scope: 'read:orders'
})
});
const { access_token, expires_in } = await response.json();
// Cache the token until near expiry
tokenCache.set('orders-api', access_token, expires_in - 60);
return access_token;
}
Configuration Best Practices
- Short access token lifetimes. 5-15 minutes for user-facing flows, 30-60 minutes for machine-to-machine. Short lifetimes limit the damage window from a stolen token.
- Refresh token rotation. Enable it always. Detect and revoke on reuse.
- Sender-constrained tokens. Where possible, use DPoP (Demonstration of Proof-of-Possession) to bind tokens to the client's key pair, preventing token theft.
- Exact redirect URI matching. Never allow partial matching, wildcards, or open redirectors in redirect URIs.
- JWKS key rotation. Rotate the authorization server's signing keys periodically. Publish both old and new keys in JWKS during the rotation window.
- Token revocation endpoint. Implement and expose
/revokeso clients can explicitly revoke tokens on logout.
Testing and Validation
- Happy path testing: Complete the full flow from authorization to token exchange to API call. Verify all token claims are correct.
- PKCE validation: Attempt to exchange an authorization code without the correct
code_verifier. The AS must reject the request. - State validation: Tamper with the
stateparameter in the callback. The client must reject the response. - Token expiry: Wait for the access token to expire. Verify the refresh token flow works and the client retries the API call.
- Scope enforcement: Request a token with
read:ordersscope and attempt a write operation. The resource server must return 403. - Redirect URI validation: Attempt authorization with an unregistered redirect URI. The AS must reject the request.
- CSRF testing: Attempt to initiate an authorization flow without a state parameter and verify it is rejected.
Common Pitfalls and Troubleshooting
| Problem | Cause | Solution | |---------|-------|----------| | "Invalid redirect_uri" error | URI mismatch between client registration and request | Ensure exact match including trailing slashes and port numbers | | ID token validation fails | Clock skew or wrong issuer | Sync server clocks; verify issuer matches exactly | | Refresh token rejected | Refresh token rotation detected reuse | The token family has been revoked; user must re-authenticate | | "Invalid grant" on code exchange | Code already used or expired | Authorization codes are single-use and short-lived (typically 60 seconds) | | CORS errors on token endpoint (SPA) | Authorization server does not allow CORS from SPA origin | Configure CORS headers on the AS, or use a backend-for-frontend proxy | | Token too large for headers | JWT contains too many claims | Move claims to the userinfo endpoint; keep the token lean |
Security Considerations
- Never expose tokens in URLs. Do not include access tokens in query parameters. Use the Authorization header for API calls and POST body for token exchange.
- Validate all tokens server-side. Never trust a token solely based on client-side validation. The resource server must verify the signature, issuer, audience, and expiry.
- Use the nonce claim. For OIDC flows, include a nonce in the authorization request and verify it in the ID token to prevent replay attacks.
- Protect the client secret. For confidential clients, the client secret is as sensitive as a password. Store it in a secrets manager, not in source code.
- Implement token revocation on logout. When a user logs out, revoke both the access token and the refresh token at the authorization server.
- Watch for open redirector attacks. If your application has open redirect vulnerabilities, an attacker can steal authorization codes by manipulating the redirect flow. Validate all redirect URIs strictly.
Conclusion
OAuth 2.0 and OpenID Connect are powerful protocols that, when implemented correctly, provide secure and user-friendly authentication and authorization. The key is discipline: use the authorization code flow with PKCE for all interactive clients, keep tokens short-lived, validate everything server-side, and design scopes that enforce least privilege.
The protocols themselves are well-designed. Most OAuth vulnerabilities come from implementation mistakes — using the wrong flow, accepting unregistered redirect URIs, or failing to validate tokens. By following the patterns in this guide and testing rigorously, you can build a solid OAuth/OIDC implementation that protects your users and your APIs.
FAQs
Q: Is the implicit flow still acceptable? A: No. The OAuth 2.0 Security Best Current Practice (RFC 9700) recommends against the implicit flow. Use the authorization code flow with PKCE for all clients, including SPAs.
Q: Should I use opaque or JWT access tokens? A: JWT access tokens enable local validation at the resource server, reducing latency. Opaque tokens require introspection but allow immediate revocation. For most architectures, JWT access tokens with short lifetimes (5-15 minutes) strike the right balance.
Q: How do I handle token storage in an SPA? A: The safest approach is the Backend-for-Frontend (BFF) pattern: the SPA communicates with a backend proxy that handles token exchange and stores tokens in an HTTP-only, secure, SameSite cookie. Storing tokens in localStorage or sessionStorage exposes them to XSS attacks.
Q: Can I use OAuth 2.0 for authentication? A: OAuth 2.0 alone is an authorization protocol and should not be used for authentication. Use OIDC, which adds the ID token and standardized identity claims on top of OAuth 2.0.
Q: How do I handle multi-tenant APIs? A: Include the tenant identifier in the scope or audience claim. The resource server should validate that the token's tenant matches the requested resource's tenant. Consider using separate authorization server instances per tenant for strong isolation.
Q: What is the Backend-for-Frontend (BFF) pattern? A: The BFF is a lightweight backend server that acts as a confidential OAuth client on behalf of the SPA. The SPA authenticates to the BFF using session cookies, and the BFF handles all token exchange with the authorization server. This keeps tokens out of the browser entirely.
Share this article