Securing Modern Web Apps: JWT, PKCE, Token Storage & Revocation

Securing Modern Web Apps: JWT, PKCE, Token Storage & Revocation
30 views(2 unique)
18 min read
🔐 Security Deep Dive

Securing Modern Web Apps:
JWT, PKCE, Token Storage
& Revocation

A practical guide comparing JWE, Opaque Tokens, and Token Versioning — with real flow diagrams, storage trade-offs, and security recommendations for both architects and engineers.

Security Architecture Guide · · 25 min read

JWT Fundamentals — Anatomy & Threat Model

A JSON Web Token (JWT) is a compact, URL-safe way to represent claims between two parties. Every JWT has three dot-separated Base64url-encoded parts:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
Header (algorithm + type)
.
eyJzdWIiOiJtdXJhbGkiLCJlbWFpbCI6Ii4uLiIsInJvbGVzIjpbXX0
Payload (claims — readable by anyone)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Signature (RSA-256 — proves authenticity)
⚠️
The payload is NOT secret

Base64url is encoding, not encryption. Anyone who intercepts a JWT — or copies it from localStorage — can read every claim by running atob(token.split('.')[1]). The signature only proves the token was issued by the auth server; it does not hide the claims.

JWS vs JWE — Signed vs Encrypted

The standard distinguishes two token types that are often confused:

JWS — JSON Web Signature

  • Payload is signed but readable
  • Recipient verifies authenticity & integrity
  • Anyone with the token can read the claims
  • Format: header.payload.signature
  • Used by: 99% of JWT implementations
The standard choice

JWE — JSON Web Encryption

  • Payload is encrypted — claims are hidden
  • Only the holder of the decryption key can read claims
  • Format: header.enc_key.iv.ciphertext.tag
  • Algorithm: AES-256-GCM (authenticated encryption)
  • Used when claims contain secrets (API keys, licenses)
Specialist use only

The Real Threat Model

Before choosing any security mechanism, be precise about what you're protecting against:

Authorization Code + PKCE Flow

PKCE (Proof Key for Code Exchange, RFC 7636) is the mandatory security extension for public clients (SPAs, mobile apps) that cannot safely store a client secret. It prevents authorization code interception attacks by binding the code to a cryptographic challenge that only the legitimate client can answer.

ℹ️
Why can't SPAs use client secrets?

A client secret embedded in JavaScript is visible to anyone who opens DevTools. There is no such thing as a confidential browser client. PKCE replaces the secret with a per-request cryptographic proof that never leaves the client.

The 8-Step PKCE Dance

Key Points for Architects

  • The auth server never sees the code_verifier until the token exchange — it only stores the hash.
  • The state parameter is a CSRF nonce — verify it matches exactly before proceeding.
  • The authorization code is single-use and short-lived (typically 60 seconds).
  • PKCE makes the client_secret unnecessary for public clients — do not include it in SPA code.
  • The JWT returned contains all claims needed by the API — no separate user info call required.

Client Credentials Flow

The Client Credentials grant is for machine-to-machine communication — when a backend service needs to call another service, with no user involved. Think: auth server calling the resource API to sync a user profile, or a scheduled job calling the mail service.

🚨
Never use Client Credentials in a browser

Client Credentials requires a client_secret. A client secret in browser-side JavaScript is not a secret — it is public. Use Authorization Code + PKCE for all browser clients.

When to use Client Credentials

  • Auth server → downstream service (e.g. sync user profile)
  • Scheduled jobs (cleanup, report generation)
  • Internal microservices calling each other
  • CI/CD pipelines calling deployment APIs
Server-side only

When to use Auth Code + PKCE

  • Angular SPA (editor, viewer, admin)
  • Mobile apps (React Native, Flutter)
  • Any client where source code is visible
  • Any flow involving a human user
Browser + mobile

Token Revocation: JWE vs Opaque vs Token Versioning

JWTs are stateless by design. Once issued, a JWT is valid until it expires — the API verifies it cryptographically without contacting the auth server. This is efficient but creates a problem: how do you revoke a token before it expires?

Token Versioning: How It Works in Detail

Token Storage: localStorage vs sessionStorage vs httpOnly Cookies

Where you store the access token is one of the most consequential frontend security decisions. Each option has a genuinely different threat profile — there is no universally correct answer.

✅
Token storage migration path

Start with sessionStorage — it is tab-scoped, cleared when the tab closes, and meaningfully better than localStorage. Keep PKCE state (verifier, nonce, redirect) in localStorage — it must survive the cross-origin redirect to the auth server. The long-term target is an httpOnly SameSite=Strict cookie set server-side after the PKCE callback, which eliminates the XSS token-theft surface entirely.

Cross-Origin Token Hand-Off & Hash Security

When multiple frontend apps run on different origins (different ports in dev, same domain in prod), passing a token between them requires careful design. The naive approach — putting the token in a URL query parameter — is a serious vulnerability because query parameters appear in server logs, browser history, and Referer headers.

Avoid: Token in URL Query String

