Verify every bearer token with jose's jwtVerify(): pass the issuer's public key from configuration, algorithms: ['RS256'], issuer and audience, requiredClaims: ['exp', 'sub'] and a small clockTolerance. Never use decodeJwt() for authentication: it checks nothing. A valid signature alone is not enough; a token for another API or tenant must fail.
- Find every place that reads claims from a bearer token: jose's decodeJwt(), jsonwebtoken's jwt.decode(), JSON.parse of the base64url-decoded middle segment, and middleware that trusts a gateway to have verified the token.
- Verify with jwtVerify(token, key, options) on every request, before using any claim. Take the key from configuration: importSPKI() for a single PEM key, or createRemoteJWKSet(new URL(jwksUri)) for an identity provider that rotates keys, never a URL or key named in the token's jku, x5u or jwk header.
- Pin algorithms to what the issuer uses (['RS256'] here). jose never accepts alg none, and a key imported for RS256 cannot verify HS256, but the allowlist states the intent and rejects other algorithms before any key is used.
- Set issuer and audience to exact values from configuration, so that a token the same identity provider signed for another API, or for another tenant, fails.
- Add requiredClaims: ['exp', 'sub'], because jose checks exp only when the token has one, and keep clockTolerance in seconds (30 here).
- Answer every failure with 401 and WWW-Authenticate: Bearer error="invalid_token" (RFC 6750), and log the reason (err.code) on the server only.
- If your access tokens follow RFC 9068, also pass typ: 'at+jwt' so that an ID token or another JWT from the same issuer cannot stand in for an access token.
- Add regression tests like this card's: alg none, HS256 signed with the public key, an attacker's key in the jwk header, an edited payload, expired tokens, no exp, another API's aud and another tenant's iss.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export async function createVerifier() {
// "The API gateway checks tokens, we only need the user ID."
return async (token) => decodeJwt(token);
}With IMPL=vulnerable, 9 of 14 tests fail (9 exploit tests).
The fix
export async function createVerifier({ issuer, audience, publicKeyPem }) {
// The key comes from configuration, never from the token. With an identity provider that
// rotates keys, use createRemoteJWKSet(new URL(jwksUri)) instead of one fixed key.
const issuerKey = await importSPKI(publicKeyPem, 'RS256');
return async (token) => {
const { payload } = await jwtVerify(token, issuerKey, {
algorithms: ['RS256'], // the issuer's algorithm only: never "none", never HS256 with a public key
issuer, // "iss" must be present and equal our issuer
audience, // "aud" must be present and include this API
requiredClaims: ['exp', 'sub'], // jose checks "exp" only when the token has one
clockTolerance: 30, // seconds of clock skew between the issuer and this server
});
return payload;
};
}With IMPL=fixed, 14 of 14 tests pass.
A fix that looks safe but is not
export async function createVerifier({ publicKeyPem }) {
const issuerKey = await importSPKI(publicKeyPem, 'RS256');
return async (token) => {
// Signature and algorithm are checked; issuer, audience and a required "exp" are not.
const { payload } = await jwtVerify(token, issuerKey, { algorithms: ['RS256'], clockTolerance: 30 });
return payload;
};
}With IMPL=wrong, 3 of 14 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// Every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.
async function assertRejected(token) {
const res = await getAccount(token);
assert.equal(res.status, 401, `the token was accepted: HTTP ${res.status} ${res.body}`);
assert.doesNotMatch(res.body, /alice@example\.test/);
}
test('exploit: an unsigned token (alg "none") is rejected', async () => {
await assertRejected(unsecured({ alg: 'none', typ: 'JWT' }, claimsFor('alice')));
});
test('exploit: an HS256 token whose HMAC secret is the public key is rejected (key confusion)', async () => {
// The public key is public. A verifier that lets the token pick HS256 and passes it the
// RSA public key would check this HMAC with the very bytes the attacker used.
const header = { alg: 'HS256', typ: 'JWT', kid: seed.keyId };
await assertRejected(signHs256(header, claimsFor('alice'), issuerKeys.publicKeyPem));
});
test("exploit: a token signed with the attacker's own key, embedded in its jwk header, is rejected", async () => {
const header = { ...HEADER, jwk: attackerKeys.publicJwk };
await assertRejected(signRs256(header, claimsFor('alice'), attackerKeys.privateKey));
});
test("exploit: Mallory's token with its payload changed to Alice's is rejected", async () => {
await assertRejected(withPayload(issue(claimsFor('mallory')), claimsFor('alice')));
});
test('exploit: a token that expired an hour ago is rejected', async () => {
const t = now();
await assertRejected(issue(claimsFor('alice', { iat: t - 3900, exp: t - 3600 })));
});
test('exploit: a token that expired two minutes ago is rejected (beyond the 30-second tolerance)', async () => {
const t = now();
await assertRejected(issue(claimsFor('alice', { iat: t - 420, exp: t - 120 })));
});
test('exploit: a token without exp is rejected', async () => {
const { exp, ...neverExpires } = claimsFor('alice');
await assertRejected(issue(neverExpires));
});
test('exploit: a token the identity provider issued for another API (aud) is rejected', async () => {
await assertRejected(issue(claimsFor('alice', { aud: seed.otherAudience })));
});
test('exploit: a token from another tenant that shares the signing key (iss) is rejected', async () => {
await assertRejected(issue(claimsFor('alice', { iss: seed.otherIssuer })));
});
});- decodeJwt() (and jsonwebtoken's jwt.decode()) verifies nothing; its output is whatever the caller wrote. Use it for logging or routing at most, never for an authentication or authorization decision.
- jwtVerify() without issuer and audience accepts any token the identity provider signs, for any API and, when tenants share signing keys, for any tenant. The signature says who issued the token, not who it was issued for.
- jose validates exp only when the claim is present: without requiredClaims: ['exp'] a token with no exp never expires. maxTokenAge limits the age by iat; it does not replace exp.
- clockTolerance is in seconds (or a string such as '30s') and extends every token's life by that much. RFC 7519 allows leeway of usually no more than a few minutes; keep it to seconds.
- Select keys only from the configured issuer's key set. Fetching keys from a URL the token names (jku, x5u) or trusting a key it embeds (jwk) lets the attacker supply the key; this card's tests send a token signed with the attacker's own key in its jwk header.
- Do not accept HS256 and RS256 with the same key material. RFC 8725 requires each key to be used with exactly one algorithm; CVE-2015-9235 (jsonwebtoken before 4.2.2) verified HS256 tokens with the RSA public key as the HMAC secret.
- Express verifies nothing by itself: put the check in middleware that runs before every protected route, and fail closed with 401 when the Authorization header is missing or malformed.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs jwt-validation/node-express does this for you).
- The nine exploit tests build tokens byte by byte with node:crypto, all claiming to be Alice: alg none, HS256 signed with the public key, the attacker's key in a jwk header, Mallory's token with an edited payload, expired an hour ago, expired two minutes ago, no exp, another API's aud and another tenant's iss. Each must get 401 without Alice's data. The functional tests keep valid tokens, 10 seconds of clock skew and a multi-audience aud working.
- Against your own API, replay a token the identity provider issued for a different API and one whose payload you edited, and expect 401 with WWW-Authenticate: Bearer error="invalid_token". The scanners checked here do not see a missing issuer or audience check; the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.jose.security.jwt-none-alg.jwt-none-alg | No findings on vulnerable.js, fixed.js, wrong.js or app.js. The rule matches only the jose 1.x and 2.x API, JWT.verify(token, JWK.None) after require("jose"); a probe file with that code was flagged. jose 6 has no JWT.verify or JWK.None, and decodeJwt() is outside the rule's patterns.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/javascript.jose.security.jwt-none-alg.jwt-none-alg <file> on each impl file and app.js |
| Semgrep | javascript.jsonwebtoken.security.audit.jwt-decode-without-verify.jwt-decode-without-verify | No findings. The rule is written for the jsonwebtoken package's jwt.decode(); jose's decodeJwt() in impl/vulnerable.js is not covered. No Semgrep CE rule checks that jwtVerify() is given issuer and audience, so impl/wrong.js is invisible to Semgrep.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/decode-jwt-without-verification | From the query and its JWT.qll: it reports remote input that reaches jose's decodeJwt() (or jsonwebtoken's decode(), jwt-decode, jwt-simple without verification) and never reaches a verifying call. It sits in the experimental folder, so the default and security-extended suites do not run it, and whether it follows the token from app.js into impl/vulnerable.js through the dynamic import was not tested. It has no notion of issuer or audience, so impl/wrong.js would pass.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/jwt-missing-verification | Not applicable: the query only reports jsonwebtoken's verify() called with a secret that evaluates to false. It does not model jose.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| jose | 6.2.12 |
| node:crypto (tests build and sign tokens with it) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the jwt-validation/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs jwt-validation/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- jose 6.2.12
- Matrix
- fixed: 14 of 14 tests pass; vulnerable: 9 of 14 tests fail (9 exploit tests); wrong: 3 of 14 tests fail (3 exploit tests)
- Test results
- fixed 14/14 pass; vulnerable 9/14 fail (all 9 exploit tests); wrong 3/14 fail (3 exploit tests: no exp, another API's aud, another tenant's iss), offline after npm ci
- Dependency freshness
- jose 6.2.12 was published on 2026-09-05. express 5.3.0 was published on 2026-10-09 and is taken at one day old because it is a security release (qs, body-parser and proxy-addr). npm audit found 0 vulnerabilities, npm audit signatures verified all 66 packages, and OSV listed no advisories for them on 2026-10-10. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- RFC 8725: JSON Web Token Best Current Practices (2.1 weak signatures, 3.1 algorithm verification, 3.8 issuer, 3.9 audience)
- OWASP JSON Web Token Cheat Sheet
- jose: jwtVerify()
- jose: JWTVerifyOptions (algorithms, issuer, audience, requiredClaims, clockTolerance, typ)
- jose: decodeJwt() (does not check the signature)
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (section 4, validating access tokens)
- RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)
- GHSA-c7hr-j4mj-j2w6 / CVE-2015-9235: verification bypass in jsonwebtoken before 4.2.2
- PortSwigger Web Security Academy: JWT algorithm confusion
Attack patterns: