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.
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.
jsonwebtoken9 behaves exactly as documented.decodeis 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
jsonwebtokenwithjwt.verify. Also correct if you passalgorithms,issuerandaudienceand fetch keys with a JWKS client. We chosejosebecause 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-importsandno-restricted-propertiesrule flagsjwt.decode,decodeJwtandjsonwebtokenimports 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 checkiss,audandexp. - Fetch keys from JWKS by
kidso rotation does not need deploys. - Verify tokens in every service, not only at the gateway.
- Authorize against server-side data, not just claims.
References
- RFC 7519, JSON Web Token — https://www.rfc-editor.org/rfc/rfc7519
- RFC 8725, JSON Web Token Best Current Practices — https://www.rfc-editor.org/rfc/rfc8725
- jose library documentation — https://github.com/panva/jose
- jsonwebtoken README (
decodewarning) — https://github.com/auth0/node-jsonwebtoken#jwtdecodetoken--options - OWASP: JSON Web Token cheat sheet — https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html
Add a label
Embed this post
<iframe src="https://hibug.me/jwt-decoded-but-never-verified" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.