PushBackLog

OAuth 2.0 & JWT Best Practices

Hard enforcement Complete by PushBackLog team
Topic: security Topic: authentication Skillset: backend Skillset: frontend Technology: generic Stage: execution Stage: review

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:

CheckWhy it mattersAttack prevented
SignatureVerifies the token was issued by a trusted issuerForged tokens
Algorithm (alg)Must be explicitly allowed; never accept alg: noneAlgorithm confusion attack
Expiry (exp)Must be in the futureReplay of expired tokens
Issuer (iss)Must match the expected issuer URLTokens from other systems
Audience (aud)Must match the receiving serviceToken substitution across services
Subject (sub)Must correspond to a valid, active userTokens 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 typeStorageTransmission
Access tokenMemory (JS variable) or httpOnly cookieAuthorization: Bearer <token> header
Refresh tokenhttpOnly, Secure, SameSite=Strict cookieSent 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

TokenRecommended expiry
Access token15 minutes
Refresh token7–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

FlowUse when
Authorization Code + PKCEAll user-facing applications (web, mobile, SPA) — the standard modern flow
Client CredentialsService-to-service (machine-to-machine) authentication without a user
Device CodeDevices 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: none is 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