JSON Web Tokens appear in nearly every modern web application, and they are also among the most commonly misimplemented authentication mechanisms. The misunderstandings show up as real vulnerabilities in production systems: tokens with no expiry, secrets committed to version control, sensitive data sitting unencrypted in the payload, and entire architectures built around JWTs for reasons that turn out not to apply. This article covers what a JWT actually is, how the cryptographic verification works, the standard patterns for access and refresh tokens, the vulnerability classes that recur most often, and the cases where traditional server-side sessions remain the better choice.
the structure: three base64url segments
A JWT is three base64url-encoded strings separated by dots: header.payload.signature. Each segment can be decoded independently using any base64url decoder. The important thing to understand immediately: base64url is encoding, not encryption. Anyone who holds a JWT can read the header and payload without any key.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <-- header
.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcyMDQ3MzYwMH0 <-- payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c <-- signature
The header specifies the signing algorithm (alg) and token type (typ). The payload contains claims — key-value pairs about the token's subject. Standard registered claims include sub (subject, typically a user ID), exp (expiry as a Unix timestamp), iss (issuer), and aud (audience). You can add any custom claims your application needs. The signature is computed by applying the algorithm to the concatenation of the encoded header and payload using a secret or private key.
Decoding the payload of the example above reveals: {"sub": "user_123", "role": "admin", "exp": 1720473600}. The role and user ID are readable to anyone who intercepts the token. Plan your payload contents accordingly.
how signature verification works
The signature is the entire mechanism behind JWT trust. When a server receives a JWT, the verification process is:
- Split the token on the dots to get header, payload, and signature.
- Recompute the expected signature by applying the algorithm to
base64url(header) + "." + base64url(payload)using the known secret or public key. - Compare the recomputed signature to the one in the token using a constant-time comparison.
- If they match, the token was created by someone who holds the signing key and has not been modified since. If they differ, reject the token.
No external lookup is required. The verification is entirely local, which is the property that makes JWTs attractive at scale.
Two signing strategies exist. Symmetric signing (HMAC-SHA256, HS256) uses a single shared secret for both signing and verification. Every service that needs to verify tokens must hold the same secret. Simple to set up; requires careful secret distribution when multiple services are involved.
// Signing (Node.js / jsonwebtoken)
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m', issuer: 'auth.example.com', audience: 'api.example.com' }
);
// Verifying
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'auth.example.com',
audience: 'api.example.com'
});
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
return res.status(401).json({ error: 'Invalid token' });
}
Asymmetric signing (RS256, ES256) uses a private key to sign and a public key to verify. Only the issuing service needs the private key. Any service that needs to verify tokens receives the public key, which can be distributed freely. This is the right choice for multi-service architectures where sharing a secret across many services creates unacceptable risk.
stateless authentication: the actual benefit
The primary advantage of a JWT is that the server does not need to look anything up to verify it. A stateless API server receives a JWT, verifies the signature locally, reads the claims from the payload, and knows who the user is and what they are authorised to do — without a database query or a cache lookup.
This matters at scale. A session-based system requires every request to hit the session store — a database or Redis — to retrieve the session record and validate it. Under high load, the session store becomes a bottleneck and a single point of failure. A JWT-based system distributes the verification work across all API server instances. Any instance can verify any token using only the shared key. You can scale horizontally by adding instances without coordinating session state.
The second benefit is federation. A token issued by one service can be verified by another service that holds the public key, without the two services communicating with each other. This makes JWTs a natural fit for microservice architectures and third-party API access.
access tokens and refresh tokens
Short-lived access tokens solve one of the most difficult problems with stateless authentication: revoking a token before it expires. Because there is no server-side state to delete, a JWT remains valid until its exp claim says otherwise. If a token is stolen or a user's account is compromised, you cannot invalidate the token — you can only wait for it to expire.
The standard pattern addresses this by pairing two tokens:
- An access token with a short lifetime (5–15 minutes). This is sent as a bearer token in the Authorization header with every API request. Its short lifetime limits the damage window from theft — a stolen access token is useless after 15 minutes.
- A refresh token with a longer lifetime (7–30 days), stored more securely. The client uses it to obtain a new access token when the current one expires, without requiring the user to log in again. Refresh tokens are stored server-side so they can be revoked immediately.
// Client receives both tokens on login
{ accessToken: "eyJ...", refreshToken: "dGhp..." }
// Access token expires — client hits refresh endpoint
POST /auth/refresh
Authorization: Bearer <refreshToken>
// Server validates refresh token (checks DB), issues new access token
{ accessToken: "eyJ..." }
// If refresh token is revoked (logout, suspicious activity), server returns 401
// Client redirects to login
Refresh token rotation — issuing a new refresh token each time one is used and invalidating the old one — limits the damage from refresh token theft. A stolen refresh token that has already been used will be rejected, and the attempted reuse can trigger a security alert.
vulnerabilities that appear in production
Algorithm confusion. An early class of JWT attack exploited servers that derived the signing algorithm from the token's own alg header. An attacker could set "alg": "none" to create an unsigned token, or switch from RS256 to HS256 and sign with the public key (which the server then verified using the same public key, treating it as the HS256 shared secret). Always specify exactly which algorithms your application accepts — never derive it from the token. The code example above passes algorithms: ['HS256'] explicitly for this reason.
Weak or exposed secrets. A short or predictable HS256 secret can be brute-forced offline by an attacker who captures a valid token. The secret should be at least 256 bits of cryptographic randomness (32 bytes from a CSPRNG), stored in a secrets manager, rotated periodically, and never committed to version control or hardcoded in application code.
Missing or absent expiry. Tokens without an exp claim are valid indefinitely. Every JWT issued by a production system must have an expiry, sized to the sensitivity of what it authorises. There is no valid reason to omit expiry for production access tokens.
Sensitive data in the payload. Because the payload is base64url-encoded and not encrypted, anyone who holds a token can read it. The payload should contain only what is necessary for authorisation decisions: a user identifier, a role or scope, and standard claims. Never include passwords, personally identifying information, financial data, internal system identifiers, or secrets. If you need to transmit sensitive data in a token, use JSON Web Encryption (JWE), which encrypts the payload.
Missing issuer and audience validation. The iss and aud claims exist to prevent token confusion attacks — a token issued for one service being accepted by another. If your system has multiple services, each service should validate that the incoming token was issued by the expected issuer and intended for that specific audience. Without this check, a token issued for a low-privilege service could be replayed against a high-privilege one.
Tokens in localStorage. localStorage is accessible to any JavaScript running on the page, including scripts injected via XSS. For access tokens, prefer HttpOnly, Secure, SameSite=Strict cookies, which are completely inaccessible to JavaScript. If you use localStorage — for example, to share tokens across subdomains — your XSS defenses must be correspondingly thorough: a strict Content Security Policy, rigorous output encoding, and regular dependency audits.
token revocation strategies
The stateless nature of JWTs is a double-edged property: it enables scalability but makes immediate revocation impossible without reintroducing server-side state. When a user logs out, changes their password, or has their account suspended, what options exist?
The most common approach is a token denylist — a fast key-value store (typically Redis) holding the JTI (JWT ID) claims of revoked tokens, checked on each request. This reintroduces a lookup but limits it to tokens that have been explicitly revoked; the common path (valid, unrevoked token) still hits only the local verification step.
A simpler approach for many applications is accepting the limitation: keep access tokens short-lived (under 15 minutes), and revoke only refresh tokens. A user who logs out loses their refresh token immediately; their current access token remains valid but expires quickly. For most threat models this is acceptable — it reduces the revocation problem to refresh token management, which requires server-side state anyway.
If you genuinely need instant revocation of access tokens in a stateless system, the honest answer is that you need server-side state. The choice of JWTs does not eliminate that requirement; it only defers it.
when sessions are still the right answer
JWTs are a good fit for: stateless API servers that scale horizontally; microservice architectures where multiple services verify identity without coordinating with each other; and cross-domain or cross-application scenarios where sharing cookies is problematic.
Server-side sessions are a better fit for: traditional server-rendered applications where every request already hits the server; applications that need immediate and reliable revocation; systems where the operational overhead of key management, token rotation, and refresh logic is not justified by the scale or architecture; and scenarios where simplicity and auditability matter more than distributed verification speed.
A session ID in an HttpOnly cookie, backed by a Redis session store with a 30-minute TTL, is cheap to look up, instantly revocable, carries no risk of payload data exposure, requires no client-side token management logic, and is secure against XSS by default. The "JWTs are always better" assumption does not survive contact with real requirements. Match the mechanism to the problem.
a practical baseline
If JWTs are the right choice for your architecture, this is the minimum viable implementation posture:
- Short-lived access tokens (15 minutes) combined with refresh token rotation.
- Algorithm explicitly specified on both signing and verification — never derived from the token.
- Secret of at least 256 bits from a CSPRNG, stored in a secrets manager, not in code or environment files committed to version control.
- Always validate
exp,iss, andaudon every verification call. - Access tokens delivered via HttpOnly, Secure cookies wherever possible. If bearer tokens in headers are required, document the XSS threat model explicitly.
- Payload contains only what is necessary for authorisation: no PII, no secrets, no internal system data.
- Refresh token denylist in Redis for logout and account suspension, with a size-bounded TTL matching the refresh token lifetime.
JWT authentication is the right choice for many architectures, but only when implemented with a clear understanding of what it provides and what it does not. The signature guarantees integrity — the token has not been tampered with. It does not provide confidentiality, and it does not provide revocability without additional infrastructure. Knowing these limits is what separates a JWT implementation that works correctly from one that works correctly until something goes wrong.