SecurityAuthenticationJune 25, 2026 ยท 26 min read

JWT Authentication Deep Dive

JWTs are everywhere โ€” and so are JWT implementation bugs. This guide covers the full token lifecycle: how they're structured, how to validate them correctly, when to use refresh tokens, and the security pitfalls that catch developers off guard.

By the numbers

RFC 7519JWT standard
3 partsHeader ยท Payload ยท Sig
HS256+Common algorithms
2011JWT introduced

Anatomy of a JWT โ€” decoded

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxOTAwMDAwMCwiZXhwIjoxNzE5MDAzNjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

โ— Header

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

Declares the token type and signing algorithm.

โ— Payload

{ "sub": "user_123", "name": "Alice", "role": "admin", "iat": 1719000000, "exp": 1719003600 }

The claims โ€” who the token is for and what it asserts.

โ— Signature

HMACSHA256(base64(header) + "." + base64(payload), secret)

Cryptographic proof the token hasn't been tampered with.

โš ๏ธ The payload is Base64-encoded, not encrypted. Never put secrets in JWT claims.

What Is a JWT?

A JSON Web Token is a compact, self-contained string that carries information about a user or system in a format that can be independently verified. The key word is self-contained: unlike session cookies that require a database lookup on every request, a JWT encodes the user's identity and permissions directly inside the token โ€” and proves they haven't been altered using a cryptographic signature.

When a user logs in, your server creates a JWT signed with a secret key (or private key) and sends it back. On every subsequent request, the client includes that token. The server verifies the signature โ€” without touching a database โ€” and trusts the claims inside. This makes JWTs particularly attractive for stateless architectures, APIs, and microservices where you want to avoid session storage.

The trade-off: JWTs can't be invalidated before they expire. If you issue a token and the user logs out or their permissions change, the token is still technically valid until its expiry. Managing this is the central challenge of JWT-based auth systems.

AspectSession CookiesJWTs
StorageServer-side (DB or cache)Client-side (token itself)
ScalabilityRequires shared session store across serversStateless โ€” any server can verify
RevocationInstant โ€” delete the sessionDifficult โ€” must wait for expiry or use blocklist
PayloadSession ID onlyClaims embedded (user ID, roles, etc.)
Best forMonoliths, traditional web appsAPIs, microservices, mobile apps

JWT Structure in Detail

Every JWT is three Base64URL-encoded JSON objects joined by dots. Understanding what lives in each part โ€” and what the limitations are โ€” is fundamental to implementing JWTs correctly.

Header

alg

The signing algorithm โ€” e.g. HS256, RS256, ES256. This tells the receiver which algorithm to use when verifying the signature.

typ

Token type โ€” almost always "JWT". Some implementations also use "at+JWT" for access tokens per newer standards.

โš  Never trust the "alg" field without validating it server-side. The alg:none attack exploits servers that accept whatever algorithm the token claims to use.

Payload

sub

Subject โ€” uniquely identifies the principal this token refers to. Typically a user ID.

iss

Issuer โ€” identifies who created the token. Useful in multi-issuer environments.

aud

Audience โ€” identifies who this token is intended for. Validate this to prevent token reuse across services.

iat

Issued at โ€” Unix timestamp of when the token was created.

exp

Expiry โ€” Unix timestamp after which the token must be rejected. Always include this.

nbf

Not before โ€” token is invalid before this timestamp. Optional but useful for delayed activation.

โš  The payload is only Base64 encoded โ€” anyone can decode it. Never store passwords, secrets, or sensitive PII in JWT claims.

Signature

Construction

ALGORITHM(base64url(header) + '.' + base64url(payload), secret_or_private_key)

Purpose

Guarantees the header and payload haven't been modified since the token was issued. Without a valid signature, the token is worthless.

โš  The signature only proves integrity โ€” not confidentiality. If you need the payload to be unreadable, use JWE (JSON Web Encryption) instead of plain JWTs.

Signing Algorithms Compared

The algorithm you choose affects your security model, your infrastructure requirements, and what attack surface you expose. There are two families worth understanding.

Symmetric (HMAC)

HS256HS384HS512

