Skip to content
ecdsa.com

Verify a JWT with JWKS · Node.js

How to verify a JWT with a JWKS in Node.js

To verify a JWT against a JWKS in Node.js, jose's createRemoteJWKSet fetches and caches the issuer's key set, and jwtVerify picks the key by the token's kid, checks the ES256 signature, and validates iss, aud and exp. The run serves a real JWKS over HTTPS inside the container and checks five tokens: one valid and four rejected, each for its own reason.

Tested with jose 6.2.13 (Node.js 24.22.0).

verify.mjs — remote JWKS, pinned algorithm, issuer, audience and exp
import { createRemoteJWKSet, jwtVerify } from "jose";
// Fetched on first use, cached, and fetched again when a token names an unknown kid
// (at most once per 30 seconds)
const jwks = createRemoteJWKSet(new URL("https://issuer.example.com/.well-known/jwks.json"));
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, jwks, {
algorithms: ["ES256"], // never let the token choose the algorithm
issuer: "https://issuer.example.com",
audience: "api://orders",
requiredClaims: ["exp"], // exp is checked when present; this makes it mandatory
});
return payload;
}
check every token in tokens.txt
import { readFileSync } from "node:fs";
for (const line of readFileSync("tokens.txt", "utf8").trim().split("\n")) {
const [label, token] = line.split(" ");
try {
const claims = await verifyAccessToken(token);
console.log(`${label}: accepted, sub=${claims.sub}`);
} catch (err) {
console.log(`${label}: rejected: ${err.code}: ${err.message}`);
}
}

How it works

  1. createRemoteJWKSet(url) returns a key-resolver function. The set is fetched on first use, cached, and fetched again when a token names a kid the cache does not have.
  2. jwtVerify selects the JWK whose kid, kty, crv and alg fit the token, and verifies the signature before looking at any claim.
  3. algorithms, issuer and audience turn the checks on; requiredClaims: ["exp"] also rejects tokens without an expiry.
  4. Each failure has its own code — ERR_JWT_CLAIM_VALIDATION_FAILED, ERR_JWT_EXPIRED, ERR_JWKS_NO_MATCHING_KEY — so the caller can map them to 401 responses or logs.

Gotchas

  • exp is only checked when present: a token without it passes jwtVerify unless requiredClaims includes "exp". Issuers usually set it; an attacker-minted token signed by a leaked key does not have to.
  • Without algorithms, the remote key set still refuses an HS256 token (ERR_JOSE_NOT_SUPPORTED: Unsupported "alg" value for a JSON Web Key Set), but pinning the expected algorithm is the documented defence — keep it explicit.
  • A token without kid against a set with two matching keys fails with ERR_JWKS_MULTIPLE_MATCHING_KEYS; jose does not try the keys one by one. Issuers that rotate keys must set kid.
  • The unknown-kid refetch has a 30-second cooldown (cooldownDuration), so a flood of tokens with invented kid values cannot turn into a flood of requests to the issuer.

Related recipes