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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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.


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 safe — jwt.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 reliably — jwt.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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3

Related Articles

critical

Music Studio App Express Server: CWE-287 Authentication Bypass via

A critical authentication bypass vulnerability in a music studio application's Express server allowed remote attackers to access API endpoints despite the intended localhost-only restriction. The localRequest middleware validated the Host header but failed to verify the actual remote socket address, enabling trivial bypasses in containerized and multi-user environments.

high

CVE-2026-104850: MCP TypeScript SDK OAuth Credential Leak

The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) contained a flaw in its OAuth client flow where the MCP server being connected to could influence which authorization server the client talked to, meaning client credentials and token-exchange traffic could be directed to an endpoint the attacker controls. This project was pinned to the affected `1.30.0`; the dependency has been moved to `^1.31.0`, which carries the upstream fix for CVE-2026-104850. Any MCP client that performs OAuth against t

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

high

undici 8.10.0 Cache Poisoning: CVE-2026-85152 Auth Bypass

undici, the HTTP client used by Node.js's `fetch()` implementation, shipped a caching layer that did not properly isolate cached responses by origin, letting a response poisoned on one origin be served to requests for another. This created a path to cross-origin authentication bypass, tracked as CVE-2026-85152 and fixed in undici 8.10.2.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.