verify()

Check a compact JWS against a key, a JWK Set or a key lookup, then validate its header and claims and return the payload.

import { verify } from "unjwt/jws";

const { payload, protectedHeader } = await verify(token, publicKey, {
  issuer: "https://auth.example.com",
  audience: "api",
});
// payload:         { sub: "user_1", iss: "https://auth.example.com", aud: "api", iat: 1792068494, exp: 1792072094 }
// protectedHeader: { kid: "ySN6Jmo0…", alg: "ES256", typ: "JWT" }

#Signature

function verify<T extends object = JWTClaims>(
  jws: string,
  key: JWSVerifyJWK | JWKSet | CryptoKey | Uint8Array | JWSKeyLookup,
  options?: JWSVerifyOptions,
): Promise<{ payload: T | string | Uint8Array; protectedHeader: JWSProtectedHeader }>;

type JWSKeyLookup = (
  header: JWSHeaderParameters, // the decoded protected header, its `alg` already checked
  token: string,
) => key | JWKSet | Promise<key | JWKSet>;

#Options

OptionTypeDefaultWhat it does
algorithmsJWSAlgorithm[]inferred from the keyThe alg values to accept. Required for raw bytes and alg-less keys.
payload"json" | "string" | "bytes""json"How to decode the payload. Sets the return type.
validateClaimsbooleantrue under "json"false skips the claim checks below.
audiencestring | string[]noneExpected aud. Without it aud is not checked.
issuerstring | string[]noneExpected iss.
subjectstringnoneExpected sub.
maxTokenAgeDurationnoneRejects a token whose iat is older than this.
clockTolerancenumber | Duration0Seconds of skew allowed on exp, nbf and iat.
requiredClaimsstring[][]Claim names that must be present.
currentDateDatenew Date()The moment the time claims are checked against.
typstringnoneExpected header typ, compared as a media type.
recognizedHeadersstring[][]crit extensions your code understands.
allowSymmetricJWKSetbooleanfalseAdmit oct entries of a JWK Set.
limits.keyCandidatesnumber8Most JWK Set entries one token may be tried against.

Every option is checked before the token is read. An empty audience, issuer, subject or typ, a maxTokenAge of 0, or a list that is not an array of strings is ERR_INVALID_OPTION. An empty list such as algorithms: [] accepts no token.

#Return shape

protectedHeader is the decoded header as signed. payload follows the payload option:

payloadpayload typeClaims validated
omitted or "json"T, JWTClaims unless you pass oneyes
"string"string (strict UTF-8)no
"bytes"Uint8Arrayno

The signer never chooses the shape: typ and cty do not change how the payload is decoded. Under "json" a payload that is not a JSON object is ERR_JWS_INVALID.

type Session = { sub: string; role: "admin" | "user" };
const { payload } = await verify<Session>(token, publicKey);
payload.role; // "admin"

const text = await sign("hello", privateKey);
await verify(text, publicKey);
// JWTError ERR_JWS_INVALID: Invalid JWS: Payload is not a JSON object.
(await verify(text, publicKey, { payload: "string" })).payload; // "hello"
(await verify(text, publicKey, { payload: "bytes" })).payload; // Uint8Array(5) [104, 101, 108, 108, 111]

The claims type can be a type alias or an interface. A claims type together with payload: "string" or "bytes" is a type error, and so is validateClaims: true with either (ERR_INVALID_OPTION at runtime).

#Keys

A single key. A public JWK, a CryptoKey or HMAC bytes. An asymmetric JWK still carrying d is ERR_JWK_INVALID, so a private key cannot slip into a verifier.

A JWK Set ({ keys: [...] }). The header's kid is a hint to pick the key (RFC 7515 §4.1.4). An entry is a candidate when:

  • it is a JWK and not oct, unless allowSymmetricJWKSet is set;
  • its kid equals the header kid, when the header has one;
  • its alg, when it declares one, equals the header alg;
  • its use is absent or "sig", and its key_ops is absent or includes "verify".

No candidate is ERR_JWK_KEY_NOT_FOUND before any crypto runs. Candidates are tried in order and the first that verifies wins. When none verifies, ERR_JWS_SIGNATURE_INVALID carries one JWTError per attempt as its cause. More than limits.keyCandidates candidates is ERR_JWK_KEY_NOT_FOUND, which bounds the work a token without a kid can cause.

await verify(token, { keys: [other.publicKey, publicKey] }); // picks publicKey by kid
await verify(token, { keys: [other.publicKey] });
// JWTError ERR_JWK_KEY_NOT_FOUND: No key found in JWK Set with kid "ySN6Jmo0…".

