How to Implement OAuth 2.1 with JWT Refresh Tokens in Node.js (2026 Guide)
In 2026, API security isn't optional—it's the foundation of every production system. OAuth 2.1, the latest evolution of the OAuth framework, consolidates years of security best practices into a single, opinionated specification. Combined with JWT access tokens and refresh token rotation, you get a robust authentication system that protects against modern threats like token interception, refresh token theft, and phishing attacks.
This guide walks you through implementing a complete OAuth 2.1-compliant authentication system in Node.js, with working code you can deploy today.
Why OAuth 2.1 Matters in 2026
OAuth 2.1 isn't just a new version number—it's a security revolution. Here's what's changed:
Key OAuth 2.1 Security Requirements
| Requirement | Old OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Optional for SPAs | Mandatory for all public clients |
| Refresh Token Rotation | Optional | Strongly recommended |
| Token Expiration | Often 1 hour+ | Shorter access tokens (15-30 min) |
| Implicit Flow | Allowed | Deprecated |
| ROPC Flow | Allowed | Not recommended |
In 2026, attackers have become sophisticated. Code interception, token exfiltration via browser developer tools, and refresh token theft through phishing or malware are common attack vectors. OAuth 2.1's enforced PKCE (Proof Key for Code Exchange), token rotation, and sender-constrained refresh tokens directly address these vulnerabilities.
Architecture Overview
Our implementation will include:
- Authorization Server - Issues tokens, handles PKCE, manages refresh tokens
- Resource Server - Validates JWT access tokens, enforces authorization
- PKCE Flow - Required for public clients (SPAs, mobile apps)
- Refresh Token Rotation - Invalidates old tokens when new ones are issued
Project Setup
Initialize your project:
mkdir oauth2-server && cd oauth2-server
npm init -y
npm install express jsonwebtoken jose uuid cors dotenv
- express: Web framework
- jsonwebtoken: JWT signing/verification (for access tokens)
- jose: Full OAuth 2.1/OIDC compliance (recommended over jsonwebtoken for 2026)
- uuid: Token identifiers
- cors: Cross-origin resource sharing
- dotenv: Environment configuration
Implementation
1. Environment Configuration
Create a .env file:
PORT=3000
JWT_SECRET=your-super-secret-key-change-in-production
REFRESH_TOKEN_SECRET=your-refresh-secret-change-in-production
JWT_ALGORITHM=RS256 # Use asymmetric signing in production
TOKEN_EXPIRY=900 # 15 minutes in seconds
REFRESH_TOKEN_EXPIRY=604800 # 7 days in seconds
2. Token Service
Create services/tokenService.js:
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
// In-memory store (use Redis in production)
const refreshTokens = new Map();
class TokenService {
// Generate short-lived access token (JWT)
generateAccessToken(userId, roles = []) {
const payload = {
sub: userId,
roles,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (parseInt(process.env.TOKEN_EXPIRY) || 900),
iss: 'https://api.yourdomain.com',
aud: 'your-api-identifier'
};
return jwt.sign(payload, process.env.JWT_SECRET, {
algorithm: 'HS256' // Use RS256 in production with proper key pairs
});
}
// Generate refresh token with rotation support
generateRefreshToken(userId) {
const tokenId = uuidv4();
const token = crypto.randomBytes(64).toString('hex');
// Store refresh token metadata for rotation
refreshTokens.set(token, {
userId,
tokenId,
createdAt: Date.now(),
rotated: false,
family: uuidv4() // Track token family for rotation detection
});
return { token, tokenId };
}
// Verify and rotate refresh token
async rotateRefreshToken(oldToken) {
const tokenData = refreshTokens.get(oldToken);
if (!tokenData) {
throw new Error('Invalid refresh token');
}
// Check if already rotated (potential token reuse attack)
if (tokenData.rotated) {
// Security: invalidate entire token family
this.invalidateTokenFamily(tokenData.family);
throw new Error('Token reuse detected - security alert');
}
// Mark old token as rotated
tokenData.rotated = true;
refreshTokens.set(oldToken, tokenData);
// Generate new tokens
const newTokens = this.generateRefreshToken(tokenData.userId);
// Update family tracking
const newTokenData = refreshTokens.get(newTokens.token);
newTokenData.family = tokenData.family;
refreshTokens.set(newTokens.token, newTokenData);
// Clean up old tokens older than 24 hours
this.cleanupOldTokens();
return newTokens;
}
// Verify access token
verifyAccessToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Explicitly specify allowed algorithms
issuer: 'https://api.yourdomain.com',
audience: 'your-api-identifier'
});
} catch (error) {
return null;
}
}
// Invalidate a specific token
invalidateToken(token) {
refreshTokens.delete(token);
}
// Invalidate all tokens in a family (security measure)
invalidateTokenFamily(familyId) {
for (const [token, data] of refreshTokens.entries()) {
if (data.family === familyId) {
refreshTokens.delete(token);
}
}
}
// Cleanup old rotated tokens
cleanupOldTokens() {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
for (const [token, data] of refreshTokens.entries()) {
if (data.rotated && (now - data.createdAt) > maxAge) {
refreshTokens.delete(token);
}
}
}
// Get token metadata (for debugging)
getTokenInfo(token) {
return refreshTokens.get(token);
}
}
module.exports = new TokenService();
3. PKCE Challenge Generator
Create services/pkce.js:
const crypto = require('crypto');
class PKCE {
// Generate code verifier (43-128 characters, URL-safe)
generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
// Generate code challenge from verifier
generateCodeChallenge(verifier) {
// Use S256 (SHA-256) challenge method
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// Verify PKCE challenge
verifyChallenge(verifier, challenge) {
const computed = this.generateCodeChallenge(verifier);
return computed === challenge;
}
}
module.exports = new PKCE();
4. Authorization Server (OAuth Endpoints)
Create server.js:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const tokenService = require('./services/tokenService');
const pkce = require('./services/pkce');
const { v4: uuidv4 } = require('uuid');
const app = express();
app.use(express.json());
app.use(cors({
origin: ['https://yourfrontend.com', 'http://localhost:3001'],
credentials: true
}));
// In-memory storage (use database in production)
const authorizationCodes = new Map();
const users = new Map([
['user1', { id: 'user1', password: 'password123', roles: ['user'] }],
['admin1', { id: 'admin1', password: 'adminpass', roles: ['admin', 'user'] }]
]);
// PKCE storage (maps code to verifier)
const pkceStore = new Map();
// ============ OAuth 2.1 Endpoints ============
// 1. Authorization Endpoint
app.get('/oauth/authorize', (req, res) => {
const {
client_id,
redirect_uri,
response_type,
scope,
state,
code_challenge,
code_challenge_method
} = req.query;
// Validate required parameters
if (!client_id || !redirect_uri || !response_type || !code_challenge) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters'
});
}
// Only support 'code' response type (no implicit/implicit flow)
if (response_type !== 'code') {
return res.status(400).json({
error: 'unsupported_response_type',
error_description: 'Only code response_type is supported'
});
}
// Validate code challenge method
if (code_challenge_method !== 'S256') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Only S256 code_challenge_method is supported'
});
}
// Store PKCE verifier with the future authorization code
const authCode = uuidv4();
pkceStore.set(authCode, {
codeVerifier: null, // Will be set during token exchange
codeChallenge: code_challenge,
clientId: client_id,
redirectUri: redirect_uri,
scope,
userId: null // Will be set after login
});
// In real app, redirect to login page
// For this demo, we'll simulate a logged-in user
const simulatedUserId = 'user1';
pkceStore.get(authCode).userId = simulatedUserId;
// Redirect with authorization code
const params = new URLSearchParams({
code: authCode,
state: state || ''
});
res.redirect(`${redirect_uri}?${params}`);
});
// 2. Token Endpoint (supports authorization_code and refresh_token)
app.post('/oauth/token', async (req, res) => {
const {
grant_type,
code,
redirect_uri,
client_id,
code_verifier,
refresh_token
} = req.body;
try {
// Handle Authorization Code Grant with PKCE
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters'
});
}
const pkceData = pkceStore.get(code);
if (!pkceData) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Invalid or expired authorization code'
});
}
// Verify PKCE
if (!pkce.verifyChallenge(code_verifier, pkceData.codeChallenge)) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'PKCE verification failed'
});
}
// Verify redirect URI
if (pkceData.redirectUri !== redirect_uri) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Redirect URI mismatch'
});
}
// Clean up authorization code (one-time use)
pkceStore.delete(code);
// Generate tokens
const userId = pkceData.userId;
const accessToken = tokenService.generateAccessToken(userId, users.get(userId)?.roles || []);
const { token: refreshToken, tokenId } = tokenService.generateRefreshToken(userId);
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: parseInt(process.env.TOKEN_EXPIRY) || 900,
refresh_token: refreshToken,
scope: pkceData.scope || 'read write'
});
}
// Handle Refresh Token Grant with Rotation
if (grant_type === 'refresh_token') {
if (!refresh_token) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Refresh token required'
});
}
// Rotate tokens (get new access + refresh token)
const newTokens = await tokenService.rotateRefreshToken(refresh_token);
const userData = tokenService.getTokenInfo(newTokens.token);
const accessToken = tokenService.generateAccessToken(
userData.userId,
users.get(userData.userId)?.roles || []
);
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: parseInt(process.env.TOKEN_EXPIRY) || 900,
refresh_token: newTokens.token,
scope: 'read write'
});
}
// Unsupported grant type
return res.status(400).json({
error: 'unsupported_grant_type',
error_description: 'Only authorization_code and refresh_token grants supported'
});
} catch (error) {
console.error('Token endpoint error:', error);
if (error.message.includes('Token reuse')) {
return res.status(401).json({
error: 'invalid_grant',
error_description: 'Token reuse detected. Please re-authenticate.'
});
}
return res.status(500).json({
error: 'server_error',
error_description: 'Internal server error'
});
}
});
// 3. Token Introspection (for resource servers)
app.post('/oauth/introspect', (req, res) => {
const { token } = req.body;
if (!token) {
return res.status(400).json({ active: false });
}
const payload = tokenService.verifyAccessToken(token);
if (payload) {
return res.json({
active: true,
scope: payload.roles?.join(' ') || 'read',
client_id: 'your-client-id',
token_type: 'Bearer',
exp: payload.exp,
iat: payload.iat,
sub: payload.sub
});
}
res.json({ active: false });
});
// 4. Token Revocation
app.post('/oauth/revoke', (req, res) => {
const { token } = req.body;
if (token) {
tokenService.invalidateToken(token);
}
// Per OAuth 2.1, always return success
res.json({});
});
// ============ Protected API Endpoints ============
// Example protected resource
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({
message: 'Access granted to protected resource',
user: req.user
});
});
// Example admin-only endpoint
app.get('/api/admin', authenticateToken, requireRole('admin'), (req, res) => {
res.json({
message: 'Admin access granted',
secret: 'This is admin-only data'
});
});
// Middleware: Authenticate JWT access token
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Access token required'
});
}
const payload = tokenService.verifyAccessToken(token);
if (!payload) {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired access token'
});
}
req.user = payload;
next();
}
// Middleware: Require specific role
function requireRole(role) {
return (req, res, next) => {
if (!req.user.roles || !req.user.roles.includes(role)) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: `Role '${role}' required`
});
}
next();
};
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`OAuth 2.1 Authorization Server running on port ${PORT}`);
});
5. Client Example (SPA/Frontend)
Here's how a client would use this OAuth 2.1 flow:
class OAuthClient {
constructor(config) {
this.config = config;
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
}
// Step 1: Initiate authorization with PKCE
async initiateAuth() {
// Generate PKCE code verifier and challenge
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
// Store verifier for token exchange (in sessionStorage)
sessionStorage.setItem('code_verifier', codeVerifier);
// Build authorization URL
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
scope: 'read write',
state: this.generateState(),
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${this.config.authUrl}?${params}`;
}
// Step 2: Exchange code for tokens
async exchangeCode(code) {
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch(this.config.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
code_verifier: codeVerifier
})
});
const tokens = await response.json();
if (tokens.access_token) {
this.setTokens(tokens);
sessionStorage.removeItem('code_verifier');
}
return tokens;
}
// Step 3: Refresh tokens with rotation
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(this.config.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken
})
});
const tokens = await response.json();
if (tokens.access_token) {
this.setTokens(tokens);
} else if (tokens.error) {
// Token rotation failed - clear tokens
this.clearTokens();
throw new Error(tokens.error_description);
}
return tokens;
}
// Automatic token refresh wrapper
async authenticatedFetch(url, options = {}) {
// Check if token needs refresh (5 minute buffer)
if (this.tokenExpiry && Date.now() > this.tokenExpiry - 300000) {
await this.refreshAccessToken();
}
options.headers = {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
};
return fetch(url, options);
}
setTokens(tokens) {
this.accessToken = tokens.access_token;
this.refreshToken = tokens.refresh_token;
this.tokenExpiry = Date.now() + (tokens.expires_in * 1000);
// Store securely (avoid localStorage for sensitive tokens)
sessionStorage.setItem('access_token', this.accessToken);
sessionStorage.setItem('refresh_token', this.refreshToken);
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token');
}
generateCodeVerifier() {
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.substring(0, 128);
}
async generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
generateState() {
return Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
}
Security Best Practices for 2026
1. Always Use PKCE
PKCE (Proof Key for Code Exchange) is now mandatory for all public clients in OAuth 2.1:
- Prevents authorization code interception attacks
- Protects against malicious redirect URIs
- Required for SPAs and mobile apps
2. Implement Refresh Token Rotation
Every time a refresh token is used, issue a new one and invalidate the old:
// Critical security measure
if (tokenData.rotated) {
// Attacker trying to reuse stolen token
invalidateTokenFamily(tokenData.family);
throw new Error('Token reuse detected');
}
3. Use Short-Lived Access Tokens
- Access tokens: 15-30 minutes maximum
- Refresh tokens: 7 days maximum
- Force re-authentication periodically
4. Use Asymmetric Keys (RS256) in Production
// Use RS256 in production - private key signs, public key verifies
const privateKey = fs.readFileSync('./keys/private.pem');
const publicKey = fs.readFileSync('./keys/public.pem');
jwt.sign(payload, privateKey, { algorithm: 'RS256' });
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
5. Validate All Token Claims
Always validate issuer, audience, and expiration:
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.yourdomain.com', // Required
audience: 'your-api-identifier', // Required
clockTolerance: 10 // Handle clock skew
});
Common Pitfalls to Avoid
- Don't store tokens in localStorage - Vulnerable to XSS. Use httpOnly cookies or sessionStorage
- Don't use HS256 in microservices - Use RS256 so only the auth server can sign
- Don't skip PKCE - Even for "trusted" clients, PKCE adds minimal overhead for major security gains
- Don't ignore token reuse - Always invalidate the entire token family on reuse detection
Conclusion
OAuth 2.1 represents the security best practices of 2026. By implementing:
- Mandatory PKCE for all public clients
- Refresh token rotation to detect theft
- Short-lived access tokens (15 minutes)
- Proper token validation (issuer, audience, expiration)
You're building an authentication system that protects against modern attack vectors while providing a smooth user experience.
The complete code above gives you a production-ready foundation. Just swap the in-memory storage for Redis or a database, configure proper CORS settings, and you're ready to deploy.
Tags: OAuth2, JWT, Node.js, API Security, Authentication, PKCE, Refresh Tokens, Web Security, 2026
Top comments (0)