JWT & Token-Based Authentication

TL;DR

A JWT (JSON Web Token) is a signed, base64-encoded string with three parts: header.payload.signature. The server signs it once; any server can verify it without a database lookup. Use short-lived access tokens + long-lived refresh tokens for the best security/UX balance.

Explain Like I'm 12

Imagine your teacher writes you a hall pass: "Jane, 3rd period, going to the library, signed Mrs. Smith." Any teacher in the hallway can read it and verify Mrs. Smith's signature. They don't need to call Mrs. Smith to check — the pass itself is proof. That's a JWT — it carries your info AND proof it hasn't been tampered with. But if someone photocopied it, they could use it too — which is why hall passes (and JWTs) should expire quickly.

Anatomy of a JWT

A JWT has three base64url-encoded parts separated by dots:

JWT structure showing header (algorithm + type), payload (claims like sub, exp, role), and signature parts
# A real JWT (line breaks added for readability)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.    # Header
eyJzdWIiOiIxMjMiLCJuYW1lIjoiSmFuZSIsInJ  # Payload
vbGUiOiJhZG1pbiIsImlhdCI6MTc0MzYzODQwMC
wiZXhwIjoxNzQzNjQyMDAwfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  # Signature
Info: JWTs are signed, not encrypted. Anyone can decode the header and payload (it's just base64). Don't put secrets in the payload. The signature only proves the data hasn't been tampered with.

Payload (Claims)

The payload contains claims — statements about the user and metadata. There are registered (standard), public, and private claims:

{
  "sub": "user-123",        // Subject (user ID) — registered
  "name": "Jane Doe",       // Public claim
  "role": "admin",          // Private claim (your app's data)
  "iat": 1743638400,        // Issued At — registered
  "exp": 1743642000,        // Expiration — registered (1 hour later)
  "iss": "myapp.com",       // Issuer — registered
  "aud": "api.myapp.com"    // Audience — registered
}
ClaimFull NamePurpose
subSubjectWho this token represents (user ID)
issIssuerWho created and signed this token
audAudienceWho should accept this token
expExpirationWhen this token expires (Unix timestamp)
iatIssued AtWhen this token was created
nbfNot BeforeToken is invalid before this time
jtiJWT IDUnique identifier (for revocation)
Warning: Never put sensitive data (passwords, credit card numbers, PII) in JWT claims. The payload is base64-encoded, not encrypted — anyone with the token can read it.

Signature & Signing Algorithms

The signature ensures the token hasn't been tampered with. Different algorithms serve different use cases:

AlgorithmTypeKeyUse Case
HS256Symmetric (HMAC)One shared secretSingle server or trusted internal services
RS256Asymmetric (RSA)Private key signs, public key verifiesDistributed systems, third-party verification
ES256Asymmetric (ECDSA)Private key signs, public key verifiesSame as RS256 but smaller keys, faster
noneNo signatureNoneNEVER use — disables all security
# Python: Sign and verify JWT with RS256 (asymmetric)
import jwt  # PyJWT library

# Sign with private key
private_key = open('private.pem').read()
token = jwt.encode(
    {'sub': 'user-123', 'role': 'admin', 'exp': 1743642000},
    private_key,
    algorithm='RS256'
)

# Verify with public key (can be shared freely)
public_key = open('public.pem').read()
decoded = jwt.decode(token, public_key, algorithms=['RS256'])
print(decoded['sub'])  # user-123
Tip: Use RS256/ES256 (asymmetric) when multiple services need to verify tokens — they only need the public key. Use HS256 (symmetric) only when a single server both issues and verifies.

Access Tokens vs Refresh Tokens

A common pattern: issue a short-lived access token for API calls and a long-lived refresh token to get new access tokens without re-login.

Access TokenRefresh Token
Lifetime5-60 minutesDays to months
Sent toResource servers (APIs)Auth server only
StorageMemory (JS variable)httpOnly cookie or server-side
If stolenLimited damage (short-lived)Full account compromise
Revocable?Not easily (check on every request is expensive)Yes (server-side check)
// Token refresh flow (client-side)
async function fetchWithAuth(url, options = {}) {
  let response = await fetch(url, {
    ...options,
    headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` }
  });

  // If 401, try refreshing the token
  if (response.status === 401) {
    const refreshResponse = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include'  // sends httpOnly refresh cookie
    });

    if (refreshResponse.ok) {
      const { access_token } = await refreshResponse.json();
      accessToken = access_token;  // update in-memory token

      // Retry original request with new token
      response = await fetch(url, {
        ...options,
        headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` }
      });
    } else {
      // Refresh failed — redirect to login
      window.location.href = '/login';
    }
  }

  return response;
}

