Start Here
JWTs are not secure by default.
They're fast. Stateless. Powerful. But easy to break if you don't understand what you're actually doing.
Most developers copy-paste jwt.verify(token, secret) and move on. That's where the problems start.
The JOSE Family
JWT stands for JSON Web Token. It's part of JOSE: JSON Object Signing and Encryption.
Two things matter:
JWS (JSON Web Signature) — Signs data. Anyone can read it, but tampering is detectable.
JWE (JSON Web Encryption) — Encrypts data. Only the recipient can read it.
Most JWTs are JWS because you rarely need to hide the payload. You just need proof it hasn't been modified.
What a JWT Actually Looks Like
eyJhbGci... . eyJzdWIi... . 2Xh3n...
header payload signature
Each part is base64url-encoded JSON:
HEADER PAYLOAD SIGNATURE
{ "alg": "HS256" } { "sub": "user_123" } HMAC(header + payload)
Visual breakdown:
┌─────────────┐
│ HEADER │ → Algorithm + metadata
├─────────────┤
│ PAYLOAD │ → Your claims (readable!)
├─────────────┤
│ SIGNATURE │ → Proof of integrity
└─────────────┘
The payload is NOT encrypted. Base64 is encoding, not encryption. Anyone can decode and read it.
atob("eyJzdWIiOiJ1c2VyXzEyMyJ9")
// {"sub":"user_123"}
The signature is what protects you. It proves the token came from someone with the key and hasn't been modified.
The Header
{
"alg": "HS256",
"kid": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
alg — How it's signed (HS256, RS256, ES256)
kid — Which key was used (UUID only, never a path or URL)
jku — URL to fetch keys (never trust this from the token)
Rule: Don't make security decisions based on header values. Attackers control headers too.
The Payload
{
"sub": "user_5839",
"exp": 1640995200,
"iss": "https://auth.yourapp.com",
"aud": "https://api.yourapp.com"
}
exp — Expiration (mandatory)
iss — Who issued it
aud — Who it's for
jti — Unique ID (for logout/blacklisting)
Don't put secrets here. Passwords, API keys, SSNs—none of that belongs in a JWT.
When JWE Makes Sense
JWE encrypts the payload:
header.encrypted_key.iv.ciphertext.tag
Use it when:
- Tokens pass through untrusted intermediaries
- Compliance requires encryption at rest
Skip it when:
- You're using HTTPS (already encrypted in transit)
- You control both issuer and verifier
Most systems don't need JWE.
Where Security Breaks
Algorithm Confusion
Token says "alg": "HS256" but your server expects RS256. If your code doesn't enforce the algorithm, attackers can forge tokens.
// Vulnerable
const decoded = jwt.verify(token, publicKey);
The "none" Algorithm
Token with "alg": "none" and no signature. Some libraries accept this.
eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.
Trusting Headers
Using kid from the token to load keys:
{ "kid": "../../etc/passwd" }
{ "kid": "http://attacker.com/keys" }
If you do loadKey(header.kid) without validation, you're in trouble.
Skipping Claim Validation
Verifying the signature isn't enough. You must validate:
- Algorithm matches what you expect
- Token hasn't expired
- Issuer is correct
- Audience is correct
Fetching Keys from Untrusted URLs
Token's jku points to https://evil.com/keys. Your server fetches keys from there, "verifies" the attacker's token.
How to Verify Correctly
jwt.verify(token, key, {
algorithms: ['RS256'], // Hardcode this
issuer: 'https://auth.yourapp.com', // Hardcode this
audience: 'https://api.yourapp.com', // Hardcode this
maxAge: '30m'
});
Rules:
- Explicitly set allowed algorithms
- Validate issuer and audience
- Never trust
jkufrom the token—configure JWKS URL server-side - Use UUIDs for
kid, not paths or URLs - Keep access tokens short (15-30 min)
- Use longer refresh tokens with revocation support
The Mental Model
A JWT is a signed statement.
The signature proves integrity and authenticity. It doesn't prove the token should be accepted.
You decide that by validating the claims.
Final Thought
Tools don't fail. Understanding fails.
Most JWT vulnerabilities come from trusting input you shouldn't trust or skipping validation you should enforce.
Configure strictly. Validate everything. Never assume the library handles security for you.
The difference between safe and broken is understanding what the signature actually proves.
Reference: RFC 7515 (JWS), RFC 7516 (JWE), RFC 7519 (JWT)
Top comments (0)