JWT Signature Verification Failed? Debug kid, JWKS and Claims in Node.js
By OpenFinanceTools · Updated
A readable JWT can still be completely untrustworthy. If verification fails, first identify the failing layer: token format, permitted algorithm, key selection, signature, or claims. Changing an audience setting will not repair a bad signature, and downloading another key will not make an expired token valid.
This guide gives you a local failure lab, the expected errors, and a practical investigation sequence. Use the JWT Decoder & Validator to inspect synthetic examples alongside it. The examples are diagnostic exercises, not a bank integration or a FAPI conformance claim.
1. Find the failing layer before changing settings
A three-part compact JWS contains an encoded header, payload and signature. Decoding the first two parts is inspection; it does not establish who signed them. A signed JWT also does not conceal its payload. The JWT specification distinguishes signed and encrypted representations. Source: RFC 7519.
| Symptom | Investigate first | A useful next action |
|---|---|---|
| Token cannot be parsed | Wrong format or damaged input | Remove accidental quotes or the Bearer prefix; establish whether the issuer uses opaque tokens or JWE. |
| Algorithm rejected | Your explicit algorithm policy | Compare the integration contract with the configured allowlist. Do not trust the header to choose policy. |
| No matching key | Issuer, JWKS and kid | Check that the trusted issuer publishes an eligible public key for this identifier. |
| Signature verification failed | Signing key or changed bytes | Check environment, rotation and whether the original token was altered. |
| Claim validation failed | Issuer, audience, time and token type | Compare expected values with the issuer's documented contract. |
The order matters. Capture the library's error code before replacing settings. A generic application message such as “unauthorized” can cover all five categories. Keep a request correlation ID so that you can connect the client failure to a server-side reason without retaining the entire token.
2. Run a local lab with real signatures
Tested environment: Node.js 22.19.0 and jose 5.10.0. The version pin reproduces this site's test environment; it is not a claim that this is the newest package release. The script generates disposable keys in memory and uses a fixed verification date so it will not start failing as the calendar advances.
Create an empty directory, download jwt-verification-lab.mjs into it, then run:
npm init -y
npm install --save-exact jose@5.10.0
node jwt-verification-lab.mjs
The important verification settings are shown below. The lab chooses PS256 and an access-token type of at+jwt. Match these to the actual token profile in a real integration; do not apply an access-token verifier to an ID token.
const options = {
issuer: 'https://issuer.example',
audience: 'https://api.example',
algorithms: ['PS256'],
typ: 'at+jwt',
requiredClaims: ['iss', 'aud', 'sub', 'iat', 'exp'],
currentDate: new Date('2026-09-15T12:00:00Z'),
clockTolerance: 5,
};
const { payload } = await jwtVerify(token, resolveKey, options);
Remove currentDate in production. Freezing the verifier's clock there would defeat normal expiry handling. Five seconds of tolerance is a choice for this lab, not a universal banking requirement. Also, requiring iat to exist does not by itself impose a maximum token age; configure an age policy separately when your application requires one.
Complete, executable lab
// Synthetic local fixtures only. Tested with Node.js 22.19.0 and jose 5.10.0.
import assert from 'node:assert/strict';
import {
generateKeyPair, exportJWK, SignJWT, createLocalJWKSet, jwtVerify,
} from 'jose';
const issuer = 'https://issuer.example';
const audience = 'https://api.example';
const now = new Date('2026-09-15T12:00:00Z');
const epoch = Math.floor(now.getTime() / 1000);
const { publicKey, privateKey } = await generateKeyPair('PS256');
const other = await generateKeyPair('PS256');
const jwk = { ...await exportJWK(publicKey), kid: 'key-2026-09', alg: 'PS256', use: 'sig' };
const resolveKey = createLocalJWKSet({ keys: [jwk] });
const options = {
issuer, audience, algorithms: ['PS256'], typ: 'at+jwt',
requiredClaims: ['iss', 'aud', 'sub', 'iat', 'exp'],
currentDate: now, clockTolerance: 5,
};
async function issue(claims = {}, header = {}, key = privateKey) {
return new SignJWT({
iss: issuer, aud: audience, sub: 'synthetic-user',
iat: epoch, exp: epoch + 300, ...claims,
}).setProtectedHeader({ alg: 'PS256', typ: 'at+jwt', kid: jwk.kid, ...header }).sign(key);
}
const valid = await issue();
assert.equal((await jwtVerify(valid, resolveKey, options)).payload.sub, 'synthetic-user');
console.log('PASS valid token');
const failures = [
['unknown kid', await issue({}, { kid: 'unpublished-key' }), 'ERR_JWKS_NO_MATCHING_KEY'],
['wrong signing key', await issue({}, {}, other.privateKey), 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED'],
['wrong audience', await issue({ aud: 'https://other-api.example' }), 'ERR_JWT_CLAIM_VALIDATION_FAILED'],
['wrong issuer', await issue({ iss: 'https://other-issuer.example' }), 'ERR_JWT_CLAIM_VALIDATION_FAILED'],
['expired token', await issue({ exp: epoch - 30 }), 'ERR_JWT_EXPIRED'],
['future nbf', await issue({ nbf: epoch + 120 }), 'ERR_JWT_CLAIM_VALIDATION_FAILED'],
['wrong token type', await issue({}, { typ: 'id+jwt' }), 'ERR_JWT_CLAIM_VALIDATION_FAILED'],
];
for (const [label, token, code] of failures) {
await assert.rejects(() => jwtVerify(token, resolveKey, options), { code });
console.log(`PASS ${label}: ${code}`);
}
await assert.rejects(
() => jwtVerify(valid, resolveKey, { ...options, algorithms: ['ES256'] }),
{ code: 'ERR_JOSE_ALG_NOT_ALLOWED' },
);
console.log('PASS disallowed algorithm: ERR_JOSE_ALG_NOT_ALLOWED');
console.log('9 checks passed');
The exact terminal output is:
PASS valid token
PASS unknown kid: ERR_JWKS_NO_MATCHING_KEY
PASS wrong signing key: ERR_JWS_SIGNATURE_VERIFICATION_FAILED
PASS wrong audience: ERR_JWT_CLAIM_VALIDATION_FAILED
PASS wrong issuer: ERR_JWT_CLAIM_VALIDATION_FAILED
PASS expired token: ERR_JWT_EXPIRED
PASS future nbf: ERR_JWT_CLAIM_VALIDATION_FAILED
PASS wrong token type: ERR_JWT_CLAIM_VALIDATION_FAILED
PASS disallowed algorithm: ERR_JOSE_ALG_NOT_ALLOWED
9 checks passed
These are expected failures, asserted by the script. The “wrong signing key” case deliberately signs with a second private key while retaining the first key's identifier. That reproduces an especially confusing incident: the kid exists, but verification still fails. The test proves why an identifier match alone is insufficient.
3. Diagnose the key, not just the kid
kid helps a verifier select a key; it is not an identity proof. Use a JWKS associated with an issuer you already trust. Never turn an arbitrary token's iss, jku or x5u into an unrestricted server-side fetch. Fixing an authentication failure should not introduce a request to an attacker-controlled or internal endpoint.
The lab uses createLocalJWKSet to isolate cryptographic behavior from networking. A deployed service may use a remote resolver, but its configured endpoint, cache and refresh policy become additional things to test. Consult the documentation for the version you run; do not assume all JWKS clients refresh at the same time.
- Check the environment. A sandbox token and production JWKS can have plausible-looking but incompatible identifiers.
- Check key eligibility. Compare the key type, intended use and algorithm with your configured verifier.
- Check publication order. Before an issuer signs with a new key, its public key must be available to consumers that need it.
- Check cache behavior. Reproduce the failure with the actual resolver. A fresh download working while a long-running worker fails points toward refresh behavior.
- Check overlap. During planned rotation, retain verification support for still-valid tokens. A suspected compromise needs a separate emergency procedure.
Do not hard-code “use the first key in the array.” Do not respond to an unknown identifier by retrying indefinitely. An attacker can send arbitrary identifiers, so refresh attempts need limits and observable failure behavior. For a public-key representation check, the JWKS Generator can help inspect the expected structure; it does not publish or rotate keys for your issuer.
4. A correct signature is only one gate
After the signature passes, the token must still belong to your application and be valid for the current request. Explicit algorithm verification and rules that distinguish different kinds of JWTs are central recommendations in JWT Best Current Practices, RFC 8725.
- Issuer: compare the expected identifier precisely. A trailing slash can be significant; do not normalize identifiers casually.
- Audience: establish whether the expected recipient is an API, client or another service. Different token purposes have different recipients.
- Time: JWT NumericDate values use seconds. A milliseconds value or unsynchronized clock can produce misleading results. Use the Timestamp Converter to inspect a synthetic value.
- Required claims: decide what must be present, rather than merely validating a claim when it happens to exist.
- Authorization: check the operation's permissions and consent separately. Passing JWT verification does not authorize every API action.
5. Keep the diagnostic fixes in regression tests
For each real incident, add the smallest synthetic case that reproduces it. Keep a valid control case beside the failure: a test that rejects every token can look secure while preventing all legitimate traffic. Record the expected error category and whether the application maps it to an appropriate response.
A useful deployment checklist is: trusted issuer configuration; fixed algorithm policy; expected audience and type; synchronized clocks; required claims; bounded key refresh; and redacted logs. Add tests for the actual remote cache and rollover sequence separately from this offline lab. Do not make success depend on a live bank sandbox being available in every unit-test run.
Only use synthetic tokens in shared debugging tools and issue trackers. Decoding can expose account identifiers, names or scopes even when the signature is invalid. The JWT tooling overview helps choose an inspection workflow; the lab here checks specific acceptance and rejection behavior.
Common questions
Why does a token decode successfully but fail verification?
Decoding reads bytes. Verification checks the signature against a trusted key and enforces your claims policy. An attacker can produce a perfectly readable payload.
Should I allow another algorithm until it works?
Only if the documented integration requires that algorithm and you have tested its key handling. Expanding policy to accommodate untrusted input hides the original problem.
Does this validate FAPI compliance?
No. This lab checks JWT behavior. The FAPI 2.0 guide covers the wider authorization flow and the need for ecosystem-specific conformance testing.
Method: AI-assisted drafting with local execution of the downloadable synthetic lab. The nine cases above were exercised with the stated versions. No production customer tokens or bank credentials were used.