Common JWT Security Pitfalls

Attack/PitfallWhat Goes WrongPrevention
alg:none attackAttacker changes alg to "none", server skips verificationAlways validate the alg claim; reject "none"
Key confusionAttacker switches RS256→HS256, signs with public keyHardcode expected algorithm; never accept alg from token
No expirationToken valid forever if stolenAlways set exp claim; verify it on every request
Sensitive data in payloadToken is base64, not encrypted — anyone can read itOnly put non-sensitive claims; use JWE for encryption
localStorage storageXSS can steal tokens from localStorageStore access tokens in memory, refresh tokens in httpOnly cookies
# SAFE: Hardcode the algorithm — never trust the token's header
decoded = jwt.decode(
    token,
    public_key,
    algorithms=['RS256'],  # Only accept RS256
    audience='api.myapp.com',
    issuer='myapp.com'
)

# DANGEROUS: Accepting any algorithm from the token
# decoded = jwt.decode(token, key, algorithms=jwt.get_unverified_header(token)['alg'])
Warning: The most common JWT vulnerability is accepting the algorithm from the token's header. Always hardcode your expected algorithms in verification code.

When NOT to Use JWTs

  • You need instant revocation — JWTs can't be revoked until they expire (without a blocklist, which defeats the "stateless" benefit)
  • You have a single server — sessions with server-side storage are simpler and more secure
  • You need to store sensitive data — JWT payloads are readable by anyone. Use server-side sessions or JWE (encrypted JWT)
  • Your tokens get very large — JWTs are sent with every request. Large payloads = bigger requests = slower performance
Info: JWTs shine in distributed systems and microservices where multiple independent services need to verify identity without sharing a database or session store.

Test Yourself

What are the three parts of a JWT and what does each contain?

Header: algorithm and token type (e.g., RS256, JWT). Payload: claims — user data and metadata (sub, exp, iat, custom claims). Signature: HMAC or RSA/ECDSA signature of header + payload, proving the token hasn't been tampered with.

Why are JWTs "signed but not encrypted"? What's the practical implication?

The signature proves the token hasn't been modified, but the header and payload are only base64-encoded — anyone can decode and read them. Implication: never store secrets, PII, or sensitive data in JWT claims. Anyone who intercepts the token can read its contents.

When should you use RS256 over HS256?

Use RS256 (asymmetric) when multiple services need to verify tokens — they only need the public key, so the signing secret stays with the auth server. Use HS256 (symmetric) only when a single trusted service both issues and verifies tokens.

Why store access tokens in memory instead of localStorage?

localStorage is accessible to any JavaScript on the page. An XSS vulnerability lets an attacker steal tokens from localStorage. In-memory tokens disappear on page refresh (cleared by the JS runtime) and can't be accessed via XSS as easily. Trade-off: the user re-authenticates on refresh, mitigated by silent refresh via httpOnly refresh cookie.

What is token rotation and why does it matter for refresh tokens?

Token rotation: each time a refresh token is used, the server issues a new refresh token and invalidates the old one. If an attacker steals and uses a refresh token, the legitimate user's next refresh attempt will fail (old token invalidated), alerting the system to compromise. Without rotation, a stolen refresh token can be used indefinitely.

Interview Questions

How would you implement token revocation for JWTs without losing the stateless benefit?

Use short-lived access tokens (5 min) so revocation happens naturally via expiry. For urgent revocation: maintain a small in-memory blocklist (Redis) of revoked token IDs (jti claims) — only tokens that haven't expired yet need to be in the list, so it stays small. Alternatively, use token introspection (RFC 7662) for high-security operations.

Explain the "alg:none" attack and how to prevent it.

An attacker modifies a JWT's header to {"alg": "none"} and removes the signature. Vulnerable libraries skip verification for "none" algorithm. Prevention: always hardcode the expected algorithm in your verification code (algorithms=['RS256']), never derive it from the token's header, and use well-maintained JWT libraries that reject "none" by default.

Your microservices architecture uses JWTs. Service A issues tokens, Services B/C/D verify them. How do you handle key rotation?

Use asymmetric signing (RS256/ES256) so only Service A has the private key. Publish public keys via a JWKS (JSON Web Key Set) endpoint. When rotating: 1) Add the new key to JWKS, 2) Start signing with the new key (include kid header), 3) Verifiers fetch updated JWKS and match by kid, 4) Remove old key after all existing tokens expire. This is exactly how OAuth providers like Auth0 and Okta handle rotation.