Security Bug Report

JWT decoded but never verified: how our API trusted unsigned claims

An internal reporting service read the user ID and role from a JWT with jwt.decode(), which never checks the signature. A route that bypassed our gateway let any edited token through. We switched to verification with a pinned algorithm, issuer, audience and JWKS rotation.

Daniel Tran Daniel Tran @fun_dev · November 12, 2024 · 9 min read · 5 Views
Banner: JSON Web Token payload trusted without verifying its signature

This one did not start with an outage. During a scheduled internal security review in October, an engineer reading the code of our reporting service stopped at its authentication middleware:

const claims = jwt.decode(req.headers.authorization?.replace('Bearer ', ''));
req.user = { id: claims.sub, role: claims.role, tenantId: claims.tid };

jwt.decode from the jsonwebtoken package parses the token and returns the payload. It does not check the signature, the expiry or anything else. The package README says so plainly, but the name sounds like it does the work.

For a long time this was not exploitable, because the reporting service sat behind our API gateway, which verified tokens before forwarding requests. The review found that this assumption had quietly broken.

Reproducing it

In our staging environment we took a valid token for a test user in tenant A, changed the payload, re-encoded it without touching the signature, and called the service:

# A real staging token for user 1042 (role "viewer", tenant "t_a")
TOKEN=$(./scripts/staging-login.sh viewer@example.com)

# Edit only the middle segment: role -> "admin", tid -> "t_b". Signature left as-is.
FORGED=$(node scripts/edit-claims.mjs "$TOKEN" '{"role":"admin","tid":"t_b"}')

curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $FORGED" \
  https://reports-internal.staging.example.com/v1/exports/monthly

Through the gateway the request was rejected with 401 as expected. Sent to the service's internal hostname, which the partner export job used, it returned 200 and tenant B's report. The signature no longer matched the payload, and nothing noticed.

What we ruled out

  • Gateway misconfiguration. The gateway verified every token correctly; the problem was traffic that never went through it.
  • Token leakage. The test proved anyone with any valid token, or even an expired one, could forge claims. Access logs showed no forged tokens in production (details below).
  • The library being vulnerable. jsonwebtoken 9 behaves exactly as documented. decode is meant for reading claims you have already verified or do not rely on.

Root cause

In June, a scheduled partner export job was added. To avoid the gateway's rate limits, it called the reporting service through an internal load balancer that was also reachable from the shared VPN used by contractors. From that path, the service's only "authentication" was jwt.decode. Any user with a valid login could edit the role and tid claims in their own token and read any tenant's reports.

What is happening under the hood

A JWS-signed JWT is three base64url-encoded parts joined by dots: header, payload, signature. The signature covers the first two parts. Decoding is just base64url plus JSON.parse; anyone can do it and anyone can produce a new payload. Only verification proves the payload was issued by someone holding the signing key.

Correct verification is more than the signature:

Check Claim or header Why it matters
Signature alg, kid, signature Proves the issuer created this exact payload
Algorithm allowlist alg Stops none tokens and key-confusion tricks (for example HS256 with an RSA public key as secret)
Issuer iss A token from another identity provider or environment must not be accepted
Audience aud A token meant for service A must not work on service B
Time exp, nbf Expired or not-yet-valid tokens are rejected, with small clock skew allowed

RFC 8725 (JWT Best Current Practices) lists these as requirements, not nice-to-haves.

Environment

Component Version / value
Runtime Node.js 20.18
Framework Express 4.21
JWT library (before) jsonwebtoken 9.0.2, using decode()
JWT library (after) jose 5.9
Identity provider OpenID Connect provider issuing RS256 tokens, JWKS endpoint, keys rotated every 90 days
Exposure Internal load balancer reachable from the contractor VPN

Minimal reproduction

import jwt from 'jsonwebtoken';

// Any string with three base64url segments and a JSON payload decodes fine.
const header  = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: '1', role: 'admin' })).toString('base64url');
const token   = `${header}.${payload}.not-a-real-signature`;

console.log(jwt.decode(token)); // { sub: '1', role: 'admin' }  -- no error, no check

Who is affected

Any service that reads identity from a JWT using a decode-only function: jwt.decode in jsonwebtoken, decodeJwt in jose, jwt.decode(..., options={"verify_signature": False}) in PyJWT, or a hand-written base64 split. The risk is highest in internal services that "trust the gateway", because internal network paths change over time and nobody re-reviews the assumption.

The fix

We replaced the middleware with real verification using jose, which fetches and caches the identity provider's JWKS and selects the key by kid, so key rotation needs no deploy.

// auth.mjs
import { createRemoteJWKSet, jwtVerify, errors } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://login.example.com/.well-known/jwks.json'));