One shared secret key โ€” used to both sign and verify.

Pros

  • โœ“Simple to implement
  • โœ“Fast to verify
  • โœ“No key infrastructure needed

Cons

  • โœ—Secret must be shared with every verifying service
  • โœ—Compromise of one service exposes the key to all

๐Ÿ’ก Single-service APIs, internal microservices where you control all verifiers.

Asymmetric (RSA / ECDSA)

RS256RS384ES256ES384

Private key signs; public key verifies. The private key never leaves your auth server.

Pros

  • โœ“Services only need the public key to verify
  • โœ“Compromise of a consumer service doesn't expose signing key
  • โœ“Public key can be published via JWKS endpoint

Cons

  • โœ—More complex key management
  • โœ—Slightly slower verification
  • โœ—RSA keys should be 2048+ bits

๐Ÿ’ก Multi-service architectures, third-party token consumers, OAuth/OIDC implementations.

๐Ÿšซ Never use the "none" algorithm in production

The JWT spec allows alg: none, meaning an unsigned token. Some libraries, if not configured correctly, will accept these tokens as valid. Always explicitly allowlist the algorithms your server will accept and reject anything else โ€” including none.

The Full Token Lifecycle

Understanding the end-to-end lifecycle โ€” from user login to token expiry โ€” helps you design auth flows that are both secure and usable.

1

User authenticates

The user submits credentials (username/password, OAuth code, biometric, etc.) to your auth endpoint. You verify those credentials against your user store.

POST /auth/login  โ†’  { username, password }
2

Server issues access token (+ refresh token)

On success, generate a short-lived access token (15 minutes to 1 hour is common) signed with your secret or private key. Optionally generate a longer-lived refresh token for getting new access tokens without re-authentication.

{ access_token: "eyJ...", refresh_token: "...", expires_in: 900 }
3

Client stores tokens

The access token is typically held in memory (JavaScript variable). The refresh token, if used, goes in an HttpOnly cookie or secure storage. More on this in the storage section.

4

Client sends access token with each request

Every API request includes the access token in the Authorization header. This is the Bearer token pattern.

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
5

Server validates the token

The server verifies: signature integrity, algorithm matches expected, token is not expired, audience matches this service, and any custom claims are valid. If all checks pass, the request is processed.

verify(token, secret, { algorithms: ['HS256'], audience: 'api.myapp.com' })
6

Access token expires โ€” refresh flow triggers

When the access token expires, the client uses the refresh token to request a new access token from the auth server โ€” without requiring the user to log in again.

POST /auth/refresh  โ†’  { refresh_token: "..." }
7

User logs out

The client discards the tokens. Because JWTs are stateless, the server-side impact depends on your architecture โ€” see the revocation discussion in the refresh token section.

Server-Side Validation: Every Check That Matters

Receiving a JWT is not the same as trusting it. Validation is not one step โ€” it's a sequence of checks, each of which can independently fail. Skip one and you may have a security hole.

Verify the signatureCritical

Use your JWT library's verify function โ€” not decode. Verify rejects tampered tokens. Decode does not. This is the most common JWT mistake: calling the wrong function.

// โŒ Wrong โ€” doesn't verify signature
const payload = jwt.decode(token);

// โœ… Correct โ€” throws if signature invalid
const payload = jwt.verify(token, secret);
Validate the algorithmCritical

Explicitly specify which algorithms are acceptable. If your server issues HS256 tokens, reject RS256 tokens โ€” and always reject "none". Many libraries accept whatever the token header says by default.

jwt.verify(token, secret, { algorithms: ['HS256'] });
Check expiry (exp claim)Critical

Most libraries do this automatically when you use verify(). But double-check. Allow a small clock skew tolerance (30โ€“60 seconds) to handle slight time differences between servers.

jwt.verify(token, secret, { clockTolerance: 30 });
Validate the audience (aud claim)

If you include an audience in your tokens, verify it on every request. This prevents a valid token issued for Service A from being replayed against Service B.

jwt.verify(token, secret, { audience: 'api.myapp.com' });
Validate the issuer (iss claim)

