Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix JWT validation in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Add requiredClaims: ['exp', 'sub'], because jose checks exp only when the token has one, and keep clockTolerance in seconds (30 here).
  6. Answer every failure with 401 and WWW-Authenticate: Bearer error="invalid_token" (RFC 6750), and log the reason (err.code) on the server only.
  7. 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.
  8. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.jsJavaScript
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

Fixedimpl/fixed.jsJavaScript
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

Wrong fiximpl/wrong.jsJavaScript
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 regression test

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.

Testtest/jwt.test.jsJavaScript
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 })));
  });
});

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix JWT validation in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.jose.security.jwt-none-alg.jwt-none-algNo 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
Semgrepjavascript.jsonwebtoken.security.audit.jwt-decode-without-verify.jwt-decode-without-verifyNo 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)
CodeQLjs/decode-jwt-without-verificationFrom 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
CodeQLjs/jwt-missing-verificationNot 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
jose6.2.12
node:crypto (tests build and sign tokens with it)Node.js 26.11.1

Run the tests

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.

Shell
npm ci
Shell
# 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 --silent

Each 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.

Download the project ZIP, 27 KB

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

  1. RFC 8725: JSON Web Token Best Current Practices (2.1 weak signatures, 3.1 algorithm verification, 3.8 issuer, 3.9 audience)
  2. OWASP JSON Web Token Cheat Sheet
  3. jose: jwtVerify()
  4. jose: JWTVerifyOptions (algorithms, issuer, audience, requiredClaims, clockTolerance, typ)
  5. jose: decodeJwt() (does not check the signature)
  6. RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (section 4, validating access tokens)
  7. RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)
  8. GHSA-c7hr-j4mj-j2w6 / CVE-2015-9235: verification bypass in jsonwebtoken before 4.2.2
  9. PortSwigger Web Security Academy: JWT algorithm confusion

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.