https://editor.app/callback
  ?access_token=eyJhbGc...
  &state=abc
  • Appears in server access logs
  • Stored in browser history
  • Leaked in Referer header to third parties
  • Captured by analytics scripts
Never do this

Better: URL Hash Fragment with Validation Guards

https://editor.app/callback
  #token=eyJhbGc...
  • Hash fragment not sent to servers
  • Validate exp, sub, iat before storing
  • Clear hash immediately with replaceState
  • Reject tokens older than 60 seconds (anti-replay)
Acceptable with guards

The Anti-Replay Guard (iat check)

The iat (issued-at) claim is the timestamp when the auth server signed the token. A legitimate PKCE hand-off happens within milliseconds. If you check that now - iat < 60 seconds, any token replayed from browser history, a server log, or a cached page is rejected — even if it hasn't expired yet.

// auth.service.ts — absorbHashToken() security guards
const payload = JSON.parse(atob(token.split('.')[1]));
const nowSec  = Date.now() / 1000;

// Guard 1: Token must not be expired
if (payload.exp && nowSec > payload.exp) { return; }

// Guard 2: Token must have a subject
if (!payload.sub) { return; }

// Guard 3: Anti-replay — reject if issued more than 60s ago
// Legitimate hand-offs happen in milliseconds. Replays fail.
if (payload.iat && nowSec - payload.iat > 60) { return; }

// Guard 4: Skip if valid token already exists (same-origin prod)
const existing = sessionStorage.getItem('app_token');
if (existing && decodeToken(existing) !== null) { return; }

sessionStorage.setItem('app_token', token);
window.history.replaceState({}, document.title, window.location.pathname);
💡
When the hash hand-off can be skipped entirely

If all your frontend apps share the same origin (e.g. app.example.com/editor and app.example.com/viewer), they already share sessionStorage within the same browser tab. The token written in the PKCE callback is immediately visible to all apps on that origin — no URL hash hand-off needed. The hash approach is only required in local development where apps run on different ports (different origins).

Security Recommendations Checklist

Auth Server

  • ✅
    Use RSA-256 or EC P-256 for JWT signing
    Persist the key pair to disk so it survives restarts. A new key on every restart invalidates all existing tokens. Plan a key rotation policy — rotate annually or immediately after a suspected compromise.
    Recommended
  • ✅
    Hash passwords with bcrypt, scrypt, or Argon2
    Never store plaintext, MD5, or SHA-1 passwords. bcrypt cost factor ≥10 is the current baseline; Argon2id is the OWASP recommended choice for new systems.
    Recommended
  • ✅
    Verify email addresses before activating accounts
    Issue a cryptographically random token (at least 128 bits), set a short expiry (24h), and keep the account in PENDING status until verified. Prevents account enumeration via registration.
    Recommended
  • ✅
    Rate-limit authentication endpoints per IP
    Block IPs after repeated failures using a sliding window. A common policy: 5 failures → 5-min block, 10 failures → 60-min block. Apply to login, registration, password reset, and token endpoints.
    Recommended
  • ✅
    Use PKCE for all browser and mobile OAuth2 flows
    RFC 7636 is mandatory for public clients. Never embed a client_secret in a SPA or mobile app. For social login (Google, GitHub), add prompt=select_account to force the account picker and prevent silent session reuse from a previous user's login.
    Recommended
  • ⚠️
    Use constant-time comparison for all secret validation
    String equality in most languages short-circuits on the first byte mismatch, leaking information via timing. Use a constant-time function (e.g. MessageDigest.isEqual() in Java, hmac.compare_digest() in Python, crypto.timingSafeEqual() in Node.js) for any comparison involving API keys, HMAC signatures, or internal service tokens.
    Important
  • ⚠️
    Plan a key rotation strategy
    RSA-2048 is still secure today but EC P-256 provides equivalent strength with significantly faster signing. Include key IDs (kid) in your JWKS from day one — this lets you publish multiple keys simultaneously and rotate without invalidating all existing tokens at once.
    Consider

Resource Server

  • ✅
    Verify JWTs cryptographically using the JWKS endpoint
    Fetch the auth server's public key from its JWKS URI on startup. Verify the RSA or EC signature locally on every request — no per-request call to the auth server. Cache keys in memory; re-fetch when you encounter an unknown key ID (kid).
    Recommended
  • ✅
    Implement token versioning for near-instant revocation
    Embed a version counter in the JWT at issuance. Increment it in the auth server's database whenever roles change or accounts are suspended. The resource server fetches the current version periodically (e.g. 5-minute cache) and rejects tokens whose embedded version is stale. This gives revocation within minutes at near-zero performance cost.
    Recommended
  • ✅
    Apply token version checks consistently across all protected endpoints
    It is a common mistake to check revocation only on high-privilege (admin) endpoints. Apply the same check to all authenticated write operations — create, update, delete, publish — so a suspended account cannot continue writing even on lower-risk paths.
    Recommended
  • ✅
    Rate-limit write endpoints per authenticated identity
    Apply limits per IP for unauthenticated paths and per user ID for authenticated ones. Reasonable defaults: 10–30 POST/PUT/DELETE requests per minute per user. Return 429 with a Retry-After header. Log repeated violations.
    Recommended
  • ⚠️
    Fail safely when the auth server is unreachable
    If your token version check calls the auth server and the call fails, decide explicitly: fail-open (allow the request using a cached value) or fail-closed (deny). Fail-open is the common default for availability, but it means a revoked token remains valid during an auth server outage. Use the most recent cached version as a fallback, not a hard-coded permissive default.
    Important
  • ⚠️
    Protect internal service-to-service endpoints
    Internal APIs called between your own services should require a pre-shared secret sent in a request header (e.g. X-Internal-Key). Validate it with constant-time comparison. These endpoints should never be reachable from the public internet — enforce this at the network/firewall level in addition to application-level auth.
    Recommended

