Initializing Enclave...

How to Fix JWT Signature Verification Failed: Blocking 'alg:none' Attacks and Missing 'exp' Claims

Threat/Impact Level: CRITICAL | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins

TL;DR

  • What broke: Your JWT library rejected a token because the alg header is "none" (unsigned) or the exp claim is absent/past its timestamp — both are fatal verification failures.
  • How to fix it: Enforce an explicit algorithm allowlist (RS256 or ES256), always set exp to a short TTL (≤15 min for access tokens), and reject any token that fails either check at the middleware layer.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your token-generation code and get a hardened diff output without sending secrets anywhere.

The Incident (What Does the Error Mean?)

Raw error thrown by most JWT libraries (jsonwebtoken, python-jose, nimbus-jose-jwt):

Error: json web token signature verification failed.
Algorithm 'none' is not supported, or 'exp' claim is missing/expired in the payload.

This fires on token validation, not generation. Two distinct failure modes collapse into this single error:

Mode Trigger Immediate Consequence
alg: none Token header declares no signing algorithm Library refuses to verify — or worse, an older/misconfigured lib accepts it silently
Missing/expired exp Payload has no exp field, or exp < Date.now()/1000 Token is treated as permanently valid or already dead

In production, this surfaces as 401 Unauthorized cascading across every authenticated endpoint until the token is rotated or the library config is patched.


The Attack Vector / Blast Radius

The alg:none vector is a CVE-class exploit, not a misconfiguration.

The attack flow is textbook:

  1. Attacker intercepts or crafts a JWT with header {"alg":"none","typ":"JWT"}.
  2. They strip the signature segment entirely (token becomes header.payload.).
  3. A vulnerable library that doesn't enforce an algorithm allowlist accepts this token as valid because there's nothing to verify.
  4. Attacker sets sub: admin, role: superuser in the payload. Full privilege escalation. Zero cryptographic resistance.

This is CVE-2015-9235 (jsonwebtoken) and its variants. It has been re-discovered in Go, Python, and Ruby JWT libraries repeatedly. If your library version predates 2018 and you haven't pinned an algorithm allowlist, assume you are vulnerable.

Missing exp blast radius:

A token without exp is an immortal credential. If it leaks — via logs, a misconfigured S3 bucket, a browser history entry — it is valid forever. No rotation, no revocation list covers this unless you've built a token denylist (most teams haven't).


How to Fix It (The Solution)

Basic Fix — Enforce Algorithm + Inject exp

- const token = jwt.sign({ sub: userId, role: userRole }, secretKey);
+ const token = jwt.sign(
+   { sub: userId, role: userRole },
+   privateKey,
+   { algorithm: 'RS256', expiresIn: '15m' }
+ );
- const decoded = jwt.verify(token, secretKey);
+ const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Never pass algorithms: ['none'] or omit the algorithms array on verify. Omitting it means the library falls back to trusting whatever alg the token header declares — attacker-controlled input.


Enterprise Best Practice — Middleware-Level Enforcement

Don't trust individual service implementations. Enforce at the gateway.

Node.js / Express middleware:

- app.use((req, res, next) => {
-   const token = req.headers.authorization?.split(' ')[1];
-   const user = jwt.verify(token, secret);
-   req.user = user;
-   next();
- });
+ import { expressjwt } from 'express-jwt';
+ 
+ app.use(
+   expressjwt({
+     secret: publicKey,
+     algorithms: ['RS256'],          // explicit allowlist — 'none' is rejected hard
+     credentialsRequired: true,
+     requestProperty: 'auth',
+   })
+ );
+ 
+ // Global error handler for JWT failures
+ app.use((err, req, res, next) => {
+   if (err.name === 'UnauthorizedError') {
+     return res.status(401).json({ error: 'Invalid or expired token.' });
+   }
+   next(err);
+ });

Python (PyJWT):

- payload = jwt.decode(token, secret, algorithms=None)
+ payload = jwt.decode(
+     token,
+     public_key,
+     algorithms=["RS256"],
+     options={
+         "require": ["exp", "iat", "sub"],   # fail if any are missing
+         "verify_exp": True,
+     },
+ )

Key rotation: Use asymmetric keys (RS256/ES256) so you can rotate the private signing key without redeploying every verifying service. Publish the public key via a /.well-known/jwks.json endpoint.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Semgrep rule — block alg:none and missing expiresIn at commit time:

# .semgrep/jwt-hardening.yaml
rules:
  - id: jwt-sign-missing-expiry
    patterns:
      - pattern: jwt.sign($PAYLOAD, $KEY, ...)
      - pattern-not: jwt.sign($PAYLOAD, $KEY, { ..., expiresIn: ... }, ...)
    message: "jwt.sign() called without expiresIn. Token will never expire."
    severity: ERROR
    languages: [javascript, typescript]

  - id: jwt-verify-no-algorithm-allowlist
    patterns:
      - pattern: jwt.verify($TOKEN, $KEY)
    message: "jwt.verify() called without explicit algorithms array. Vulnerable to alg:none attack."
    severity: ERROR
    languages: [javascript, typescript]

Run in CI:

semgrep --config .semgrep/jwt-hardening.yaml --error src/

2. Dependency pinning + audit:

# Node — flag known-vulnerable JWT library versions
npm audit --audit-level=high

# Python
pip-audit --requirement requirements.txt

3. OPA / Envoy policy (service mesh layer):

If you're running Istio or Envoy, enforce JWT validation at the proxy level so no service ever receives an unverified token regardless of application code:

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: jwt-enforce-rs256
spec:
  jwtRules:
    - issuer: "https://auth.yourdomain.com"
      jwksUri: "https://auth.yourdomain.com/.well-known/jwks.json"
      # Istio rejects alg:none and missing exp by default when jwksUri is set

4. Token TTL policy in your IdP (Okta, Auth0, Keycloak):

  • Access tokens: ≤ 15 minutes
  • Refresh tokens: ≤ 24 hours with rotation enabled
  • Set exp, iat, nbf, and jti as required claims in your token policy — jti enables single-use revocation if you ever need it.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →