Skip to main content

What is JWT and how does it work?

JWT (JSON Web Token) is a compact, self-contained token that encodes user claims in JSON, signs them cryptographically, and lets servers verify identity without querying a database.

Theory

TL;DR

  • JWT works like a sealed passport: the data is visible, but tampering breaks the cryptographic seal
  • Stateless authentication means no DB lookup on each request, unlike session-based auth
  • Three Base64Url-encoded parts joined by dots: header.payload.signature
  • The payload is readable by anyone. JWT is signed, not encrypted
  • Use JWT for APIs and microservices; use sessions when you need instant revocation

Quick Example

javascript
const jwt = require('jsonwebtoken'); // Sign a token at login const token = jwt.sign( { userId: 123, role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '15m' } ); // eyJhbGciOiJIUzI1NiJ9... // Verify on every protected request jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => { if (err) return res.status(401).json({ error: 'Unauthorized' }); // decoded = { userId: 123, role: 'admin', iat: ..., exp: ... } });

sign creates the token. verify recomputes the signature and compares it byte-for-byte against the third segment. Change anything in the payload, and the signatures no longer match.

JWT Structure

A JWT looks like this: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEyM30.SflKxwRJSMeKKF2QT4fw

Three parts, two dots. Each part is Base64Url-encoded, a URL-safe variant of Base64 that drops + and = characters so the token survives inside HTTP headers without corruption.

Header declares the signing algorithm:

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

Payload carries claims about the user:

json
{ "sub": "123", "role": "admin", "exp": 1716239022, "iat": 1716235422 }

Standard claims: sub (user ID), iss (issuer), aud (audience), exp (expiration time), iat (issued at), nbf (not valid before). You can add any custom fields alongside them.

Signature is the security layer:

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

The server recomputes this on every request. A match means the token is valid and unmodified. A mismatch means tampering.

How the Signature Works

When you call jwt.sign(), Node's crypto module runs HMAC-SHA256 over the encoded header and payload using your secret key. The result becomes the third segment of the token string.

On jwt.verify(), the same HMAC computation runs again over the received header.payload. The library compares the result against the token's third segment using constant-time comparison, which prevents timing attacks. If the exp claim is past the current Unix time, verification fails regardless of signature validity.

This is why you can paste any JWT into jwt.io and read the payload. The signature does not hide data. It only proves the data has not been altered since the server issued the token.

Key Difference from Sessions

With sessions, the server keeps state: user ID, roles, and related data live in memory or a database, and every request triggers a lookup. With JWT, the server stores nothing. The token carries all the data, and verification is a pure crypto computation.

This scales well horizontally since any server instance can verify any token using the shared secret. But the trade-off is revocation. A token stays valid until exp, even after logout. Sessions let you delete that state immediately. I've seen teams build token blacklists in server memory for logout, then lose the entire list on every deployment restart. Short expiry plus server-side refresh tokens is the only approach that holds up in production.

When to Use

  • API-only backend or microservices: JWT (stateless, any instance verifies)
  • Single server with a session store: cookies and sessions (simpler revocation)
  • Mobile apps or SPAs: JWT (easy to attach to HTTP headers)
  • OAuth2 and OIDC flows: JWT (standardized claims, used by Auth0, Okta, AWS Cognito)
  • Long sessions with revocation needs: short JWT access tokens plus server-side refresh tokens

Common Mistakes

Putting sensitive data in the payload. The payload is just Base64. Anyone can paste the token into jwt.io and read every field. Store only an opaque user ID and roles. Fetch sensitive data from the database after verification.

Wrong:

javascript
jwt.sign({ sub: 'user', creditCard: '4111...' }, secret);

Fix:

javascript
jwt.sign({ sub: 'user123', role: 'admin' }, secret, { expiresIn: '15m' });

No expiration or a weak secret.

javascript
// Wrong: token lives forever, secret is brute-forceable jwt.sign(payload, 'secret'); // Correct: short expiry, 256-bit random secret jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' }); // Generate with: crypto.randomBytes(32).toString('hex')

Missing algorithm restriction. The JWT spec allows alg: none, which removes the signature entirely. An attacker strips the signature, modifies the payload, and sets alg to none.

javascript
// Wrong: accepts any algorithm, including "none" jwt.verify(token, secret); // Correct: explicit whitelist jwt.verify(token, secret, { algorithms: ['HS256'] });