export async function requireAuth(req, res, next) {
  const header = req.get('authorization') ?? '';
  const [scheme, token] = header.split(' ');
  if (scheme !== 'Bearer' || !token) return res.status(401).json({ error: 'missing_token' });

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      algorithms: ['RS256'],                    // never accept anything else
      issuer: 'https://login.example.com/',
      audience: 'reports-api',
      clockTolerance: 30,                       // seconds
    });
    req.user = { id: payload.sub, role: payload.role, tenantId: payload.tid };
    return next();
  } catch (err) {
    const reason = err instanceof errors.JWTExpired ? 'token_expired' : 'invalid_token';
    req.log.warn({ reason, code: err.code }, 'jwt rejected');
    return res.status(401).json({ error: reason });
  }
}

The diff at the call site was small; the important part is what the verify call enforces:

-const claims = jwt.decode(token);
+const { payload: claims } = await jwtVerify(token, JWKS, {
+  algorithms: ['RS256'], issuer: 'https://login.example.com/', audience: 'reports-api',
+});

We also asked the identity provider team to issue tokens for the reporting service with aud: "reports-api". Previously every token carried the same generic audience, which would have let a token for any of our services be replayed here.

Authorization stayed server-side. The tenant in the token is now only used to look up the user's memberships in our database; access to a report requires that membership, not just a claim.

Network defense in depth. The internal load balancer is no longer reachable from the contractor VPN, and the partner export job now uses a client-credentials token with its own audience and scope.

Alternatives and trade-offs

  • jsonwebtoken with jwt.verify. Also correct if you pass algorithms, issuer and audience and fetch keys with a JWKS client. We chose jose because it handles JWKS caching and rotation in one place and has no dependencies.
  • Rely on the gateway and remove all other paths. Works only as long as every path goes through the gateway. We now verify in every service and treat the gateway as an extra layer.
  • Opaque tokens with introspection. Revocation becomes immediate, at the cost of a network call per request.

How to test the fix

Unit tests sign tokens with a local test key pair and assert that every bad variant fails:

import { generateKeyPair, SignJWT, createLocalJWKSet, exportJWK, jwtVerify } from 'jose';
import { test, expect } from 'vitest';

const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwks = createLocalJWKSet({ keys: [{ ...(await exportJWK(publicKey)), kid: 'k1', alg: 'RS256' }] });
const opts = { algorithms: ['RS256'], issuer: 'https://login.example.com/', audience: 'reports-api' };

const sign = (claims, { iss = opts.issuer, aud = opts.audience, exp = '5m' } = {}) =>
  new SignJWT(claims).setProtectedHeader({ alg: 'RS256', kid: 'k1' })
    .setIssuer(iss).setAudience(aud).setIssuedAt().setExpirationTime(exp).sign(privateKey);

test('accepts a valid token', async () => {
  await expect(jwtVerify(await sign({ sub: '1' }), jwks, opts)).resolves.toBeTruthy();
});

test('rejects edited payload', async () => {
  const [h, , s] = (await sign({ sub: '1', role: 'viewer' })).split('.');
  const p = Buffer.from(JSON.stringify({ sub: '1', role: 'admin' })).toString('base64url');
  await expect(jwtVerify(`${h}.${p}.${s}`, jwks, opts)).rejects.toThrow();
});

test('rejects wrong audience and expired tokens', async () => {
  await expect(jwtVerify(await sign({}, { aud: 'billing-api' }), jwks, opts)).rejects.toThrow();
  await expect(jwtVerify(await sign({}, { exp: Math.floor(Date.now() / 1000) - 120 }), jwks, opts)).rejects.toThrow();
});
npx vitest run auth

Confirming in production

  • The staging forge test now returns 401 on both paths, and the service logs jwt rejected reason=invalid_token code=ERR_JWS_SIGNATURE_VERIFICATION_FAILED.
  • In production, rejected-token logs settled at around 40 per day, all expired tokens from long-open browser tabs.
  • We searched 180 days of access logs for requests to the internal hostname whose token signature did not verify against the JWKS keys valid at the time. We found none, and we documented that result in the incident record.

Regression guard

  • An ESLint no-restricted-imports and no-restricted-properties rule flags jwt.decode, decodeJwt and jsonwebtoken imports outside the shared auth package.
  • Every service's integration suite includes the "edited payload" and "wrong audience" tests from a shared helper.
  • A quarterly review lists every network path to each internal service, with the auth check that protects it.

Security implications

This was an authentication bypass with cross-tenant data exposure, reachable by any authenticated user on the VPN. We treated it as a security incident: fixed within a day of discovery, checked the logs for exploitation, and informed our security lead and the affected partner contract owner as our policy requires. The fix narrows the attack surface further by pinning the algorithm, binding tokens to an audience and enforcing tenant membership server-side. Keep in mind that JWT payloads are only encoded, not encrypted: never put secrets or personal data in them.

Prevention checklist

  • Never use decode-only functions for authentication or authorization decisions.
  • Always pin algorithms, and check iss, aud and exp.
  • Fetch keys from JWKS by kid so rotation does not need deploys.
  • Verify tokens in every service, not only at the gateway.
  • Authorize against server-side data, not just claims.

References

Add a label

#jwt #nodejs #authentication #jsonwebtoken #jose
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.