What it is#
A JWT is a compact set of claims that may be signed or encrypted. The common signed form has a header, payload, and signature. The header and payload are encoded, not secret; anyone holding the token can normally read them.
Security depends on verification policy. The application must choose which algorithms, issuers, audiences, keys, and token types it trusts. A library that merely decodes the payload has not authenticated it.
Frequent problems include accepting unsigned tokens, algorithm confusion, weak HMAC secrets, trusting token-supplied key URLs, unsafe kid handling, missing expiration checks, accepting an ID token as an API access token, and trusting role claims from the wrong issuer.
Why it matters#
- Forge another user's identity or administrative role.
- Reuse expired, revoked, or incorrectly scoped tokens.
- Present a token issued for a different application or API.
- Bypass tenant boundaries through trusted but unvalidated claims.
- Expose personal or secret data placed in the readable payload.
- Extend compromise when long-lived bearer tokens cannot be revoked.
Medium for limited lifetime or disclosure issues. High to critical when an attacker can forge signatures, select trusted keys, or convert an unrelated token into administrative API access. Actual severity depends on context: who is affected, what data or actions are reachable, and existing controls.
Examples#
// Vulnerable: decode() parses claims but does not verify authenticity.
import jwt from 'jsonwebtoken';
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
const claims = jwt.decode(token);
res.json(loadProfile(claims.sub));
});
// Fixed: pin the algorithm and validate issuer, audience, and lifetime.
import jwt from 'jsonwebtoken';
const verificationKey = process.env.ACCESS_TOKEN_PUBLIC_KEY;
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
const claims = jwt.verify(token, verificationKey, {
algorithms: ['RS256'],
issuer: 'https://identity.example.com/',
audience: 'api://customer-portal',
clockTolerance: 5,
maxAge: '15m',
});
res.json(loadProfile(claims.sub));
});
Production code must also handle missing tokens and verification errors without returning sensitive details.
How to fix it#
1. Use a maintained JOSE library and its verification API. Do not implement cryptography or token parsing yourself.
2. Pin allowed algorithms. Configure the expected asymmetric or symmetric algorithm server-side. Never select the algorithm solely from the untrusted header, and never accept none for authenticated tokens.
3. Validate the complete trust context. Require exact issuer and audience values, expiration, not-before time where used, token type, and any application-specific scope or tenant claims.
4. Control key selection. Fetch keys only from preconfigured trusted issuers over HTTPS. Do not follow arbitrary jku or x5u values. Treat kid as an identifier into an allowlisted key set, not a filename, query, or URL.
5. Separate token purposes. Access tokens, ID tokens, refresh tokens, email-verification tokens, and password-reset tokens are not interchangeable. Use distinct audiences, types, validation paths, and preferably keys.
6. Protect signing keys. Store private and symmetric keys in a managed key service or secret store, rotate them, retain old public keys only for the required validation window, and audit key use.
7. Keep tokens short lived. Use refresh-token rotation or server-side session state where revocation is required. Revoke sessions after credential changes and suspected compromise.
8. Keep secrets out of claims. Signed JWT payloads remain readable. Store only the minimum identifiers and authorization context required.
9. Authorize after verification. A valid token proves only what its trusted issuer asserted. The API must still enforce object, tenant, and function-level authorization.
How to test for it#
- Obtain test tokens through the normal identity flow and document expected issuer, audience, algorithm, type, scope, and lifetime.
- Modify the header, payload, and signature separately and confirm every altered token is rejected.
- Verify rejection of unsigned tokens, unexpected algorithms, expired tokens, future not-before values, wrong issuer, wrong audience, and wrong token type.
- Present an ID token where an access token is expected and a token for one test API to another. Both must fail.
- Test unknown and malformed key identifiers and untrusted key URLs without contacting attacker infrastructure.
- Rotate a test signing key and confirm the intended overlap and retirement behavior.
- Verify logout, password reset, account disablement, and privilege changes have the documented effect on existing tokens.
- Confirm logs never store complete bearer tokens and error responses do not reveal signature or key-selection internals.
References#
- CWE-347: Improper Verification of Cryptographic Signature ↗ (opens in a new tab)cwe.mitre.org
- RFC 8725: JSON Web Token Best Current Practices ↗ (opens in a new tab)rfc-editor.org
- OWASP JSON Web Token Cheat Sheet ↗ (opens in a new tab)cheatsheetseries.owasp.org
- OWASP Top 10 A07:2021 Identification and Authentication Failures ↗ (opens in a new tab)owasp.org
- PortSwigger Web Security Academy: JWT attacks ↗ (opens in a new tab)portswigger.net