Particularly important in multi-issuer environments or when consuming third-party tokens (e.g., Google, Auth0). Reject tokens from unexpected issuers.

jwt.verify(token, secret, { issuer: 'https://auth.myapp.com' });
Check token revocation (if needed)

If you maintain a token blocklist (for logout or compromise scenarios), check it here. This adds latency since it requires a DB or cache lookup โ€” which is why most systems use short-lived access tokens to minimize this need.

const isRevoked = await tokenBlocklist.has(payload.jti);
if (isRevoked) throw new Error('Token revoked');

Refresh Tokens and Rotation

Access tokens are intentionally short-lived โ€” typically 15 minutes to one hour. This limits the damage if a token is stolen: it expires quickly. But you don't want to force users to log in every 15 minutes. That's where refresh tokens come in.

A refresh token is a longer-lived credential (days to weeks) that can be exchanged for a new access token without requiring the user's password again. It's stored more securely than the access token and is only sent to the auth server โ€” never to regular API endpoints.

Refresh token rotation

The security-conscious pattern is refresh token rotation: every time a refresh token is used, it's immediately invalidated and a new one is issued. This enables detection of token theft: if an attacker and the legitimate user both try to use copies of the same refresh token, the second use will fail โ€” and you can invalidate the entire token family, logging the user out everywhere.

// Refresh token rotation flow

1. Client sends

POST /auth/refresh โ†’ { refresh_token: "RT_abc123" }

2. Server verifies RT_abc123 and invalidates it

db.revokeRefreshToken("RT_abc123")

3. Server issues new token pair

{ access_token: "AT_new...", refresh_token: "RT_xyz789" }

4. If RT_abc123 used again โ†’ token family revoked

// Possible theft detected โ€” revoke ALL tokens for this user

๐Ÿ’ก Short access token + rotation = practical security

Access tokens of 15โ€“60 minutes, combined with refresh token rotation and an HttpOnly cookie for the refresh token, is the current industry best practice for browser-based apps. It dramatically limits the window of token theft while maintaining a seamless user experience.

Token revocation strategies

Short expiry

Simple

Accept that tokens live until they expire. Fast, zero infrastructure, appropriate when access tokens are very short-lived (< 5 min).

Token blocklist

Balanced

Maintain a set of revoked token JTI (JWT ID) claims in Redis or a fast cache. Check on every request. Adds ~1ms of latency.

Token versioning

Flexible

Store a token version per user in your DB. Include it in the JWT. Reject any token with a version lower than the current one.

Where to Store JWTs

Token storage is one of the most debated topics in JWT security โ€” and the right answer depends on your threat model. Here are the three common options and their trade-offs.

Memory (JS variable)

Best

XSS risk

โœ“ Protected

CSRF risk

โœ“ Protected

Persistence

โœ— Lost on refresh

Store the access token in a JavaScript variable (React state, a module-level variable, etc.). Never survives a page refresh, so pair with a refresh token in an HttpOnly cookie. The most XSS-resistant option.

HttpOnly Cookie

Good

XSS risk

โœ“ Protected

CSRF risk

โœ— Vulnerable (use CSRF token)

Persistence

โœ“ Survives refresh

Cookies with the HttpOnly flag cannot be read by JavaScript, which protects against XSS theft. But cookies are automatically sent with requests, so you must add CSRF protection (SameSite=Strict or a CSRF token). Best option for refresh tokens.

localStorage / sessionStorage

Avoid

XSS risk

โœ— Vulnerable

CSRF risk

โœ“ Protected

Persistence

โœ“ Survives refresh

Accessible by any JavaScript on the page. A single XSS vulnerability in any dependency exposes every token stored here. Despite being widely used, this is the least secure storage option for sensitive tokens.

โœ… Recommended pattern for browser apps

Access token in memory (cleared on page refresh) + refresh token in an HttpOnly; Secure; SameSite=Strict cookie. On page load, use the refresh token cookie to silently obtain a new access token. This combines XSS resistance for the sensitive short-lived token with CSRF resistance for the cookie.

Security Mistakes to Avoid

โœ— Using jwt.decode() instead of jwt.verify()

Critical

