JWT Deep Dive: Structure, Algorithms, and Security Pitfalls
21 February, 2026 Security
JSON Web Tokens are everywhere - OAuth 2.0 authorization servers issue them, REST APIs accept them, and single sign-on systems pass them between services. Despite being ubiquitous, JWTs are frequently misimplemented in ways that create serious security vulnerabilities. This article covers the spec, the algorithms, and the attacks developers most often get wrong.
What Is a JWT?
A JSON Web Token is a compact, URL-safe means of representing claims between two parties. The spec is defined in RFC 7519. A "claim" is simply a key-value pair - assertions about a subject such as their identity, role, or the token's expiry time.
JWTs are used primarily for:
- Authentication - a server issues a token after login; subsequent API requests carry that token instead of credentials
- API authorization - service-to-service calls where one microservice proves its identity to another
- Single sign-on (SSO) - a user authenticates once and carries a token that multiple services accept
- Stateless session replacement - avoid server-side session storage by encoding session data in the token itself
The defining property of a JWT is that it is self-contained: the receiver can verify the token and read its claims without querying a database, because the cryptographic signature proves the token was issued by a trusted party and has not been modified.
You can decode this JWT in your browser right now without sending it to a server.
The Three-Part Structure
Every JWT consists of three Base64URL-encoded parts separated by dots:
header.payload.signature
A real token looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzM4NDAwMDAwLCJleHAiOjE3Mzg0ODY0MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The three parts are separated at the . characters. Each part is independently Base64URL encoded.
Base64URL vs Base64 - standard Base64 uses +, /, and = characters that are not safe in URLs and HTTP headers. Base64URL replaces + with -, / with _, and omits padding =. These two encodings are NOT interchangeable. A common bug is applying standard Base64 decoding to a JWT component and wondering why it fails.
The Header
Decoded, the header JSON from the example above is:
{
"alg": "HS256",
"typ": "JWT"
}
- typ - always
"JWT"for a JSON Web Token. Some implementations also use"at+JWT"for OAuth 2.0 access tokens per RFC 9068. - alg - the signing algorithm. This is the most security-critical field in the entire token. The value here tells the verifier which algorithm to use when checking the signature.
Why does alg matter so much? Because accepting whatever algorithm the token claims to use - rather than enforcing a specific expected algorithm on the server - is the root cause of several serious attacks covered below.
The Payload
The payload carries the claims. Decoded from the example:
{
"sub": "1234567890",
"name": "Alice",
"iat": 1738400000,
"exp": 1738486400
}
RFC 7519 defines three categories of claims:
Registered Claims
These are standardised claim names with specific semantics. All are optional but strongly recommended:
| Claim | Full name | Description |
|---|---|---|
iss |
Issuer | Who issued the token. E.g. "https://auth.example.com". Use this to reject tokens from unexpected issuers. |
sub |
Subject | The principal the token represents - typically a user ID. E.g. "user:8291". |
aud |
Audience | Who the token is intended for. E.g. "api.example.com". A service should reject tokens not addressed to it. |
exp |
Expiration | Unix timestamp after which the token must be rejected. |
iat |
Issued At | Unix timestamp when the token was created. |
nbf |
Not Before | Unix timestamp before which the token must be rejected. Useful for tokens issued for future use. |
jti |
JWT ID | A unique identifier for the token. Used to prevent replay attacks and support token revocation. |
A realistic issued token payload:
{
"iss": "https://auth.example.com",
"sub": "user:8291",
"aud": "api.example.com",
"exp": 1738486400,
"iat": 1738400000,
"nbf": 1738400000,
"jti": "a8f3c1d2-4e5b-6789-abcd-ef0123456789",
"email": "alice@example.com",
"roles": ["admin", "editor"]
}
Public and Private Claims
Public claims are names registered with IANA to avoid collisions. The IANA JWT Claims Registry lists standardised names like email, name, phone_number.
Private claims are application-specific and agreed upon between producer and consumer. Examples: "tenant_id", "feature_flags", "subscription_tier". Prefix them with a namespace to avoid collisions: "com.example/tenant_id".
The Signature
The signature is computed over the encoded header and payload:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
)
For HMAC-SHA256 (HS256), the signing key is a shared secret. The server that issues tokens and the server that verifies them must both know this secret.
Conceptually, HMAC works like this: it feeds the input data and the key through the SHA-256 hash function in a way that makes it computationally infeasible to produce a valid signature without knowing the key. Even changing a single bit in the payload produces a completely different signature. This means:
- A token cannot be forged without the signing key.
- A token cannot be modified in transit - any change invalidates the signature.
- The signature does not encrypt the payload. The payload is only Base64URL-encoded and is fully readable by anyone with the token. Never put secrets in JWT claims.
Signing Algorithms
HS256 - HMAC with SHA-256 (Symmetric)
Both parties share a single secret key. Fast and simple. The problem: every service that needs to verify tokens must also know the secret, which means every service could also issue tokens. In a microservices architecture this means a compromise of any one service compromises the entire token infrastructure.
Use HS256 when: a single application both issues and verifies its own tokens, and the secret never needs to be shared externally.
RS256 - RSA with SHA-256 (Asymmetric)
The issuer signs with a private key; verifiers use the corresponding public key. The public key can be distributed freely - for example via a JWKS (JSON Web Key Set) endpoint at /.well-known/jwks.json. A compromised verifier service cannot forge tokens because it only holds the public key.
Use RS256 when: multiple services verify tokens, or you use an external identity provider (Auth0, Keycloak, AWS Cognito). This is the industry standard for OAuth 2.0 and OIDC.
ES256 - ECDSA with P-256 and SHA-256 (Asymmetric)
Functionally similar to RS256 - asymmetric, private key signs, public key verifies - but uses elliptic curve cryptography. ES256 signatures are 64 bytes; RS256 signatures are typically 256 bytes for a 2048-bit key. Smaller tokens mean less overhead in HTTP headers and cookies.
Use ES256 when: token size matters (e.g., tokens transmitted in every request header) or you need the same security level as RS256 with smaller keys.
| Algorithm | Type | Key size | Signature size | Use case |
|---|---|---|---|---|
| HS256 | Symmetric | 256-bit secret | 32 bytes | Single-service apps |
| RS256 | Asymmetric | 2048-bit RSA | 256 bytes | Multi-service, OAuth 2.0 |
| ES256 | Asymmetric | 256-bit EC | 64 bytes | Performance-sensitive systems |
Common Security Vulnerabilities
1. The "none" Algorithm Attack
In the original JWT spec, "alg": "none" indicated an unsigned token. Some libraries, if not properly configured, accept this and skip signature verification entirely - accepting any token that claims to be valid.
This is the pattern behind CVE-2015-9235 (node-jsonwebtoken) and similar vulnerabilities in other libraries. An attacker can take a valid token, modify the payload (e.g., change "role": "user" to "role": "admin"), set "alg": "none", remove the signature, and the vulnerable library accepts it.
Fix: Explicitly specify the allowed algorithms when verifying. Never accept a token's own alg claim without cross-checking it against your configured allowlist.
// Vulnerable - accepting whatever algorithm the token declares
$decoded = JWT::decode($token, new Key($secret, $header->alg));
// Correct - enforce the expected algorithm
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
2. Algorithm Confusion Attack (RS256 to HS256)
If a library uses the public key as the HMAC secret when it encounters "alg": "HS256", an attacker who knows the public key (which is, by definition, public) can forge tokens.
The attack: take the server's public key, set "alg": "HS256" in the header, sign the forged token with the public key as the HMAC secret. A vulnerable verifier, expecting RS256, sees "alg": "HS256" and verifies the HMAC using the public key - which succeeds.
Fix: Explicitly require the expected algorithm on the server side regardless of the token header.
3. Not Verifying Expiration
The exp claim is only useful if the server actually checks it. Many implementations decode the payload but forget to validate exp, meaning tokens remain valid indefinitely after issuance.
Fix: Always validate exp. Most JWT libraries do this automatically, but confirm it is not disabled in your configuration.
4. Storing JWTs in localStorage
localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability - one unsanitized user input rendered anywhere on the site - can give an attacker access to every stored token.
httpOnly cookies are inaccessible to JavaScript by design. XSS cannot steal a token stored in an httpOnly cookie.
Fix: Store JWTs in httpOnly, Secure, SameSite=Strict cookies for browser applications. Reserve localStorage only for non-sensitive data.
5. Weak Secrets for HS256
An HS256 secret that is too short or too predictable can be brute-forced offline. An attacker with a valid token can run dictionary attacks against the signature without hitting your servers.
Fix: Use a cryptographically random secret of at least 256 bits (32 bytes). Do not use human-memorable strings, environment names, or application names as secrets.
Stateless vs Stateful: The Revocation Problem
JWT's stateless nature is its main performance advantage - no database lookup per request. It is also its main operational weakness.
The problem: once a JWT is issued, it is valid until exp. There is no built-in way to invalidate it. If a user logs out, changes their password, or is suspended, their existing tokens remain valid.
Common mitigations:
- Short expiry times - issue tokens that expire in 15-60 minutes. Combine with refresh tokens (longer-lived, stored server-side) to re-issue access tokens without requiring the user to re-authenticate.
- Token blacklisting - maintain a server-side set of revoked
jtivalues. Requires a lookup on every request, partially negating the stateless advantage, but can be fast with Redis. - Versioning - store a
token_versioncounter per user. Include it as a claim; increment it on logout or password change. Verification checks the claim against the current database value.
There is no perfect solution. The right approach depends on your security requirements and acceptable complexity.
Code Examples
PHP (lexik/jwt-authentication-bundle)
// config/packages/lexik_jwt_authentication.yaml
// lexik_jwt_authentication:
// secret_key: '%env(JWT_SECRET_KEY)%'
// public_key: '%env(JWT_PUBLIC_KEY)%'
// pass_phrase: '%env(JWT_PASSPHRASE)%'
// token_ttl: 3600
// Issuing a token manually
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
class AuthController extends AbstractController
{
public function __construct(
private readonly JWTTokenManagerInterface $jwtManager,
) {}
public function login(User $user): JsonResponse
{
$token = $this->jwtManager->create($user);
return new JsonResponse(['token' => $token]);
}
}
// Verifying manually with firebase/php-jwt
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
echo $decoded->sub; // "user:8291"
Python (PyJWT)
import jwt
from datetime import datetime, timezone, timedelta
SECRET = "your-256-bit-secret"
# Issue a token
payload = {
"sub": "user:8291",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"iat": datetime.now(tz=timezone.utc),
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=1),
}
token = jwt.encode(payload, SECRET, algorithm="HS256")
# Verify and decode - PyJWT validates exp automatically
try:
decoded = jwt.decode(
token,
SECRET,
algorithms=["HS256"], # explicit allowlist
audience="api.example.com", # validates aud claim
)
print(decoded["sub"]) # "user:8291"
except jwt.ExpiredSignatureError:
print("Token has expired")
except jwt.InvalidTokenError as e:
print(f"Invalid token: {e}")
JavaScript (jsonwebtoken)
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// Issue a token
const token = jwt.sign(
{ sub: 'user:8291', roles: ['admin'] },
SECRET,
{ algorithm: 'HS256', expiresIn: '1h', audience: 'api.example.com' }
);
// Verify - throws on invalid signature, expired token, wrong audience
try {
const payload = jwt.verify(token, SECRET, {
algorithms: ['HS256'], // explicit allowlist - never omit this
audience: 'api.example.com',
});
console.log(payload.sub); // 'user:8291'
} catch (err) {
console.error('Token invalid:', err.message);
}
JWT vs Session Tokens vs API Keys
| Property | JWT | Server-side session | API key |
|---|---|---|---|
| Stateless | Yes | No | Yes |
| Revocable instantly | No (without extra infra) | Yes | Yes |
| Contains user data | Yes | No (session ID only) | No |
| Database lookup per request | No | Yes | Usually yes |
| Expiry mechanism | Built-in (exp claim) |
Session timeout | Manual |
| Suitable for microservices | Yes | Complex (shared store) | Yes |
| Risk on theft | Until expiry | Until session invalidation | Until rotation |
Decoding a JWT Without a Library
Understanding what a library does helps when debugging. You can decode (not verify) a JWT with standard Base64URL decoding.
function decodeJwtPayload(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Not a JWT');
// Base64URL to Base64: replace - with +, _ with /, add padding
const base64 = parts[1]
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(parts[1].length + (4 - parts[1].length % 4) % 4, '=');
return JSON.parse(atob(base64));
}
const payload = decodeJwtPayload(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' +
'eyJzdWIiOiJ1c2VyOjgyOTEiLCJleHAiOjE3Mzg0ODY0MDB9.' +
'signature'
);
// { sub: 'user:8291', exp: 1738486400 }
This decodes the payload without verifying the signature. Never use decoded-but-unverified data for authorization decisions. The JWT encoder/decoder on this site does the same thing - client-side, nothing is sent to a server.
Conclusion
JWT is a well-designed, battle-tested standard when used correctly. The most common mistakes are not validating exp, accepting unconstrained alg values, and storing tokens in localStorage. Use asymmetric algorithms (RS256 or ES256) for any system where multiple services verify tokens, keep access token lifetimes short, and pair them with server-side refresh tokens for revocability.
When debugging a JWT - whether one you issued or one from a third-party identity provider - the JWT encoder/decoder decodes the header and payload instantly without transmitting your token anywhere. The Base64 encoder is useful for inspecting individual components or testing Base64URL encoding manually.