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

Fix JWT validation in TypeScript and Next.js

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

In short

Verify the bearer token inside the route handler with jose's jwtVerify(): the issuer's public key from configuration, algorithms: ['RS256'], issuer and audience, requiredClaims: ['exp', 'sub'] and a small clockTolerance. Never use decodeJwt() for authentication, and do not rely on proxy.ts (formerly middleware.ts) alone; a token for another API or tenant must fail.

Stack
  • Language TypeScript
  • Runtime Node.js 26.11.1 (the route handler uses the default Node.js runtime; jose also runs on the Edge runtime)
  • Framework Next.js 16.4.0 (App Router route handlers)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 36 KB
On this page

Steps

  1. Find every place that reads claims from a bearer token: decodeJwt(), jwt.decode() from jsonwebtoken, JSON.parse of the base64url-decoded middle segment, and route handlers or server actions that trust proxy.ts (middleware.ts before Next.js 16) or a gateway to have verified the token.
  2. Verify with jwtVerify(token, key, options) in the route handler, or in a data-access function it calls, on every request and before using any claim. Take the key from configuration: importSPKI() for one PEM key, or createRemoteJWKSet(new URL(jwksUri)) for an identity provider that rotates keys; never a key or key URL from the token's jwk, jku or x5u header.
  3. Pin algorithms to what the issuer uses (['RS256'] here), and 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.
  4. Add requiredClaims: ['exp', 'sub'], because jose checks exp only when the token has one, and keep clockTolerance in seconds (30 here).
  5. Answer every failure with 401 and WWW-Authenticate: Bearer error="invalid_token" (RFC 6750), and log the reason (err.code) on the server only.
  6. Read configuration when the first request arrives rather than at module load, so next build does not need the key, and fail closed if it is missing.
  7. Add regression tests that call the route handler with a Request carrying this card's tokens: 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

Vulnerablelib/impl/vulnerable.tsTypeScript
export async function createVerifier(_config: VerifierConfig): Promise<Verifier> {
  // "Proxy checks the token, the route only needs the user ID."
  return async (token) => decodeJwt(token);
}

With IMPL=vulnerable, 9 of 14 tests fail (9 exploit tests).

The fix

Fixedlib/impl/fixed.tsTypeScript
export async function createVerifier({ issuer, audience, publicKeyPem }: VerifierConfig): Promise<Verifier> {
  // 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 fixlib/impl/wrong.tsTypeScript
export async function createVerifier({ publicKeyPem }: VerifierConfig): Promise<Verifier> {
  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.

Testtests/jwt.test.tsTypeScript
describe(`exploit (IMPL=${implName})`, () => {
  // Every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.
  async function expectRejected(token: string) {
    const res = await getAccount(token);
    expect(res.status, `the token was accepted: HTTP ${res.status} ${res.body}`).toBe(401);
    expect(res.body).not.toMatch(/alice@example\.test/);
  }

  test('exploit: an unsigned token (alg "none") is rejected', async () => {
    await expectRejected(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 expectRejected(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 expectRejected(signRs256(header, claimsFor('alice'), attackerKeys.privateKey));
  });

  test("exploit: Mallory's token with its payload changed to Alice's is rejected", async () => {
    await expectRejected(withPayload(issue(claimsFor('mallory')), claimsFor('alice')));
  });

  test('exploit: a token that expired an hour ago is rejected', async () => {
    const t = now();
    await expectRejected(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 expectRejected(issue(claimsFor('alice', { iat: t - 420, exp: t - 120 })));
  });

  test('exploit: a token without exp is rejected', async () => {
    const { exp: _exp, ...neverExpires } = claimsFor('alice');
    await expectRejected(issue(neverExpires));
  });

  test('exploit: a token the identity provider issued for another API (aud) is rejected', async () => {
    await expectRejected(issue(claimsFor('alice', { aud: seed.otherAudience })));
  });

  test('exploit: a token from another tenant that shares the signing key (iss) is rejected', async () => {
    await expectRejected(issue(claimsFor('alice', { iss: seed.otherIssuer })));
  });
});

Pitfalls

  • Next.js verifies nothing about bearer tokens: no built-in proxy, route option or config validates an Authorization header. Whatever checks it is your code.
  • proxy.ts (middleware.ts before Next.js 16) is an optimistic first check, not the control. The Next.js authentication guide says it should not be your only line of defense, its example matcher skips /api entirely, and CVE-2025-29927 let a request skip middleware with an x-middleware-subrequest header in Next.js before 15.2.3, 14.2.25, 13.5.9 and 12.3.5.
  • decodeJwt() verifies nothing; its output is whatever the caller wrote. Use it for logging 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.
  • 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.
  • Server actions are public POST endpoints too; an action that acts on behalf of a token holder needs the same verification, not a check in the page that renders the form.

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/typescript-nextjs does this for you).
  2. The nine exploit tests build tokens byte by byte with node:crypto, all claiming to be Alice, and call GET /api/account with them: 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.
  3. Against your own deployment, 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"; send the same requests with an x-middleware-subrequest header to confirm nothing depends on proxy.ts alone.

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 TypeScript and Next.js
ToolRuleWhat it found here
Semgrepjavascript.jose.security.jwt-none-alg.jwt-none-algNo findings. The rule matches only the jose 1.x and 2.x API (JWT.verify(token, JWK.None)); jose 6 has neither, and decodeJwt() is outside its patterns.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/javascript.jose.security.jwt-none-alg.jwt-none-alg app lib
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 lib/impl/vulnerable.ts is not covered, and no rule checks that jwtVerify() gets issuer and audience.Checked: Semgrep 1.180.0 (CE)
Semgrepp/jwt, p/default, p/typescript, p/security-audit, p/owasp-top-tenNo findings on any implementation.Checked: Semgrep 1.180.0 (CE); on app and lib
CodeQLjs/decode-jwt-without-verificationReports remote input that reaches jose's decodeJwt() and never a verifying call, but it is experimental (not in the default or security-extended suites). Next.qll models request.headers of a GET exported from app/**/route.ts as a source; here GET comes from a factory in lib/account-handler.ts, and whether the analysis follows it was not tested. It has no notion of issuer or audience, so lib/impl/wrong.ts would pass.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageTypeScript
RuntimeNode.js 26.11.1 (the route handler uses the default Node.js runtime; jose also runs on the Edge runtime)
FrameworkNext.js 16.4.0 (App Router route handlers)
next16.4.0
jose6.2.12
react, react-dom19.3.0
node:crypto (tests build and sign tokens with it)Node.js 26.11.1
vitest (test only)5.0.3
typescript (type check and next build only)6.0.3

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/typescript-nextjsfolder.

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/typescript-nextjs runs all three modes and checks the expected results.

Download the project ZIP, 36 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • next 16.4.0
  • jose 6.2.12
  • react 19.3.0
  • vitest 5.0.3 (Vite 8.3.4)
  • typescript 6.0.3
  • semgrep 1.180.0
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, next 16.4.0 on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (TypeScript 7 no longer exports the compiler API next build's type check loads). npm audit found 0 vulnerabilities. tsc --noEmit and next build pass. 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. OWASP JSON Web Token Cheat Sheet
  3. jose: JWTVerifyOptions (algorithms, issuer, audience, requiredClaims, clockTolerance, typ)
  4. jose: decodeJwt() (does not check the signature)
  5. Next.js: Authentication guide (Proxy as an optimistic check, Data Access Layer, Route Handlers)
  6. Next.js: proxy.js file convention (renamed from middleware)
  7. GHSA-f82v-jwr5-mffw / CVE-2025-29927: Authorization bypass in Next.js middleware
  8. RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)
  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.