A deep dive into the three-part JWT structure, how signatures are generated and verified, stateless vs stateful auth, token expiry and refresh patterns, and the security mistakes that get production systems compromised.
JSON Web Tokens are everywhere. They're also widely misunderstood, misconfigured, and occasionally disastrously insecure. This guide covers how JWTs actually work — the cryptography, the claims, the token lifecycle — and the mistakes that turn a reasonable authentication scheme into a security liability.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxMTcyNzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three base64url-encoded segments separated by dots:
HEADER.PAYLOAD.SIGNATURE
Decoded, each segment is readable JSON or binary data.
{
"alg": "HS256",
"typ": "JWT"
}The header declares the token type and the algorithm used to sign it. HS256 is HMAC-SHA256. Other common values: RS256 (RSA with SHA-256), ES256 (ECDSA with SHA-256). The algorithm matters — it determines what kind of secret is used for signing and what's required to verify it. More on this later.
{
"sub": "user_123",
"role": "admin",
"iat": 1711641200,
"exp": 1711727600
}The payload contains claims — statements about the subject (typically your user) and the token itself. Some claims are registered (standardized by the JWT spec):
| Claim | Name | Description |
|---|---|---|
sub |
Subject | Identifies the principal (usually user ID) |
iss |
Issuer | Who issued the token (your auth server URL) |
aud |
Audience | Who the token is intended for |
exp |
Expiration | Unix timestamp after which the token is invalid |
iat |
Issued At | Unix timestamp when the token was issued |
jti |
JWT ID | Unique identifier for this token (enables blacklisting) |
You can add your own claims: role, permissions, tenant_id, anything your application needs to authorize requests without a database lookup.
Critical point: the payload is base64url-encoded, not encrypted. Anyone holding the token can decode it and read every claim. atob(payload.replace(/-/g, '+').replace(/_/g, '/')) in a browser console — done. Do not put sensitive data in the payload.
The signature is what prevents tampering. It's computed like this:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
The server signs the concatenated header and payload using a secret key (for HMAC algorithms) or a private key (for RSA/ECDSA). When the server receives a token, it recomputes this signature using the same key and compares it to the signature in the token. If they match, the token hasn't been tampered with. If someone modifies the payload — changes "role": "user" to "role": "admin" — the signature no longer matches and the token is rejected.
This is the core guarantee: authenticity, not confidentiality. The payload is readable but tamper-evident.
When a client sends a request with a JWT (typically in the Authorization: Bearer <token> header), the server:
. to get header, payload, and signatureexp in the future? Does iss match the expected issuer? Does aud include this service?Notice what's not in this list: a database lookup. That's the entire appeal of JWTs for stateless systems — the token is self-contained. The server verifies it cryptographically, no session store required.
The classic alternative to JWTs is server-side sessions: the server creates a session record in a database (or Redis), hands the client an opaque session ID (a random string), and looks it up on every request.
| Factor | JWT (stateless) | Sessions (stateful) |
|---|---|---|
| Server state | None — no session store | Session store required (DB or Redis) |
| Revocation | Hard — token valid until expiry | Easy — delete session record |
| Horizontal scaling | Easy — any server can verify | Requires shared session store |
| Performance | Crypto per request (fast) | DB lookup per request (latency) |
| Payload size | Grows with claims | Minimal (just a session ID) |
| Security on logout | Token still valid until exp | Session deleted immediately |
The tradeoff that bites teams: JWTs can't be revoked easily. If a user logs out, you can delete the token from the client — but you can't make the token cryptographically invalid before it expires. If the token is stolen after logout, it still works until expiry. For access tokens with 15-minute lifetimes, this is usually acceptable. For tokens with 24-hour or 7-day lifetimes, it's a real problem.
Opaque tokens (random strings stored server-side) give you revocation at the cost of a database lookup per request. For most applications, that lookup is fast enough and the revocation capability is worth it. JWTs make sense at scale where you want to minimize infrastructure dependencies, or where you're issuing tokens for third-party API consumption.
Short-lived access tokens paired with long-lived refresh tokens is the standard pattern for handling expiry without constantly logging users out.
Client Auth Server API Server
| | |
|-- POST /auth/login -----> | |
|<-- access_token (15m) ----| |
| refresh_token (7d) | |
| | |
|-- GET /api/data (Bearer access_token) -----------> |
|<-- 200 OK ------------------------------------------------|
| | |
| [access_token expires] | |
| | |
|-- POST /auth/refresh -------> | |
| (refresh_token in body) | |
|<-- new access_token (15m) ----| |
| new refresh_token (7d) | (rotate) |
| | |
|-- GET /api/data (new access_token) --------------> |
|<-- 200 OK ------------------------------------------------|
Key implementation details:
Refresh token rotation: When a refresh token is used to get a new access token, issue a new refresh token and invalidate the old one. If the old refresh token is ever used again (possible replay attack), invalidate the entire token family and require re-authentication. Libraries like Auth.js implement this automatically.
Refresh token storage: The refresh token is more sensitive than the access token — it's longer-lived and allows issuing new access tokens. Store it in an httpOnly, Secure, SameSite=Strict cookie. Never in localStorage.
Silent refresh: In SPAs, implement silent refresh — automatically exchange the refresh token for a new access token before the current one expires, so the user never sees an error.
This one is serious. The JWT header declares the algorithm. If your verification code trusts the header's alg claim to decide how to verify the signature, an attacker can exploit this.
Classic attack: a system uses RS256 (asymmetric — signed with private key, verified with public key). The public key is, by definition, public. An attacker crafts a token using HS256 (symmetric HMAC) where the "secret" is the server's public key. If the server's JWT library says "oh, this token says HS256, I'll verify it with HMAC using my stored key material" — and that key material happens to be the public key — the forged token verifies successfully.
Fix: Always specify the expected algorithm explicitly in your verification call. Never let the token header dictate the algorithm.
// Wrong — algorithm from token header
jwt.verify(token, secret);
// Right — algorithm specified server-side
jwt.verify(token, secret, { algorithms: ['HS256'] });The JWT spec includes "alg": "none" — an unsigned token. Some early JWT libraries accepted this as valid. An attacker sets alg: none and removes the signature. The token "verifies" because nothing is being verified.
Fix: Use a maintained JWT library (jsonwebtoken, jose) and explicitly specify allowed algorithms. Reject any token with alg: none.
Access tokens with 24-hour or 7-day expiry provide session-length utility without session-style revocation. A stolen token stays valid for days. There's no recovery except waiting.
Fix: Access tokens should expire in 15 minutes to 1 hour. Use refresh tokens for session persistence.
JavaScript-accessible storage is vulnerable to XSS. One malicious script injected via a dependency vulnerability, a user-generated content field, or a compromised CDN can exfiltrate every JWT from every active user.
Fix: httpOnly cookies. The browser sends them automatically, JavaScript can't read them.
iss and aud claimsIf you issue JWTs from multiple services, a token intended for Service A could be accepted by Service B if you don't validate the audience. Always check iss (issuer) and aud (audience) against expected values.
For HS256, your signing secret should be at least 256 bits of random data. jwt_secret, secret, or any human-readable string is not a secret — it's guessable via brute force. Generate a cryptographically random key: openssl rand -base64 32.
JWTs are a good fit when:
access_token issuance)JWTs are a poor fit when:
Opaque tokens (random strings, stored in a database or Redis) are underrated. They're simpler, support revocation natively, and the database lookup is fast. For many applications, they're the better choice. The stateless appeal of JWTs matters most at scale — when you have many API servers that you don't want to coordinate around a shared session store.
Don't implement JWT signing or verification yourself. Use a maintained library that handles the edge cases: constant-time comparison for signature bytes (prevents timing attacks), algorithm allowlisting, claim validation. For Node.js, jose (by panva) is the current recommendation — it's actively maintained, handles all the spec edge cases, and has no known vulnerabilities from the historical jsonwebtoken issues. For Python, PyJWT. For Go, golang-jwt/jwt.
Authentication bugs are silent until they're catastrophic. Hunchbite's developer experience reviews cover your auth implementation, token lifecycle, and security configuration — catching the mistakes that don't show up in functional testing.
Call +91 90358 61690 · Book a free call · Contact form
If this guide resonated with your situation, let's talk. We offer a free 30-minute discovery call — no pitch, just honest advice on your specific project.
How to set up Drizzle ORM with PostgreSQL from scratch — schema definition, migrations, query patterns, connection pooling, and the configuration decisions that matter in production Next.js applications.
11 min readguideA technical guide to database indexes: B-tree internals, composite index column ordering, covering indexes, partial indexes, the write cost of over-indexing, EXPLAIN ANALYZE interpretation, and the common indexing mistakes that degrade production performance.
14 min read