Back to Blog
critical SEVERITY7 min read

How JWT Signature Bypass happens in Node.js and how to fix it

A critical authentication bypass vulnerability was discovered in `backend/services/auth-state.js` where the `tokenTtlSeconds()` function used `jwt.decode()` instead of `jwt.verify()`, allowing attackers to forge JWT tokens with arbitrary claims. Because `jwt.decode()` never validates the cryptographic signature, any attacker could craft a token with a manipulated expiration time or elevated privileges and have it accepted as legitimate. The fix replaces the insecure decode call with `jwt.verify(

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a JWT signature bypass vulnerability (CWE-347) in Node.js, found in `backend/services/auth-state.js` at line 84. The `tokenTtlSeconds()` function called `jwt.decode()`, which extracts JWT claims without verifying the cryptographic signature, enabling attackers to forge tokens with arbitrary expiration or privilege claims. The fix replaces `jwt.decode(token)` with `jwt.verify(token, config.jwtSecret)`, enforcing signature validation before any token claims are trusted. This is a one-line change with significant security impact — never use `jwt.decode()` on tokens that influence authentication or authorization decisions.

Vulnerability at a Glance

cweCWE-347
fixReplace `jwt.decode(token)` with `jwt.verify(token, config.jwtSecret)` to enforce signature verification
riskAttackers can forge JWT tokens with arbitrary claims, bypassing authentication and session expiry controls
languageJavaScript (Node.js)
root cause`jwt.decode()` was used instead of `jwt.verify()`, skipping cryptographic signature validation entirely
vulnerabilityJWT Signature Bypass (Improper Verification of Cryptographic Signature)

The File That Guards Your Sessions — And the Flaw That Undermined It

The backend/services/auth-state.js file is responsible for managing authentication state in this web service — tracking login status, handling login failures, and determining how long a session token remains valid. It sits directly on the path that decides whether a user is authenticated. That makes any flaw inside it high-impact by definition.

The specific flaw was subtle: a single function, tokenTtlSeconds(), was using jwt.decode() to read the expiration claim from a JWT token. On the surface, this looks reasonable — you need the exp field to calculate how many seconds remain before the token expires. But jwt.decode() does something critically unsafe: it extracts the token's payload without ever checking whether the token's cryptographic signature is valid.

The result? Any attacker who could craft a JWT with a manipulated exp value — or any other claim — could have it accepted as legitimate by this function.


The Vulnerability Explained

What jwt.decode() Actually Does

The jsonwebtoken library for Node.js exposes two ways to read a token's payload:

Function Verifies Signature? Checks Expiry? Use Case
jwt.decode(token) ❌ No ❌ No Debugging / logging only
jwt.verify(token, secret) ✅ Yes ✅ Yes Production authentication

jwt.decode() is documented as a utility for inspecting a token's contents — for example, in logging pipelines where you already know the token is trusted. It is explicitly not intended for use in security decisions.

The Vulnerable Code

Here is the vulnerable implementation of tokenTtlSeconds() at line 84 of backend/services/auth-state.js:

// VULNERABLE — jwt.decode() skips signature verification entirely
function tokenTtlSeconds(token) {
    try {
        const decoded = jwt.decode(token);
        if (!decoded?.exp) return 0;
        return Math.max(0, decoded.exp - Math.floor(Date.now() / 1000));
    } catch (_) {
        // ...
    }
}

The function takes a raw token string, decodes it, reads the exp (expiration) claim, and returns the number of seconds until expiry. The problem is on line 3 of this snippet: jwt.decode(token) will happily return the payload of any syntactically valid JWT, regardless of whether its signature was created by your server or by an attacker.

How an Attacker Exploits This

A JWT token has three base64url-encoded parts separated by dots:

header.payload.signature

Because jwt.decode() only looks at the header and payload parts, an attacker can:

  1. Take an expired legitimate token (or construct one from scratch).
  2. Decode the payload and change exp to a timestamp far in the future — say, the year 2099.
  3. Re-encode the header and payload with a fake or empty signature.
  4. Submit this forged token to the application.

When tokenTtlSeconds() processes this token, it calls jwt.decode(), reads the attacker-controlled exp value of 2099, and returns a very large positive TTL — signaling that the token is still valid. Any downstream logic that relies on this TTL to make authentication or session decisions would then treat the forged token as active.

In a web service where auth-state.js is on the critical authentication path, this is a direct authentication bypass. An attacker with a previously expired session — or no legitimate session at all — could forge a token and maintain indefinite access.

The "Algorithm None" Angle

This vulnerability is related to the well-known "alg: none" JWT attack. In that attack, an attacker sets the JWT header's alg field to "none", causing libraries that trust the header's algorithm declaration to skip signature verification. The root cause is the same: trusting token contents before verifying their integrity.


The Fix

The fix is a single line change in tokenTtlSeconds():

 function tokenTtlSeconds(token) {
     try {
-        const decoded = jwt.decode(token);
+        const decoded = jwt.verify(token, config.jwtSecret);
         if (!decoded?.exp) return 0;
         return Math.max(0, decoded.exp - Math.floor(Date.now() / 1000));
     } catch (_) {

Before vs. After

Before (vulnerable):

const decoded = jwt.decode(token);

After (fixed):

const decoded = jwt.verify(token, config.jwtSecret);

Why This Works

jwt.verify(token, config.jwtSecret) does exactly what jwt.decode() skips:

  1. Decodes the header to determine the signing algorithm.
  2. Recomputes the expected signature using config.jwtSecret and the token's header + payload.
  3. Compares the computed signature to the one in the token. If they don't match, it throws a JsonWebTokenError.
  4. Checks standard claims including exp (expiration), nbf (not before), and iss (issuer) if configured.
  5. Only if all checks pass does it return the decoded payload.

Because the existing code already wraps the call in a try/catch block that returns 0 on error, the fix integrates cleanly: a forged token will throw a verification error, the catch block returns 0 (treating the token as already expired), and the session is denied. No additional error-handling changes were needed.

The use of config.jwtSecret is also important — it means the verification key is centrally managed through the application's configuration system, rather than hardcoded or sourced from the token itself.


Prevention & Best Practices

1. Treat jwt.decode() as a Diagnostic Tool Only

Make it a team rule: jwt.decode() should never appear in production code paths that make security decisions. It belongs in:
- Log formatters that display token contents for debugging
- Admin dashboards that inspect token metadata (after authentication is already confirmed)
- Test utilities

A Semgrep rule can enforce this automatically — see the References section.

2. Always Verify Before Trusting Any Claim

Even claims that seem non-security-sensitive (like exp) can become attack vectors when read from an unverified token. The expiration time controls session duration; an attacker who can manipulate it can extend their access indefinitely.

3. Specify the Expected Algorithm

When calling jwt.verify(), pass an algorithms option to prevent algorithm confusion attacks:

const decoded = jwt.verify(token, config.jwtSecret, {
    algorithms: ['HS256']  // Reject tokens signed with unexpected algorithms
});

This prevents the "alg: none" attack and ensures tokens signed with unexpected algorithms (e.g., RS256 when you expect HS256) are rejected.

4. Use Short-Lived Tokens with Refresh

Even with proper verification, long-lived tokens increase the blast radius of a compromise. Pair signature verification with short exp values (15–60 minutes) and a separate refresh token flow.

5. Security Standards Reference

  • OWASP: JSON Web Token Cheat Sheet for Java (principles apply across languages)
  • CWE-347: Improper Verification of Cryptographic Signature
  • CWE-345: Insufficient Verification of Data Authenticity

Key Takeaways

  • jwt.decode() in tokenTtlSeconds() was the exact root cause — not a missing middleware or a misconfigured policy. One function call on one line created a full authentication bypass.
  • The exp claim is only trustworthy after signature verification — reading it via jwt.decode() gives an attacker full control over how long their session appears to be valid.
  • The existing try/catch pattern made the fix clean and safejwt.verify() throws on invalid tokens, and the catch block already handled errors gracefully by returning 0.
  • config.jwtSecret was already available — the infrastructure for proper verification existed; it just wasn't being used in this function.
  • Static analysis can catch this pattern reliablyjwt.decode() in a security context is a well-defined, detectable anti-pattern that automated tools can flag before it reaches production.

How Orbis AppSec Detected This

  • Source: JWT token string passed as the token parameter to tokenTtlSeconds() in backend/services/auth-state.js
  • Sink: jwt.decode(token) at line 84 — a call that extracts token claims without any cryptographic verification
  • Missing control: No signature verification was performed before trusting the exp claim from the token payload
  • CWE: CWE-347 — Improper Verification of Cryptographic Signature
  • Fix: Replaced jwt.decode(token) with jwt.verify(token, config.jwtSecret) to enforce cryptographic signature validation before any claims are read

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The vulnerability in tokenTtlSeconds() is a textbook example of how a single misused API call can undermine an entire authentication system. The jsonwebtoken library provides both the unsafe jwt.decode() and the safe jwt.verify() — and they look almost identical in code. That similarity is precisely what makes this class of bug so dangerous and so common.

The lesson is not just to "use verify instead of decode." It's to internalize the principle: never trust data from an external source — including your own tokens — before verifying its integrity. A JWT is a signed assertion from your server. If you read its contents before confirming the signature, you're trusting a piece of data that anyone could have written.

One line of code, one cryptographic check, and this critical vulnerability is closed.


References

Frequently Asked Questions

What is a JWT signature bypass vulnerability?

A JWT signature bypass occurs when an application reads claims from a JWT token without verifying its cryptographic signature, allowing an attacker to forge tokens with arbitrary payloads — such as extended expiration times or elevated roles — that the server will accept as legitimate.

How do you prevent JWT signature bypass in Node.js?

Always use `jwt.verify(token, secret)` from the `jsonwebtoken` library instead of `jwt.decode(token)`. The `verify()` function validates the token's signature against your secret before returning any claims, while `decode()` simply base64-decodes the payload with no security checks.

What CWE is JWT signature bypass?

JWT signature bypass maps to CWE-347: Improper Verification of Cryptographic Signature. It may also relate to CWE-345 (Insufficient Verification of Data Authenticity) depending on the specific implementation flaw.

Is checking the token expiration (`exp` claim) enough to prevent JWT bypass?

No. If you read the `exp` claim via `jwt.decode()`, an attacker can set any expiration they want in a forged token. Expiration checking is only meaningful after the signature has been verified with `jwt.verify()`, which proves the token was issued by a trusted party.

Can static analysis detect JWT signature bypass?

Yes. Tools like Semgrep have rules that flag uses of `jwt.decode()` in security-sensitive contexts. Orbis AppSec's multi-agent AI scanner detected this exact pattern in `auth-state.js` and automatically produced a fix, demonstrating that automated analysis can reliably catch this class of vulnerability.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3

Related Articles

critical

How Unverified JWT Decoding Happens in Java and How to Fix It

A critical authentication bypass was discovered in `JwtExtractor.java` where `JWT.decode()` was used instead of a proper signature-verifying method, allowing any attacker to forge a JWT with an arbitrary username — including `admin` — and gain unauthorized access. The fix adds clear documentation establishing the trust boundary: signature validation must occur upstream, and the extracted claims are for display purposes only. This change prevents the class from being misused as an authorization g

critical

How Missing Authentication Middleware Happens in Node.js APIs and How to Fix It

A critical vulnerability in a Node.js Panel Connector API (CVE-2025-7783) left 14 endpoints—including shell command execution, file deletion, and file writing—completely open to unauthenticated access. The comment in the source code even declared "NO AUTH — Full Open Access," making it a textbook example of a missing authentication control. The fix adds a Bearer token middleware guard on all `/api` routes, blocking unauthorized requests before they reach any sensitive handler.

critical

How OAuth CSRF Attacks Happen in Node.js and How to Fix Them

A missing OAuth state parameter validation in `src/account_manager.js` left the `startOAuthServer()` function vulnerable to CSRF attacks, allowing an attacker to inject their own authorization code into a victim's active OAuth session. The fix generates a cryptographically random state token using `crypto.randomBytes()`, returns it alongside the server handle, and rejects any callback where the returned state doesn't match — closing the attack window entirely. This affects all downstream consume

critical

How Unauthenticated API Endpoint Exposure happens in Node.js and how to fix it

A critical vulnerability in `api/firebase-config.js` exposed all Firebase configuration values — including API keys, app IDs, and project IDs — to any unauthenticated caller. With no access controls, CORS restrictions, or rate limiting in place, attackers could retrieve live credentials and directly access Firebase services. The fix adds shared-secret authentication using timing-safe comparison, origin validation, and method enforcement.

high

How Middleware and Proxy Bypass happens in Next.js App Router and how to fix it

CVE-2026-64642 is a high-severity authentication bypass vulnerability in Next.js that affects App Router applications using Turbopack with a single locale configuration. The flaw allows attackers to circumvent middleware and proxy security controls, potentially gaining unauthorized access to protected routes. Upgrading from Next.js 16.2.7 to 16.2.11 closes the vulnerability entirely.

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr