OAuth 2.0 & JWT Best Practices
Status: Complete
Category: Security
Default enforcement: Hard
Author: PushBackLog team
Tags
- Topic: security, authentication
- Skillset: backend, frontend
- Technology: generic
- Stage: execution, review
Summary
OAuth 2.0 is the industry-standard protocol for delegated authorisation; JWT (JSON Web Token) is the most commonly used token format in modern authentication systems. Both are widely implemented and widely misconfigured. The most serious failures involve accepting tokens without full validation, storing tokens insecurely, using long expiry windows without refresh rotation, and implementing custom authentication flows instead of using established libraries. Follow the authoritative guidance from IETF RFC 6749, RFC 7519, and the OAuth 2.0 Security Best Current Practice (RFC 9700).
Rationale
Authentication vulnerabilities are disproportionately severe
Authentication and authorisation failures are OWASP A07. A bypassed authentication check grants an attacker access to the entire system. A misconfigured JWT library that accepts unsigned tokens (the alg: none attack) makes every private resource public. A missing audience validation allows a token issued for one service to be used against another. These are not exotic attack vectors — they are documented, recurring failures with severe consequences.
Rolling your own auth is inadvisable
Authentication flows are complex, and the failure modes are not always obvious. JWT signature validation, PKCE, state parameter handling, redirect URI validation, and secure token storage each require careful implementation. Libraries and managed identity providers have been battle-tested across millions of deployments; custom implementations have not.
Guidance
JWT validation checklist
Every JWT received by a server must be validated against all of the following:
| Check | Why it matters | Attack prevented |
|---|---|---|
| Signature | Verifies the token was issued by a trusted issuer | Forged tokens |
Algorithm (alg) | Must be explicitly allowed; never accept alg: none | Algorithm confusion attack |
Expiry (exp) | Must be in the future | Replay of expired tokens |
Issuer (iss) | Must match the expected issuer URL | Tokens from other systems |
Audience (aud) | Must match the receiving service | Token substitution across services |
Subject (sub) | Must correspond to a valid, active user | Tokens for deleted/suspended accounts |
// Correct JWT validation (using jose library)
import { jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
async function validateAccessToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
algorithms: ['RS256'], // Explicit algorithm allowlist — never ['*'] or ['none']
clockTolerance: '30s', // Small tolerance for clock skew only
});
// Additional check: user account is still active
const user = await userRepository.findById(payload.sub!);
if (!user || user.isDeactivated) {
throw new Error('User account is not active');
}
return payload;
}
Token storage and transmission
| Token type | Storage | Transmission |
|---|---|---|
| Access token | Memory (JS variable) or httpOnly cookie | Authorization: Bearer <token> header |
| Refresh token | httpOnly, Secure, SameSite=Strict cookie | Sent with cookie automatically |
Never store tokens in localStorage or sessionStorage — they are accessible to any JavaScript on the page, including third-party scripts and XSS payloads.
// Correct cookie settings for refresh tokens
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // Narrow path scope
});
Token expiry and refresh rotation
| Token | Recommended expiry |
|---|---|
| Access token | 15 minutes |
| Refresh token | 7–30 days (depending on security requirements) |
Use refresh token rotation: each use of a refresh token issues a new refresh token and invalidates the old one. If an old refresh token is used again (possible replay attack), immediately invalidate the entire refresh token family.
OAuth 2.0 flows and when to use each
| Flow | Use when |
|---|---|
| Authorization Code + PKCE | All user-facing applications (web, mobile, SPA) — the standard modern flow |
| Client Credentials | Service-to-service (machine-to-machine) authentication without a user |
| Device Code | Devices with limited input (smart TVs, CLI tools) |
| Implicit (deprecated) | Never — replaced by Auth Code + PKCE |
| Resource Owner Password Credentials (deprecated) | Never — passes credentials to client app |
PKCE (Proof Key for Code Exchange)
PKCE is mandatory for public clients (SPAs, mobile apps) and strongly recommended for all clients:
// Generate PKCE pair
const codeVerifier = generateCodeVerifier(); // Random base64url string
const codeChallenge = await computeCodeChallenge(codeVerifier); // SHA-256 hash
// Include in authorisation request
const authUrl = buildAuthUrl({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: generateState(), // CSRF token
});
// Include verifier in token exchange
const tokens = await exchangeCode({ code, code_verifier: codeVerifier });
Review checklist
- JWT validation checks all required claims:
alg,iss,aud,exp,sub - Algorithm is explicitly allowlisted —
alg: noneis never accepted - Access tokens expire in ≤ 15 minutes
- Refresh tokens use rotation — replayed refresh tokens trigger session revocation
- Tokens are not stored in localStorage or sessionStorage
- Refresh tokens are stored in httpOnly, Secure, SameSite=Strict cookies
- All OAuth flows use PKCE
- State parameter is validated to prevent CSRF in auth code flow
- Redirect URIs are pre-registered and exactly matched — no wildcards