JWT Security: When Tokens Become Attack Vectors
JSON Web Tokens have become the default credential format for modern APIs. Nearly every authentication system built in the last decade issues JWTs — signed compact objects that carry identity claims across HTTP requests without requiring server-side session state. The format is elegant and well-specified. The implementations, less so.
JWT vulnerabilities are not theoretical. They appear in real assessments with regularity, often granting complete authentication bypass or privilege escalation with minimal effort. The attack surface is well-defined and the defenses are straightforward, yet the same mistakes appear again and again in production systems.
What Is a JWT?
A JSON Web Token consists of three base64url-encoded sections separated by dots: a header, a payload, and a signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJyb2xlIjoidXNlciIsImV4cCI6MTcxMjAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The header declares the token type and signing algorithm. The payload contains claims — statements about the subject, typically a user ID, roles, expiration time, and any application-specific data. The signature binds the header and payload together using a secret or private key.
// Decoded header
{ "alg": "HS256", "typ": "JWT" }
// Decoded payload
{ "sub": "123456", "role": "user", "exp": 1712000000 }Because JWTs are self-contained and cryptographically signed, the server does not need to query a database on every request to validate the session. It simply verifies the signature and trusts the claims inside. This efficiency is the feature that makes JWT attacks so valuable to an attacker: forging the token means forging identity.
The alg:none Attack
The most brazen JWT attack requires no cryptographic capability at all. Some JWT libraries, when encountering a token with the algorithm header set to none, skip signature verification entirely — treating the unsigned token as valid.
The attack is simple:
- Obtain any valid JWT (your own session token, or a captured one)
- Decode the header and payload (they are just base64url-encoded JSON)
- Modify the payload to change your user ID, elevate your role, or extend expiration
- Re-encode with
"alg": "none"in the header - Append an empty signature (just a trailing dot) or no signature at all
- Submit the token
If the library is vulnerable, the server accepts it as if it were a legitimate signed token.
// Original
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJyb2xlIjoidXNlciJ9.signature
// Forged with alg:none
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiI5OTk5OTkiLCJyb2xlIjoiYWRtaW4ifQ.
This vulnerability persisted in widely-used JWT libraries for years. The fix is to explicitly reject any token whose algorithm field is not on an allowed list. A library that accepts none as a valid algorithm has no business being in a production authentication system.
Algorithm Confusion: Turning a Public Key Into a Secret
The algorithm confusion attack is more subtle but equally devastating. It exploits the interaction between asymmetric and symmetric signing algorithms.
When a server uses RS256 (RSA with SHA-256), it signs tokens with its private RSA key and verifies them with the corresponding public key. The public key is, by definition, public — it might be published at a JWKS endpoint, embedded in documentation, or extractable from any legitimate token.
The attack exploits a server that:
- Accepts the
algfield from the token header without pinning it - Has a JWT library that uses the same key for both RS256 verification and HS256 computation
An attacker who obtains the server's RSA public key can:
- Create a JWT with
"alg": "HS256"instead of"RS256" - Set any payload claims desired (elevated role, different user ID)
- Sign the token using HS256 with the public RSA key as the HMAC secret
When the server processes this token, a confused implementation uses the public key material to verify what it thinks is an HS256 signature. It is using the public key — which the attacker also has — as the HMAC secret. The verification succeeds, and the forged token is accepted.
import jwt
import base64
# Attacker has the server's RSA public key
with open('server_public_key.pem', 'rb') as f:
public_key = f.read()
# Forge a token using the public key as an HMAC secret
forged_token = jwt.encode(
{"sub": "admin_user_id", "role": "admin"},
public_key,
algorithm="HS256"
)The defense is absolute: pin the algorithm on the server side. The server knows what algorithm it uses. It should reject any token that specifies a different one, regardless of what the client sends.
Weak HMAC Secrets
When JWTs are signed with HMAC (HS256, HS384, HS512), the security of every token issued by the system depends entirely on the strength of the secret key. And JWT secrets are brute-forceable offline.
Unlike password attacks that require interaction with a live server (and can be rate-limited), attacking a JWT secret requires only a captured token. The attacker takes the header and payload, tries candidate secrets, signs locally, and compares against the token's signature. No network interaction. No lockout. No logs.
A weak secret is found in seconds:
hashcat -a 0 -m 16500 captured_token.txt wordlist.txt
Common sources of weak JWT secrets found in assessments:
- Default values from framework documentation (
secret,your-256-bit-secret,changeme) - Application name or domain (
myapp,api.example.com) - Environment variable names used as their own values (
JWT_SECRET,SECRET_KEY) - Short random strings under 32 characters
- Reuse of database passwords or other credentials
The minimum for an HMAC JWT secret is 256 bits of cryptographically random data — the same length as the hash output for HS256. Anything shorter provides meaningfully less security. Anything predictable provides none.
The kid Header: Key Identifier Injection
The kid (key ID) header parameter is an optional field that tells the server which key to use when verifying the token. It is intended for systems that rotate keys or support multiple signing keys simultaneously.
When an application uses kid to look up a key — from a database, a file system, or a key store — without sanitization, the parameter becomes an injection vector.
SQL Injection via kid
If the key lookup is implemented as a database query:
SELECT key_value FROM signing_keys WHERE key_id = '<kid_value>';An attacker can inject:
"kid": "anything' UNION SELECT 'attackercontrolledvalue'--"
The query returns attacker-controlled content as the signing key. The attacker then signs a forged token using that same value and the server accepts it.
Path Traversal via kid
If the key is loaded from a file:
key = open(f"/keys/{kid}").read()An attacker can use path traversal:
"kid": "../../../../etc/passwd"
On Linux systems, the content of /etc/passwd is predictable. An attacker can sign a token using the contents of that file and the server will verify it successfully.
The defense is straightforward: never use attacker-controlled values in file paths or SQL queries without strict sanitization. Ideally, key IDs should be validated against an allowlist of known, expected values.
Missing Claim Validation
Even a correctly signed token can be dangerous if the application fails to validate its registered claims.
Expiration (exp): A token without expiration validation is valid forever. If a token is stolen, compromised, or issued to a since-deactivated user, it remains usable indefinitely. Every JWT verification should explicitly check the exp claim.
Audience (aud): The audience claim identifies the intended recipients of the token. A token issued for service A should not be accepted by service B. Without audience validation, a token obtained from one API can be replayed against another.
Issuer (iss): The issuer identifies who created the token. In multi-tenant or multi-service environments, a token from a different tenant's JWT issuer might be accepted if iss is not validated.
Not-before (nbf): Less commonly exploited but important for tokens that should only become valid at a future time.
In one assessment of a microservices platform, an access token issued by the authentication service for the user-facing API was accepted by the internal administration service because both services shared the same secret and neither validated the aud claim. A regular user's token, with role user, was usable against admin endpoints intended only for internal services.
JWT Libraries: Trust But Verify
The choice of library matters as much as the implementation. Several widely-used JWT libraries have had vulnerabilities including:
- Accepting
alg:nonein production configurations - Using the wrong key for algorithm confusion scenarios
- Parsing tokens before verifying them, enabling denial of service through malformed inputs
- Accepting tokens with extremely large payloads
Using a well-maintained, actively-patched library is a baseline requirement. Libraries that have not been updated in years should be treated with suspicion regardless of their star count or download numbers.
Testing JWT Implementations
A systematic approach to JWT security testing:
- Capture a valid token and decode all three sections
- Try alg:none — modify the payload, set alg to none, strip the signature
- Try algorithm confusion — obtain the public key if asymmetric signing is in use, forge an HS256 token signed with it
- Extract and test the kid header — modify it to inject SQL or path traversal characters
- Brute-force the secret — run the token through common wordlists and patterns
- Test claim validation — submit expired tokens, tokens with wrong audience, tokens with wrong issuer
- Modify claims without changing the signature — a library that does not verify signatures before processing will accept these
- Test token replay — submit the same token to other services and endpoints
Many of these tests require only a captured token and a small script. None require prior knowledge of the server's keys or secrets.
Prevention
Pin the algorithm. The server knows what algorithm it expects. Reject any token that specifies a different one. Do not delegate this decision to the client.
Use strong secrets. Generate HMAC keys with a cryptographically secure random number generator. Minimum 256 bits. Store them securely, rotate them periodically, and never reuse them across environments.
Validate all registered claims. At minimum: exp (expiration), iss (issuer), aud (audience). Fail closed — if a claim is missing or invalid, reject the token.
Sanitize the kid header. If you must use it, validate it against an allowlist of known key IDs. Never interpolate it into SQL queries or file paths.
Use a well-maintained library. Check its security record. Ensure it is actively maintained. Read the documentation carefully for any non-default configuration required for security.
Rotate secrets. A compromised or cracked secret invalidates every token in circulation. Implement key rotation with version tracking so you can retire a secret without immediately logging out all users.
Key Takeaways
JWTs are cryptographically signed, but cryptography only helps if it is used correctly. The common attacks — alg:none, algorithm confusion, weak secrets, kid injection — all exploit the gap between what the JWT specification allows and what a secure implementation requires.
The defenses are well-understood and straightforward to implement. A server that pins its expected algorithm, uses a strong random secret, validates all claims, and sanitizes extensible parameters is not vulnerable to any of the standard JWT attack categories.
The vulnerability is not in the format itself. It is in implementations that trust the token's own claims about how it should be verified.
Need your authentication implementation reviewed? Get in touch.