OAuth2 Flows Explained
Authorization Code + PKCE, Client Credentials, and Refresh Token Rotation — with complete Node.js implementations
What is OAuth2?
OAuth2 (RFC 6749) is an authorization framework — not an authentication protocol. It lets a user (Resource Owner) grant a third-party application (Client) limited access to their data on a server (Resource Server), without sharing their password.
The key distinction: OAuth2 answers "what are you allowed to do?" not "who are you?". For authentication (identity), you layer OpenID Connect (OIDC) on top of OAuth2, which adds an id_token and a /userinfo endpoint.
The Grant Types
OAuth2 defines several grant types (flows) for different use cases. In 2026, only three are recommended — the others are deprecated for security reasons:
| Grant Type | Use Case | Involves User? | Status |
|---|---|---|---|
| Authorization Code + PKCE | Web apps, mobile apps, SPAs — any user-facing client | ✅ Yes | ✅ Recommended |
| Client Credentials | Machine-to-machine (M2M) — services, cron jobs, CI/CD | ❌ No | ✅ Recommended |
| Device Code | Smart TVs, CLIs, devices with no browser | ✅ Yes (on another device) | ✅ Recommended |
| Refresh Token | Silent token renewal (used alongside other grants) | — | ✅ Use with rotation |
| Implicit | Was used for SPAs — replaced by Auth Code + PKCE | ✅ Yes | ❌ Deprecated |
| Resource Owner Password | Was used for first-party apps — avoid | ✅ Yes | ❌ Deprecated |
Authorization Code Flow + PKCE
PKCE (Proof Key for Code Exchange, RFC 7636) is now mandatory for all public clients and strongly recommended for confidential clients too (OAuth 2.1 draft). It prevents authorization code interception attacks.
Why PKCE?
The classic Authorization Code flow had a vulnerability: if an attacker intercepts the authorization code (possible via malicious apps, URL handlers, or browser history), they can exchange it for tokens. PKCE closes this by requiring the client to prove it generated the original code:
Node.js Implementation (Express)
const express = require('express');
const crypto = require('crypto');
const { URLSearchParams } = require('url');
const app = express();
app.use(require('cookie-parser')());
app.use(express.json());
const AUTH_SERVER = 'https://auth.example.com';
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const REDIRECT_URI = 'https://app.example.com/callback';
// ── Step 1: Initiate login ────────────────────────────────────────────
app.get('/login', (req, res) => {
// Generate PKCE pair
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
// CSRF protection
const state = crypto.randomBytes(16).toString('hex');
// Store in short-lived cookie (HttpOnly, Secure, SameSite=Lax)
res.cookie('pkce_verifier', verifier, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 300_000 // 5 min
});
res.cookie('oauth_state', state, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 300_000
});
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
res.redirect(`${AUTH_SERVER}/authorize?${params}`);
});
// ── Step 2: Handle callback ────────────────────────────────────────────
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Validate state (CSRF check)
if (!state || state !== req.cookies.oauth_state) {
return res.status(400).send('Invalid state parameter');
}
const verifier = req.cookies.pkce_verifier;
if (!verifier) return res.status(400).send('Missing PKCE verifier');
// Clear PKCE cookies
res.clearCookie('pkce_verifier');
res.clearCookie('oauth_state');
// Exchange code for tokens
const tokenRes = await fetch(`${AUTH_SERVER}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier
})
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
return res.status(400).json({ error: 'Token exchange failed', details: err });
}
const { access_token, refresh_token, expires_in } = await tokenRes.json();
// Store refresh token in HttpOnly cookie, access token in memory / short cookie
res.cookie('refresh_token', refresh_token, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
res.json({ access_token, expires_in });
});
Client Credentials Flow
The Client Credentials flow is for machine-to-machine (M2M) communication — there is no user involved. Services, cron jobs, and CI/CD pipelines use this to authenticate with APIs on their own behalf.
// Token manager with automatic caching and proactive refresh
class OAuth2TokenManager {
#clientId;
#clientSecret;
#tokenUrl;
#token = null;
#expiresAt = 0;
#refreshing = null; // dedup concurrent refresh calls
constructor({ clientId, clientSecret, tokenUrl, scope }) {
this.#clientId = clientId;
this.#clientSecret = clientSecret;
this.#tokenUrl = tokenUrl;
this.scope = scope;
}
async getToken() {
// Refresh 60s before expiry (proactive refresh)
if (this.#token && Date.now() < this.#expiresAt - 60_000) {
return this.#token;
}
// Deduplicate concurrent calls
if (!this.#refreshing) {
this.#refreshing = this.#fetchToken().finally(() => {
this.#refreshing = null;
});
}
return this.#refreshing;
}
async #fetchToken() {
const res = await fetch(this.#tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.#clientId,
client_secret: this.#clientSecret,
scope: this.scope
})
});
if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
const { access_token, expires_in } = await res.json();
this.#token = access_token;
this.#expiresAt = Date.now() + expires_in * 1000;
return this.#token;
}
}
// Usage in a service
const tokenManager = new OAuth2TokenManager({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
tokenUrl: 'https://auth.example.com/oauth/token',
scope: 'orders:read inventory:write'
});
async function callInventoryAPI(productId) {
const token = await tokenManager.getToken();
return fetch(`https://api.example.com/inventory/${productId}`, {
headers: { Authorization: `Bearer ${token}` }
});
}
client_secret in client-side code, environment variables committed to git, or Docker images. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, or a Kubernetes secret).
Refresh Token Rotation
Refresh tokens allow users to stay logged in without re-entering credentials. Rotation means issuing a new refresh token every time the old one is used — and immediately invalidating the old one. This enables reuse detection: if a stolen refresh token is used after the legitimate client has already rotated it, the server detects the duplicate and revokes the entire token family.
const express = require('express');
const crypto = require('crypto');
const redis = require('redis');
const app = express();
const cache = redis.createClient();
// ── Refresh token rotation endpoint ────────────────────────────────────
app.post('/auth/refresh', async (req, res) => {
const incomingRefreshToken = req.cookies.refresh_token;
if (!incomingRefreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
const tokenKey = `rt:${incomingRefreshToken}`;
const tokenData = await cache.get(tokenKey);
if (!tokenData) {
// Token not found — either expired or already used (possible theft)
// Check if this token was previously rotated (reuse detection)
const reuseKey = `rt:rotated:${incomingRefreshToken}`;
const wasRotated = await cache.get(reuseKey);
if (wasRotated) {
// REUSE DETECTED: revoke entire token family
const { familyId } = JSON.parse(wasRotated);
await revokeTokenFamily(familyId);
return res.status(401).json({ error: 'Refresh token reuse detected — please log in again' });
}
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
const { userId, familyId, scope } = JSON.parse(tokenData);
// Issue new access token
const newAccessToken = generateAccessToken(userId, scope);
// Issue new refresh token
const newRefreshToken = crypto.randomBytes(32).toString('base64url');
const refreshTTL = 30 * 24 * 60 * 60; // 30 days
// Atomically: store new token, mark old as rotated, delete old active entry
await Promise.all([
cache.setEx(`rt:${newRefreshToken}`, refreshTTL,
JSON.stringify({ userId, familyId, scope })),
cache.setEx(`rt:rotated:${incomingRefreshToken}`, refreshTTL,
JSON.stringify({ familyId })), // keep for reuse detection
cache.del(tokenKey) // invalidate old token
]);
// Return new refresh token in HttpOnly cookie
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: refreshTTL * 1000
});
res.json({ access_token: newAccessToken, expires_in: 900 });
});
async function revokeTokenFamily(familyId) {
// In production: scan for all tokens with this familyId and delete them
// Simpler: store revoked family IDs in a set with TTL
await cache.setEx(`family:revoked:${familyId}`, 30 * 24 * 60 * 60, '1');
}
JWT vs Opaque Tokens
OAuth2 does not mandate any particular token format. The two common choices are JWT (self-contained) and opaque (reference) tokens:
| Property | JWT (Self-Contained) | Opaque Token |
|---|---|---|
| Verification | Signature check only — no network call | Introspection endpoint call required |
| Revocation | Hard — must wait for expiry or use a block list | Easy — delete from store |
| Payload | Claims visible to anyone with the token | Opaque — no leaked data |
| Scalability | ✅ Excellent — stateless, no DB per request | Requires shared token store |
| Token size | ~300–500 bytes | ~32–64 bytes |
| Best for | Microservices, API gateways, high throughput | Sessions requiring instant revocation |
For most REST APIs, JWTs are the right choice for access tokens (short-lived, stateless verification). Use opaque tokens for refresh tokens stored in your database or Redis where instant revocation matters.
Scopes and Claims
Scopes define the permissions the client is requesting. Claims are key-value pairs inside the JWT payload carrying context about the token.
// Scope definition: coarse-grained
const SCOPES = {
'orders:read': 'Read your orders',
'orders:write': 'Create and update orders',
'profile:read': 'Read your profile information',
'admin': 'Full administrative access'
};
// JWT payload (claims) — what you'll verify on your API
{
"iss": "https://auth.example.com", // issuer
"sub": "user_abc123", // subject (user ID)
"aud": "https://api.example.com", // audience (your API)
"exp": 1715000000, // expiry (Unix timestamp)
"iat": 1714999100, // issued at
"scope": "orders:read profile:read", // granted scopes
"email": "alice@example.com",
"org_id": "org_xyz" // custom claim
}
// Middleware: verify JWT + check scope
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true, cacheMaxAge: 3600_000
});
function requireScope(requiredScope) {
return async (req, res, next) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const decoded = await verifyJWT(token);
const grantedScopes = (decoded.scope || '').split(' ');
if (!grantedScopes.includes(requiredScope)) {
return res.status(403).json({
error: 'insufficient_scope',
required: requiredScope
});
}
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token', message: err.message });
}
};
}
// Usage
app.get('/orders', requireScope('orders:read'), listOrders);
app.post('/orders', requireScope('orders:write'), createOrder);
OAuth2 Security Checklist
- Always use PKCE for public clients (SPAs, mobile apps)
- Validate
stateparameter on every callback to prevent CSRF - Verify
iss,aud,exp, andnbfclaims on every JWT - Use short-lived access tokens (5–15 minutes)
- Store refresh tokens in
HttpOnly,Secure,SameSite=Strictcookies — never localStorage - Implement refresh token rotation with reuse detection
- Use HTTPS everywhere — never OAuth2 over HTTP
- Register exact redirect URIs — never allow wildcards
- Rotate client secrets periodically
- Use JWKS endpoint for key rotation (never hardcode public keys)
- Avoid Implicit flow — use Authorization Code + PKCE instead
- Never use Resource Owner Password Credentials (ROPC)
- Never store client_secret in client-side code or git repos
Frequently Asked Questions
What is the difference between OAuth2 and JWT?
OAuth2 is an authorization framework (a protocol for granting access). JWT is a token format. They are complementary: OAuth2 defines the flows and endpoints for obtaining tokens, while JWT is one format those tokens can take. You can use OAuth2 without JWTs (opaque tokens) or use JWTs without OAuth2.
Why is PKCE required for public clients?
Public clients (SPAs, mobile apps) cannot securely store a client secret because their code is exposed to the user. PKCE replaces the client secret with a dynamically generated code_verifier and code_challenge pair, preventing authorization code interception attacks without needing a shared secret.
When should I use Client Credentials vs Authorization Code flow?
Use Client Credentials for machine-to-machine communication where there is no human user — cron jobs, microservices, CI/CD pipelines. Use Authorization Code + PKCE when a human user delegates access — web apps, mobile apps, third-party integrations.
How long should OAuth2 access tokens be valid?
Access tokens should be short-lived: 5–15 minutes for high-security APIs, up to 1 hour for standard use cases. Short expiry limits the blast radius if a token is stolen. Pair short access tokens with longer-lived refresh tokens (7–90 days) stored in HttpOnly cookies.
What is refresh token rotation?
Refresh token rotation means issuing a new refresh token every time the old one is used. The old token is immediately invalidated. This provides reuse detection: if an attacker uses a stolen refresh token after the legitimate client already rotated it, the server detects the reuse and revokes the entire token family.
Related Topics
For a broader overview of authentication methods including API keys and JWT basics, see our Authentication guide. OAuth2 tokens at the API gateway layer enable centralized introspection for all microservices. For microservices calling each other, use the Client Credentials flow to obtain service-scoped tokens.