Browser Client (SPA)

  • ✅
    Store tokens in sessionStorage, not localStorage
    sessionStorage is tab-scoped and cleared when the tab closes, reducing the XSS attack window from "until the JWT expires" to "while the tab is open". An attacker who steals a token via XSS cannot persist it across browser sessions or access it from a different tab.
    Recommended
  • 🎯
    Target: store tokens in httpOnly SameSite=Strict cookies
    This is the gold standard. The auth server sets the cookie after the PKCE callback; JavaScript never sees the token at all, eliminating the XSS token-theft surface. Requires CSRF protection — use the Double Submit Cookie pattern or the Synchronizer Token pattern.
    Best practice
  • ✅
    Keep PKCE state in localStorage, token in sessionStorage
    The PKCE code_verifier, state nonce, and redirect target must survive the full-page redirect to the auth server and back — sessionStorage is wiped by cross-origin navigation. The access token itself does not need to survive cross-origin navigation, so sessionStorage is safe.
    Recommended
  • ✅
    Validate PKCE state nonce before exchanging the code
    Compare the returned state parameter exactly against the stored value before calling the token endpoint. A mismatch indicates a CSRF attack or a replayed callback — abort the flow immediately.
    Recommended
  • ✅
    Validate all claims when absorbing a URL hash token
    If you pass tokens via URL fragment between apps, validate: (1) 3-part JWT structure, (2) token is not expired, (3) sub claim is present, (4) issued-at (iat) gap is <60 seconds. The iat check prevents replay from browser history. Clear the hash with replaceState immediately after.
    Recommended
  • ✅
    Deploy a strict Content Security Policy
    A CSP with script-src, connect-src, and style-src limits the blast radius of XSS. An XSS that cannot exfiltrate data is far less dangerous than one with open network access. Apply CSP at the HTTP header level (not only via meta tags) so it applies to all responses.
    Recommended

Decision Guide: Which Strategy for Which Scenario

Scenario Recommended approach Why
SPA needs user authentication Auth Code + PKCE No client secret in browser. Code interception protected. Standard RFC 7636.
Service-to-service (no user) Client Credentials No browser involved. client_secret stays server-side over TLS.
JWT claims contain user IDs, email, roles JWS (signed JWT) Claims are attributes, not secrets. Encryption adds cost with no threat coverage.
JWT claims contain API keys or PII JWE (encrypted JWT) Claims are genuine secrets. Bearer should not be able to read them.
Role revoked — stop access within minutes Token versioning 5-min cache TTL. Near-zero overhead. 95% of opaque token security.
Token revocation must be instant (banking) Opaque tokens DB lookup per request. Instant revocation. Accept the latency cost.
Browser token storage — today sessionStorage Tab-scoped. Cleared on tab close. Better than localStorage, worse than httpOnly cookie.
Browser token storage — target (v2) httpOnly SameSite=Strict cookie JavaScript cannot read it. Immune to XSS token theft. Requires CSRF mitigation.
Cross-origin token hand-off (dev) URL hash with guards Hash not sent to servers. Validate exp, sub, iat gap. Clear immediately with replaceState.
Cross-origin token hand-off (prod) Shared origin — no hand-off needed All MFEs on example.com share sessionStorage in the same tab. No token passing required.
🏗
Architect's summary in one sentence per topic

JWE: Only encrypt claims that are genuine secrets (embedded API keys, license codes). User attributes — ID, email, roles — are not secrets and do not need encryption.
Opaque tokens: Instant revocation with a database round-trip on every request. Use in regulated industries where zero-delay revocation is non-negotiable.
Token versioning: Near-instant revocation (within a configurable cache TTL) at near-zero cost. The right default for most web platforms.
localStorage: Never for access tokens in production. XSS compromises the token indefinitely.
sessionStorage: Good default — tab-scoped, clears on close. Still JavaScript-accessible, so XSS can steal within the session.
httpOnly cookie: Gold standard — JavaScript cannot touch it. Requires CSRF protection.
PKCE: Mandatory for all browser and mobile clients, no exceptions. Replaces client_secret for public clients.
Client Credentials: Backend services only. A client_secret in frontend code is not a secret.

Discussions

No discussions yet. Be the first to start one.

H

htadmin

Writer on Ullek

0 articles
0 followers
Writer on the Ullek platform.