A JWK Set is treated as public material. Keep allowSymmetricJWKSet off for any set fetched from a jwks_uri, or anyone who can add an oct entry could forge tokens.

A lookup function receives the decoded header and the raw token and returns any of the above. Throwing, or returning undefined or null, is ERR_JWK_KEY_NOT_FOUND with the thrown value as cause.

await verify(token, async (header) => keysByKid.get(header.kid), { algorithms: ["ES256"] });

Strings are refused as keys with ERR_JWK_INVALID. See JWK Sets for building and rotating sets.

#Algorithm allow-list

The header names its own alg, and the attacker writes the header. verify decides what to accept before trusting it:

"none" and any alg outside the supported list are ERR_JWS_ALG_NOT_ALLOWED, before the key is touched.
With algorithms, the header alg must be in it.
Without it, a JWK that declares alg accepts that one only.
Otherwise the list is inferred from the key shape (curve, hash, key type; for a JWK Set, the union over its entries). Raw bytes infer nothing, so algorithms is required.
await verify(hsToken, secretBytes);
// JWTError ERR_JWS_ALG_NOT_ALLOWED: Cannot infer allowed algorithms from this key; pass "options.algorithms" explicitly.
await verify(hsToken, secretBytes, { algorithms: ["HS256"] }); // ok
await verify(token, publicKey, { algorithms: ["RS256"] });
// JWTError ERR_JWS_ALG_NOT_ALLOWED: Algorithm not allowed: ES256

#Claims

Under payload: "json", after the signature checks out, verify runs validateJwtClaims:

  • exp, nbf and iat must be non-negative numbers when present. An exp at or before now is ERR_JWT_EXPIRED, an nbf in the future is ERR_JWT_NBF, each widened by clockTolerance.
  • issuer, audience, subject and maxTokenAge each require their claim (iss, aud, sub, iat) and then compare it.
  • maxTokenAge rejects an old iat with ERR_JWT_EXPIRED and a future iat with ERR_JWT_CLAIM_INVALID.
await verify(expired, publicKey);
// JWTError ERR_JWT_EXPIRED: JWT "exp" (Expiration Time) Claim validation failed: Token has expired (exp: 2026-10-15T11:49:14.000Z)
// error.cause: { jti: undefined, iat: 1792064894, exp: 1792064954 }
await verify(token, publicKey, { audience: "billing" });
// JWTError ERR_JWT_CLAIM_INVALID: Invalid JWT "aud" (Audience) Claim: Expected billing, got api
await verify(token, publicKey, { requiredClaims: ["jti"] });
// JWTError ERR_JWT_CLAIM_MISSING: Missing required JWT Claims: jti

#Header checks

typ. With typ set, the header must carry a matching typ. The comparison is case-insensitive and ignores an application/ prefix on either side (RFC 7515 §4.1.9), so "application/AT+JWT" matches "at+jwt". Pin typ when one key signs several kinds of token, so one kind cannot pass as another (RFC 8725 §3.11).

await verify(token, publicKey, { typ: "at+jwt" });
// JWTError ERR_JWS_INVALID: Invalid JWS: "typ" (Type) Header Parameter mismatch. Expected "at+jwt", got "JWT".

crit. Each listed name must be present in the header, must not be a registered name, and must be understood: b64 always is, anything else only when listed in recognizedHeaders.

await verify(critToken, publicKey);
// JWTError ERR_JWS_INVALID: Unprocessed critical header parameters: urn:example:ext
await verify(critToken, publicKey, { recognizedHeaders: ["urn:example:ext"] }); // ok

b64. A b64: false token (RFC 7797) verifies only when crit lists "b64". The unencoded segment then goes through the same payload modes.

#Errors

CodeWhen
ERR_INVALID_OPTIONA malformed or empty option, or validateClaims: true with a non-JSON mode.
ERR_JWS_INVALIDNot a string, not three segments, a header that is not a JSON object or lacks alg, a non-canonical segment, a typ mismatch, a b64 or crit violation, a payload that does not match the mode.
ERR_JWS_ALG_NOT_ALLOWED"none", an unsupported alg, one outside the allow-list, or a key nothing can be inferred from.
ERR_JWK_KEY_NOT_FOUNDNo JWK Set candidate, too many candidates, or a lookup that threw or returned nothing.
ERR_JWK_INVALIDA string key, a private JWK, or a key that cannot serve the alg (Web Crypto error as cause).
ERR_JWS_SIGNATURE_INVALIDThe signature does not match. For a JWK Set, cause lists each attempt.
ERR_JWT_EXPIRED, ERR_JWT_NBF, ERR_JWT_CLAIM_INVALID, ERR_JWT_CLAIM_MISSINGA claim check failed.

#See also