decode() simply reads the payload without checking the signature. Any attacker can craft a payload claiming to be an admin and it will be accepted. Always use verify().

โœ— Storing sensitive data in the payload

High

The JWT payload is Base64 encoded โ€” anyone with the token can read it instantly using jwt.io or any Base64 decoder. Never store passwords, API keys, PII, or anything that shouldn't be publicly readable.

โœ— Setting expiry too long (or not at all)

High

Tokens without an exp claim never expire. A token valid for 30 days gives an attacker 30 days to exploit a stolen one. Keep access tokens under 1 hour and always include exp.

โœ— Not validating the audience claim

Medium

A valid JWT from your auth server can be replayed against any microservice that accepts your tokens if they don't validate aud. Service A's token should not be accepted by Service B.

โœ— Using weak HMAC secrets

High

If you're using HS256, the secret is everything. Short or guessable secrets can be brute-forced offline against any JWT you've exposed. Use at least 256 bits of cryptographic randomness: crypto.randomBytes(32).

โœ— Trusting the kid (Key ID) header blindly

Critical

The "kid" header claim tells the server which key to use for verification. If your server fetches the key from a URL specified in the token header without validation, an attacker can point it to their own key server โ€” signing their own tokens.

Implementation Patterns

A few concrete patterns that handle the most common scenarios correctly.

Issuing a token pair on login (Node.js)

import jwt from 'jsonwebtoken';
import crypto from 'crypto';

async function login(userId, userRole) {
  // Short-lived access token
  const accessToken = jwt.sign(
    { sub: userId, role: userRole },
    process.env.JWT_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '15m',
      audience: 'api.myapp.com',
      issuer: 'https://auth.myapp.com',
      jwtid: crypto.randomUUID(),
    }
  );

  // Longer-lived refresh token (opaque, stored in DB)
  const refreshToken = crypto.randomBytes(32).toString('hex');
  await db.storeRefreshToken(userId, refreshToken, '7d');

  return { accessToken, refreshToken };
}

Secure verification middleware

function authMiddleware(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = header.slice(7);
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],      // explicit allowlist
      audience: 'api.myapp.com', // validate audience
      issuer: 'https://auth.myapp.com',
      clockTolerance: 30,         // 30s clock skew tolerance
    });
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

Refresh token rotation

async function refreshTokens(oldRefreshToken) {
  const stored = await db.getRefreshToken(oldRefreshToken);

  if (!stored || stored.usedAt) {
    // Possible replay โ€” revoke entire token family
    if (stored?.userId) {
      await db.revokeAllUserTokens(stored.userId);
    }
    throw new Error('Refresh token invalid or already used');
  }

  // Mark old token as used immediately
  await db.markRefreshTokenUsed(oldRefreshToken);

  // Issue new token pair
  return login(stored.userId, stored.userRole);
}

JWT Security Checklist

Run through this before shipping any JWT-based authentication flow.

Token creation

  • Always include exp โ€” never issue non-expiring access tokens
  • Include aud and iss claims
  • Add jti (JWT ID) for revocation support
  • Use 256+ bit random secret for HMAC or 2048+ bit RSA key
  • Access tokens expire in 15โ€“60 minutes max

Token validation

  • Use verify(), never decode()
  • Explicitly allowlist accepted algorithms
  • Validate aud and iss claims
  • Reject tokens with alg: "none"
  • Handle clock skew with tolerance (โ‰ค60s)

Storage & transport

  • Access token in memory โ€” never localStorage
  • Refresh token in HttpOnly Secure cookie
  • Set SameSite=Strict on cookie
  • Always use HTTPS โ€” never HTTP
  • Never log full JWT values

Common pitfalls

  • No secrets or PII in payload
  • Not trusting kid header without validation
  • Refresh token rotation enabled
  • Token revocation strategy defined
  • Algorithm confusion attacks tested

The bottom line

JWTs are not magic. Understand what they guarantee.

A JWT proves a token hasn't been tampered with โ€” nothing more. It doesn't prevent XSS, doesn't handle revocation automatically, and doesn't protect a weak secret. Use them correctly and they're a powerful tool. Use them blindly and they're a liability.

Feedback

Live