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:
- Take an expired legitimate token (or construct one from scratch).
- Decode the payload and change
expto a timestamp far in the future — say, the year 2099. - Re-encode the header and payload with a fake or empty signature.
- 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:
- Decodes the header to determine the signing algorithm.
- Recomputes the expected signature using
config.jwtSecretand the token's header + payload. - Compares the computed signature to the one in the token. If they don't match, it throws a
JsonWebTokenError. - Checks standard claims including
exp(expiration),nbf(not before), andiss(issuer) if configured. - 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()intokenTtlSeconds()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
expclaim is only trustworthy after signature verification — reading it viajwt.decode()gives an attacker full control over how long their session appears to be valid. - The existing
try/catchpattern made the fix clean and safe —jwt.verify()throws on invalid tokens, and the catch block already handled errors gracefully by returning0. config.jwtSecretwas 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
tokenparameter totokenTtlSeconds()inbackend/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
expclaim from the token payload - CWE: CWE-347 — Improper Verification of Cryptographic Signature
- Fix: Replaced
jwt.decode(token)withjwt.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.