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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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(_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
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
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 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 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 })));
});
});- 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.
- 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).
- 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.
- 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.
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. 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 |
| 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 lib/impl/vulnerable.ts is not covered, and no rule checks that jwtVerify() gets issuer and audience.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/jwt, p/default, p/typescript, p/security-audit, p/owasp-top-ten | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); on app and lib |
| CodeQL | js/decode-jwt-without-verification | Reports 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 |
| Component | Version |
|---|---|
| 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) |
| next | 16.4.0 |
| jose | 6.2.12 |
| react, react-dom | 19.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 |
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.
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/typescript-nextjs 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
- 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
- RFC 8725: JSON Web Token Best Current Practices
- OWASP JSON Web Token Cheat Sheet
- jose: JWTVerifyOptions (algorithms, issuer, audience, requiredClaims, clockTolerance, typ)
- jose: decodeJwt() (does not check the signature)
- Next.js: Authentication guide (Proxy as an optimistic check, Data Access Layer, Route Handlers)
- Next.js: proxy.js file convention (renamed from middleware)
- GHSA-f82v-jwr5-mffw / CVE-2025-29927: Authorization bypass in Next.js middleware
- RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)
- PortSwigger Web Security Academy: JWT algorithm confusion
Attack patterns: