OAuth 2.1 consolidates the best practices from OAuth 2.0 and its security extensions into a streamlined specification. Combined with OpenID Connect (OIDC) for identity, it provides a robust foundation for modern authentication. This guide covers implementation details, security best practices, and common pitfalls to help you build secure authentication systems in 2026.
OAuth 2.1 Key Changes
OAuth 2.1 is not a new version but a consolidation that makes previously optional security measures mandatory and removes insecure patterns.
- PKCE Required: Proof Key for Code Exchange is mandatory for all clients
- Implicit Flow Removed: No longer recommended due to security concerns
- Password Grant Removed: Direct credential exchange is deprecated
- Bearer Tokens Restricted: Tokens must be sent in headers, not URLs
- Refresh Token Rotation: Recommended for all refresh token usage
- Exact Redirect URI Matching: Wildcard redirects are prohibited
Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange) protects against authorization code interception attacks. It's now required for all OAuth clients, including confidential clients.
// OAuth 2.1 Client Implementation with PKCE
import crypto from 'crypto';
class OAuth2Client {
private clientId: string;
private redirectUri: string;
private authorizationEndpoint: string;
private tokenEndpoint: string;
private codeVerifier: string | null = null;
constructor(config: {
clientId: string;
redirectUri: string;
authorizationEndpoint: string;
tokenEndpoint: string;
}) {
this.clientId = config.clientId;
this.redirectUri = config.redirectUri;
this.authorizationEndpoint = config.authorizationEndpoint;
this.tokenEndpoint = config.tokenEndpoint;
}
// Generate cryptographically secure code verifier
private generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
// Create code challenge from verifier (S256 method)
private generateCodeChallenge(verifier: string): string {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// Generate state for CSRF protection
private generateState(): string {
return crypto.randomBytes(16).toString('base64url');
}
// Step 1: Build authorization URL
getAuthorizationUrl(scopes: string[]): { url: string; state: string } {
this.codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallenge(this.codeVerifier);
const state = this.generateState();
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: scopes.join(' '),
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return {
url: `${this.authorizationEndpoint}?${params.toString()}`,
state: state,
};
}
// Step 2: Exchange authorization code for tokens
async exchangeCode(code: string): Promise<TokenResponse> {
if (!this.codeVerifier) {
throw new Error('Code verifier not found. Start a new authorization flow.');
}
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: this.codeVerifier,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error_description || error.error}`);
}
this.codeVerifier = null; // Clear after use
return response.json();
}
// Step 3: Refresh access token
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token refresh failed: ${error.error_description || error.error}`);
}
return response.json();
}
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
id_token?: string; // If using OIDC
}OpenID Connect Integration
OIDC adds an identity layer on top of OAuth 2.0. The ID token provides user identity information in a verifiable JWT format.
// OIDC ID Token Validation
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
class OIDCValidator {
private jwksUri: string;
private issuer: string;
private audience: string;
private client: jwksClient.JwksClient;
constructor(config: {
jwksUri: string;
issuer: string;
audience: string;
}) {
this.jwksUri = config.jwksUri;
this.issuer = config.issuer;
this.audience = config.audience;
this.client = jwksClient({
jwksUri: this.jwksUri,
cache: true,
cacheMaxAge: 600000, // 10 minutes
rateLimit: true,
});
}
private async getSigningKey(kid: string): Promise<string> {
const key = await this.client.getSigningKey(kid);
return key.getPublicKey();
}
async validateIdToken(idToken: string, nonce?: string): Promise<IDTokenClaims> {
// Decode header to get key ID
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error('Invalid token format');
}
// Get signing key
const publicKey = await this.getSigningKey(decoded.header.kid);
// Verify token
const claims = jwt.verify(idToken, publicKey, {
algorithms: ['RS256'],
issuer: this.issuer,
audience: this.audience,
}) as IDTokenClaims;
// Validate nonce if provided (for replay attack prevention)
if (nonce && claims.nonce !== nonce) {
throw new Error('Nonce mismatch');
}
// Validate at_hash if access token was issued
// (omitted for brevity)
return claims;
}
}
interface IDTokenClaims {
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string | string[];// Audience
exp: number; // Expiration time
iat: number; // Issued at
auth_time?: number; // Authentication time
nonce?: string; // Nonce for replay prevention
email?: string; // User email
email_verified?: boolean;
name?: string; // Full name
picture?: string; // Profile picture URL
}
// Usage example
const validator = new OIDCValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'your-client-id',
});
const claims = await validator.validateIdToken(idToken, storedNonce);
console.log(`Authenticated user: ${claims.email}`);Secure Token Storage
Where and how you store tokens significantly impacts security. Different platforms have different best practices.
// Browser Token Storage Patterns
// BAD: localStorage is vulnerable to XSS
localStorage.setItem('access_token', token); // Don't do this!
// BETTER: HttpOnly cookies with proper flags
// Set from server response:
// Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/
// BEST: BFF (Backend for Frontend) pattern
// Tokens never reach the browser - session managed server-side
// React implementation with BFF pattern
class AuthService {
// All auth operations go through your backend
async login(provider: string): Promise<void> {
// Redirect to backend which initiates OAuth flow
window.location.href = `/api/auth/login/${provider}`;
}
async logout(): Promise<void> {
await fetch('/api/auth/logout', { method: 'POST' });
}
async getUser(): Promise<User | null> {
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
}
async fetchWithAuth(url: string, options?: RequestInit): Promise<Response> {
// Backend handles token attachment via cookies
return fetch(url, {
...options,
credentials: 'include', // Send cookies
});
}
}// Next.js API Route - BFF Token Handler
// app/api/auth/callback/route.ts
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
// Validate state
const storedState = cookies().get('oauth_state')?.value;
if (!state || state !== storedState) {
return NextResponse.redirect('/auth/error?reason=invalid_state');
}
// Exchange code for tokens (server-side)
const tokenResponse = await fetch(process.env.TOKEN_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code!,
redirect_uri: process.env.REDIRECT_URI!,
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
code_verifier: cookies().get('code_verifier')?.value!,
}),
});
const tokens = await tokenResponse.json();
// Store tokens in secure HttpOnly cookies
const response = NextResponse.redirect('/dashboard');
response.cookies.set('access_token', tokens.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: tokens.expires_in,
path: '/',
});
if (tokens.refresh_token) {
response.cookies.set('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60, // 30 days
path: '/api/auth/refresh',
});
}
// Clean up OAuth state cookies
response.cookies.delete('oauth_state');
response.cookies.delete('code_verifier');
return response;
}Refresh Token Rotation
Refresh token rotation issues a new refresh token with each use, invalidating the previous one. This limits the damage from a compromised refresh token.
// Refresh Token Rotation Implementation
class TokenManager {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private expiresAt: number = 0;
private refreshPromise: Promise<void> | null = null;
async getAccessToken(): Promise<string> {
// Check if token needs refresh (with 60s buffer)
if (this.accessToken && Date.now() < this.expiresAt - 60000) {
return this.accessToken;
}
// Prevent concurrent refresh requests
if (this.refreshPromise) {
await this.refreshPromise;
return this.accessToken!;
}
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
this.refreshPromise = this.doRefresh();
try {
await this.refreshPromise;
return this.accessToken!;
} finally {
this.refreshPromise = null;
}
}
private async doRefresh(): Promise<void> {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
// Refresh failed - user needs to re-authenticate
this.clearTokens();
window.location.href = '/login';
throw new Error('Session expired');
}
const data = await response.json();
this.setTokens(data);
} catch (error) {
this.clearTokens();
throw error;
}
}
setTokens(response: TokenResponse): void {
this.accessToken = response.access_token;
this.refreshToken = response.refresh_token || this.refreshToken;
this.expiresAt = Date.now() + response.expires_in * 1000;
}
clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
}
}
// Server-side refresh endpoint
// app/api/auth/refresh/route.ts
export async function POST(request: NextRequest) {
const refreshToken = cookies().get('refresh_token')?.value;
if (!refreshToken) {
return NextResponse.json(
{ error: 'No refresh token' },
{ status: 401 }
);
}
const response = await fetch(process.env.TOKEN_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
}),
});
if (!response.ok) {
// Refresh token is invalid or expired
const errorResponse = NextResponse.json(
{ error: 'Refresh failed' },
{ status: 401 }
);
errorResponse.cookies.delete('access_token');
errorResponse.cookies.delete('refresh_token');
return errorResponse;
}
const tokens = await response.json();
const successResponse = NextResponse.json({
expires_in: tokens.expires_in,
});
// Update both tokens (rotation)
successResponse.cookies.set('access_token', tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: tokens.expires_in,
});
if (tokens.refresh_token) {
successResponse.cookies.set('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60,
path: '/api/auth/refresh',
});
}
return successResponse;
}Common Security Pitfalls
OAuth Security Pitfalls to Avoid
Storing tokens in localStorage: Vulnerable to XSS attacks
Missing state parameter: Enables CSRF attacks
Wildcard redirect URIs: Allows open redirects
Implicit flow for SPAs: Exposes tokens in URL fragments
Long-lived access tokens: Increases attack window
Missing PKCE: Vulnerable to authorization code interception
Improper token validation: Skipping signature or claims checks
Insecure token transmission: Not using HTTPS
Client secrets in frontend: Exposes credentials
Missing refresh token rotation: Extends compromise window
Conclusion
OAuth 2.1 with OIDC provides a robust foundation for modern authentication when implemented correctly. The key principles are: always use PKCE, store tokens securely (preferably server-side), implement refresh token rotation, and validate all tokens properly. Following these practices will help you build secure authentication systems that protect your users.
Need help implementing secure authentication for your application? Contact Jishu Labs for expert security consulting and implementation services.
About Lisa Thompson
Lisa Thompson is a Security Engineer at Jishu Labs specializing in application security and identity management. She has implemented authentication systems for financial services and healthcare organizations.