Storing JWT in localStorage. Any XSS script can read localStorage and steal the token. Use httpOnly cookies, or keep tokens short-lived with a refresh flow.

Assuming JWT is revocable without extra infrastructure. A short expiresIn plus refresh tokens stored in Redis is the right model. The access token expires fast. The refresh token can be deleted on logout or user ban.

Real-world Usage

  • Express + passport-jwt: extracts and verifies Authorization: Bearer <token> in middleware
  • React/Next.js: fetch or axios attaches the token to every API request header
  • Auth0, Okta, AWS Cognito: issue JWTs after login, verified via a JWKS endpoint
  • GraphQL + Apollo: context function extracts and decodes JWT for resolvers
  • HS256 vs RS256: HS256 is symmetric (shared secret, faster); RS256 is asymmetric (private key signs, public key verifies, better for multi-service setups)

Follow-up Questions

Q: Walk through a JWT from login to API call.
A: User submits credentials, server signs a payload with the secret and returns the token. Client attaches it as Authorization: Bearer <token> on every subsequent request. Middleware verifies the signature and extracts claims for authorization logic.

Q: How do you handle JWT revocation?
A: Short access token expiry (15 minutes) combined with server-side refresh tokens in Redis. On logout, delete the refresh token. For extra coverage, include a jti claim and check a blacklist on sensitive operations.

Q: What is the difference between HS256 and RS256?
A: HS256 is symmetric: the same secret signs and verifies. RS256 is asymmetric: a private key signs, and any service with the public key can verify. RS256 fits multi-service architectures where sharing a secret across services is a security risk.

Q: Why Base64Url and not regular Base64?
A: Standard Base64 uses +, /, and = which corrupt HTTP headers and URLs. Base64Url swaps them for URL-safe alternatives, so the token travels cleanly as a header value or query param.

Q (Senior): Design zero-downtime JWT revocation for 1 million users.
A: Short access tokens (5 minutes) plus 24-hour refresh tokens in a Redis cluster with TTL matching the refresh expiry. Rotate refresh tokens on every use: old one deleted, new pair issued. For key rotation, serve public keys via a JWKS endpoint with a short cache TTL on consuming services. Monitor signature verification latency as Redis scales under load.

Examples

Login and Protected Route

javascript
const express = require('express'); const jwt = require('jsonwebtoken'); const app = express(); app.post('/login', express.json(), (req, res) => { // Replace with a real database check if (req.body.email !== 'user@example.com' || req.body.password !== 'pass') { return res.status(401).json({ error: 'Invalid credentials' }); } const token = jwt.sign( { id: 1, email: req.body.email }, process.env.JWT_SECRET, { expiresIn: '15m' } ); res.json({ token }); }); app.get('/profile', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; // Bearer <token> try { const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); res.json({ user: decoded }); } catch (err) { res.status(401).json({ error: 'Unauthorized' }); } });

/login returns { token: "eyJ..." }. The client stores it and sends Authorization: Bearer eyJ... on every request. /profile verifies the signature, then returns the decoded user object. Note the explicit algorithms option, it's the fix for the alg: none attack.

Refresh Token Rotation

When access tokens expire, clients need a way to get new ones without forcing the user to log in again. Refresh token rotation handles this and limits the damage from token theft.

javascript
const refreshTokens = new Map(); // Use Redis in production app.post('/refresh', express.json(), (req, res) => { const { refreshToken } = req.body; if (!refreshTokens.has(refreshToken)) { return res.status(403).json({ error: 'Invalid refresh token' }); } try { const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET); // Rotate: delete the old token, issue a new pair refreshTokens.delete(refreshToken); const newAccess = jwt.sign( { id: decoded.id }, process.env.JWT_SECRET, { expiresIn: '15m' } ); const newRefresh = jwt.sign( { id: decoded.id }, process.env.REFRESH_SECRET, { expiresIn: '7d' } ); refreshTokens.set(newRefresh, true); res.json({ accessToken: newAccess, refreshToken: newRefresh }); } catch { res.status(403).json({ error: 'Refresh failed' }); } });

If an attacker steals a refresh token, they can use it once. But the next time the legitimate user rotates, the stolen token is deleted from the store and fails on the next attempt. This pattern comes directly from Auth0 and Okta's recommended flows.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?