Skip to content
ecdsa.com
← All articles

JWT verification mistakes that let bad tokens through

A JWT library verifies exactly what you ask it to and nothing more. Here is what four popular libraries accept when you leave the options out, the mistakes that follow, and a verifier that rejects the right tokens, run against real test tokens.

Verifying a JWT is not one check but six, and the signature is only one of them. The short version: the verifier, not the token, decides how the token is checked. Pin the algorithm on the server, look the key up among keys you already trust for that issuer, require exp, compare iss and aud exactly, check typ so one kind of token cannot stand in for another, allow at most a minute of clock skew, and cache the JWKS with a rate-limited refresh for unknown kid values. Everything below is what goes wrong when one of those is missing, measured against real libraries.

All output on this page comes from tokens issued for the article with a freshly generated ES256 key, verified by jose 6.2.13 and jsonwebtoken 9.0.3 (with jwks-rsa 4.1.0) on Node.js 24.22, PyJWT 2.15.1 on Python 3.14.8, and golang-jwt 5.3.1 with keyfunc 3.8.2 on Go 1.27.3, each in its official Docker image.

What a library checks when you pass only the key

Most JWT bugs are not bugs in the library. They are calls that verify the signature and stop there, because the caller never told the library which issuer, audience or algorithm to expect. Here is what each library did with three tokens when called with a valid public key and no further options (PyJWT refuses to run without algorithms, so it got ["ES256"]):

Tokenjose jwtVerifyjsonwebtoken verifyPyJWT decodegolang-jwt Parse
Signed by the right key, but iss and aud of another serviceacceptedacceptedrejected: Invalid audienceaccepted
Signed by the right key, no exp claimacceptedacceptedacceptedaccepted
alg: "none", empty signaturerejectedrejectedrejectedrejected

Two lessons. First, all four libraries refuse unsigned tokens by default, so the famous none problem now mostly lives in hand-written verifiers. Second, a token without exp is valid forever in all four, and three of the four accept a token minted for a different service as long as the same key signed it. exp, nbf, iss and aud are optional claims in RFC 7519 §4.1; libraries check them when present and when configured, and the rest is up to you.

Mistake 1: letting the token choose the algorithm

The alg header arrives inside the token you have not verified yet. A verifier that reads it and dispatches on it lets the sender pick the rules. RFC 8725 §3.1, the JWT Best Current Practices, is blunt: libraries must let the caller specify the supported algorithms and must not use any others, and each key must be used with exactly one algorithm.

The classic failure is HS/RS (or HS/ES) confusion, described in RFC 8725 §2.1 and tracked for jsonwebtoken as CVE-2015-9235. A service holds one public key and verifies "whatever the header says". Someone computes an HMAC over a header with "alg": "HS256" using the public key's PEM text as the secret. The public key is public, so anyone can do this. Here is a hand-written verifier with exactly that shape:

naive.mjs: the anti-pattern, do not ship
import { createHmac, timingSafeEqual, verify } from "node:crypto";
import { readFileSync } from "node:fs";

// Anti-pattern: the token picks the algorithm, one key object serves both families.
function naiveVerify(token, publicKeyPem) {
  const [h, p, s] = token.split(".");
  const { alg } = JSON.parse(Buffer.from(h, "base64url"));
  const input = Buffer.from(`${h}.${p}`);
  const sig = Buffer.from(s, "base64url");
  if (alg === "HS256") {
    const mac = createHmac("sha256", publicKeyPem).update(input).digest();
    return mac.length === sig.length && timingSafeEqual(mac, sig);
  }
  if (alg === "ES256") {
    return verify("sha256", input, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, sig);
  }
  throw new Error(`unsupported alg ${alg}`);
}

const pem = readFileSync("out/public.pem", "utf8");
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
console.log("ES256 token from the issuer:", naiveVerify(tokens.ok, pem));
console.log("HS256 token keyed with the public PEM:", naiveVerify(tokens["HS256 keyed with the public PEM"], pem));
Output (node:24.22.0-bookworm-slim)
ES256 token from the issuer: true
HS256 token keyed with the public PEM: true

Both return true. The second token was made without the private key. Note that the constant-time comparison does not help: the code is careful about timing and still wrong about trust.

The fix is a fixed allowlist on the verifier side, ideally a single value, and keys that carry their algorithm. All four libraries rejected the forged token when given algorithms: ["ES256"], each with its own message: jose "alg" (Algorithm) Header Parameter value not allowed, jsonwebtoken invalid algorithm, PyJWT The specified alg value is not allowed, golang-jwt signing method HS256 is invalid. They also have second lines of defense, which is why the confusion is rare in library code today:

  • jsonwebtoken 9.x (since 9.0.0, December 2022) derives the allowed algorithms from the key type when you omit algorithms: an EC key allows only ES256, ES384 and ES512.
  • PyJWT refuses to use a PEM public key or certificate as an HMAC secret: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
  • golang-jwt checks the Go type of the key: the HMAC method wants []byte, so an *ecdsa.PublicKey fails with key is of invalid type.
  • jose checks that the key type fits the algorithm. Even without an algorithms option, the forged token against the EC public key failed with KeyObject instances must be of type "secret" for the HS256 algorithm.

None of these replace the allowlist. They protect the common case, not a custom key loader that hands the library raw bytes.

Mistake 2: accepting "none" in a hand-rolled path

RFC 7518 §3.6 registers "alg": "none" for tokens whose integrity is protected by something else. In the table above every library rejected it by default. The risk sits in code around the library: a "debug mode" that skips verification, a branch that treats an empty signature as "not signed yet", or a deny-list that compares alg case-sensitively. The revision of the BCP, draft-ietf-oauth-rfc8725bis (in the RFC Editor queue as of October 2026), calls out exactly that: a deny-list for none that a header saying noNE slips past. An allowlist has no such hole.

Mistake 3: trusting kid, jku, x5u or an embedded jwk

kid exists to pick one of several keys you already trust. It is not a pointer to a key the token supplies. RFC 8725 §3.10 says to treat received headers as untrusted input:

  • Look kid up in a map, not in a query or a path. A kid that reaches SQL, LDAP or a file name is an injection point. The JWKS libraries in this article only compare it against the kid values of keys they fetched from your configured URL, which is the right shape.
  • Ignore jku and x5u. Both are URLs inside the token. Fetching them lets the sender choose where your server connects and which keys it uses. Configure the JWKS URL per issuer instead; RFC 8725 suggests matching such URLs against an allowlist if you must use them at all.
  • Never verify an access token against a jwk or x5c embedded in its own header. A token signed with a key it carries proves only that the sender has some key. Protocols that do use an embedded key, such as DPoP proofs, bind it to something else (the access token's cnf claim). See DPoP explained.
  • Handle a missing kid explicitly. jwks-rsa refuses to guess when the set has several keys and the token names none (No KID specified and JWKS endpoint returned more than 1 key); a hand-written lookup that returns "the first key" will silently pick the wrong one after a rotation.

Mistake 4: not checking aud and iss, or checking them loosely

An identity provider usually signs tokens for many clients and APIs with the same key. If your API does not check aud, a token issued for any other application of the same provider works on yours, which is the first row of the table. RFC 8725 §3.9 requires the recipient to reject a JWT whose audience is missing or not its own, and RFC 9068 §4 requires an exact iss match for OAuth access tokens.

Three details matter in practice:

  • Exact string comparison. aud and iss are case-sensitive strings, and https://id.example.com is not https://id.example.com/. Copy the issuer from the provider's discovery document, not from a token you happen to have.
  • aud can be an array. Libraries accept the token if any element matches. That is correct per RFC 7519, but it means a token for ["https://api.example.com", "https://admin.example.com"] works at both APIs. If your API should only accept tokens minted for it alone, PyJWT has options={"strict_aud": True}; elsewhere, check the array length yourself.
  • Keys must belong to the issuer. RFC 8725 §3.8: if a token has an iss, the key that verified it must be that issuer's key. With several issuers, keep one JWKS client per issuer and choose it from configuration, never by the unverified iss of the token alone. Multi-tenant providers that share one key set across tenants (Microsoft Entra ID is the common case) make the iss check the only thing separating your tenant from everyone else's; OIDC ID token validation covers that in detail.

Mistake 5: tokens without exp, and generous leeway

A library checks exp when it is present. When it is absent, all four accepted the token. If your issuer ever forgets the claim, or someone finds a code path that mints tokens without it, those tokens never expire. Make exp required: requiredClaims in jose, options={"require": [...]} in PyJWT, jwt.WithExpirationRequired() in golang-jwt, and an explicit check in jsonwebtoken, which has no such option.

Clock skew is real, but small. RFC 7519 §4.1.4 allows "some small leeway, usually no more than a few minutes". Thirty to sixty seconds covers servers kept in sync by NTP. A leeway of an hour, often added to make a flaky test pass, extends every token's lifetime by an hour, including tokens you meant to cut off. The option is clockTolerance in jose and jsonwebtoken (seconds), leeway in PyJWT, and jwt.WithLeeway in golang-jwt. The same leeway applies to nbf.

When a verification fails with an expiry or not-before error on a token that "should" be valid, compare the server's clock with the issuer's before touching the leeway. A container or VM that resumed from suspend with a stale clock produces exactly the jwt not active and "nbf" claim timestamp check failed errors in the output below.

Mistake 6: accepting the wrong kind of token

An OpenID Connect provider signs ID tokens and access tokens with the same key, often with the same iss and sometimes with overlapping aud. An API that checks only signature, issuer and audience may accept an ID token as an access token. RFC 8725 §3.11 recommends explicit typing, and RFC 9068 makes it mandatory for JWT access tokens: the typ header must be at+jwt (or application/at+jwt), and resource servers must reject any other value. If your provider issues RFC 9068 tokens, check typ; if it does not, check a claim that only access tokens carry, such as scope or client_id.

Mistake 7: calling decode where you meant verify

Every library has a function that parses a token without verifying it: jwt.decode() in jsonwebtoken, decodeJwt() in jose, jwt.decode(..., options={"verify_signature": False}) in PyJWT, ParseUnverified in golang-jwt. They exist for routing (reading iss to pick a JWKS client) and for debugging. Code review should treat any use of them on a request path as suspicious, and any decision based on their output, other than choosing which verifier to call next, as a bug.

Mistake 8: fetching the JWKS on every request, or never

Fetching the key set on every request adds a network round trip to every call and turns the identity provider's availability into yours. Caching it forever means the first key rotation breaks every request until a restart. The right behavior, which the JWKS clients below implement, is a time-based cache plus a refresh when a token names an unknown kid, with a rate limit on that refresh so a stream of tokens with random kid values cannot turn your API into a load generator against the provider.

ClientCache lifetime (default)Unknown kidFetch timeout
jose createRemoteJWKSet10 minutes (cacheMaxAge)re-fetch, at most once per 30 s (cooldownDuration)5 s
jwks-rsa 4.1.010 minutes per key, 5 keysre-fetch on every miss; rateLimit is off unless enabled (then 10 per minute)30 s
PyJWT PyJWKClient300 s (lifespan)re-fetch, at most once per 30 s (cooldown_duration)30 s
keyfunc v3 NewDefaultbackground refresh every hourre-fetch, at most once per 5 minutes1 minute

Values are the defaults in the versions listed at the top, read from their source. The difference is measurable: 20 tokens with random kid values caused 20 fetches of the key set with jwks-rsa defaults, 10 with rateLimit: true (the other ten failed fast with JwksRateLimitError), and a single fetch with jose. Create the client once per issuer and reuse it: a JWKS client constructed inside the request handler has an empty cache every time. Rotation itself, from the issuer's side, is covered in JWKS key rotation without downtime.

A verifier that gets it right, in four libraries

The four verifiers below do the same thing: ES256 only, keys from the issuer's JWKS URL, exact iss and aud, exp required, typ: at+jwt, 30 seconds of leeway. Each was run against the same eight tokens.

verify-jose.mjs (jose 6.2.13)
import { createRemoteJWKSet, jwtVerify } from "jose";

// Created once per issuer and reused: caches the key set, re-fetches it
// when a token names an unknown kid (at most once per cooldown).
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL), {
  cacheMaxAge: 600_000, // re-fetch at least every 10 minutes
  cooldownDuration: 30_000, // at most one kid-miss re-fetch per 30 s
});

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ["ES256"], // fixed by the verifier, never taken from the token
    issuer: "https://id.example.com",
    audience: "https://api.example.com",
    typ: "at+jwt", // RFC 9068 access token, not an ID token
    requiredClaims: ["exp", "sub"], // exp is only checked when present
    clockTolerance: 30, // seconds
  });
  return payload;
}
verify-jsonwebtoken.mjs (jsonwebtoken 9.0.3, jwks-rsa 4.1.0)
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

const client = jwksClient({
  jwksUri: process.env.JWKS_URL,
  cache: true, // per-kid cache, 10 minutes by default
  rateLimit: true, // off by default; caps JWKS fetches at 10 per minute
});

const getKey = (header, callback) =>
  client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));

export function verifyAccessToken(token) {
  const options = {
    algorithms: ["ES256"],
    issuer: "https://id.example.com",
    audience: "https://api.example.com",
    clockTolerance: 30,
    complete: true, // also return the header, to check typ
  };
  return new Promise((resolve, reject) =>
    jwt.verify(token, getKey, options, (err, decoded) => {
      if (err) return reject(err);
      if (decoded.header.typ !== "at+jwt") return reject(new Error("unexpected typ"));
      if (typeof decoded.payload.exp !== "number") return reject(new Error("exp is required"));
      resolve(decoded.payload);
    }),
  );
}
verify_pyjwt.py (PyJWT 2.15.1, cryptography 50.0.2)
import os

import jwt

# One client per issuer, reused across requests: caches the key set and
# re-fetches on an unknown kid (at most once per cooldown).
jwks = jwt.PyJWKClient(os.environ["JWKS_URL"], lifespan=600)


def verify_access_token(token: str) -> dict:
    signing_key = jwks.get_signing_key_from_jwt(token)
    decoded = jwt.decode_complete(
        token,
        signing_key,
        algorithms=["ES256"],  # required argument in PyJWT 2.x
        issuer="https://id.example.com",
        audience="https://api.example.com",
        leeway=30,
        options={"require": ["exp", "iss", "aud", "sub"]},
    )
    if decoded["header"].get("typ") != "at+jwt":
        raise jwt.InvalidTokenError("unexpected typ")
    return decoded["payload"]
verify.go (golang-jwt v5.3.1, keyfunc v3.8.2)
package main

import (
	"context"
	"errors"
	"time"

	"github.com/MicahParks/keyfunc/v3"
	"github.com/golang-jwt/jwt/v5"
)

type Verifier struct {
	jwks   keyfunc.Keyfunc
	parser *jwt.Parser
}

func NewVerifier(ctx context.Context, jwksURL string) (*Verifier, error) {
	// Background refresh every hour; an unknown kid triggers at most
	// one extra fetch per 5 minutes (keyfunc defaults).
	jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
	if err != nil {
		return nil, err
	}
	parser := jwt.NewParser(
		jwt.WithValidMethods([]string{"ES256"}),
		jwt.WithIssuer("https://id.example.com"),
		jwt.WithAudience("https://api.example.com"),
		jwt.WithExpirationRequired(),
		jwt.WithLeeway(30*time.Second),
	)
	return &Verifier{jwks: jwks, parser: parser}, nil
}

func (v *Verifier) VerifyAccessToken(raw string) (jwt.MapClaims, error) {
	claims := jwt.MapClaims{}
	token, err := v.parser.ParseWithClaims(raw, claims, v.jwks.Keyfunc)
	if err != nil {
		return nil, err
	}
	if token.Header["typ"] != "at+jwt" {
		return nil, errors.New("unexpected typ")
	}
	return claims, nil
}

The test tokens: one valid token, and seven that each break exactly one rule. The JWKS was served over HTTP from inside the container, and every library fetched it through its JWKS client.

Output: jose and jsonwebtoken (node:24.22.0-bookworm-slim)
--- jose
ok                                 ACCEPTED sub=user-42
aud for another API                ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "aud" claim value
no exp                             ERR_JWT_CLAIM_VALIDATION_FAILED: missing required "exp" claim
expired 1 h ago                    ERR_JWT_EXPIRED: "exp" claim timestamp check failed
nbf in 1 h                         ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed
typ JWT (an ID token)              ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "typ" JWT header value
unknown kid                        ERR_JWKS_NO_MATCHING_KEY: no applicable key found in the JSON Web Key Set
HS256 keyed with the public PEM    ERR_JOSE_ALG_NOT_ALLOWED: "alg" (Algorithm) Header Parameter value not allowed
--- jsonwebtoken
ok                                 ACCEPTED sub=user-42
aud for another API                JsonWebTokenError: jwt audience invalid. expected: https://api.example.com
no exp                             Error: exp is required
expired 1 h ago                    TokenExpiredError: jwt expired
nbf in 1 h                         NotBeforeError: jwt not active
typ JWT (an ID token)              Error: unexpected typ
unknown kid                        JsonWebTokenError: error in secret or public key callback: Unable to find a signing key that matches '2026-09-z'
HS256 keyed with the public PEM    JsonWebTokenError: invalid algorithm
Output: PyJWT (python:3.14.8-slim-trixie) and golang-jwt (golang:1.27.3-trixie)
--- PyJWT
ok                                 ACCEPTED sub=user-42
aud for another API                InvalidAudienceError: Audience doesn't match
no exp                             MissingRequiredClaimError: Token is missing the "exp" claim
expired 1 h ago                    ExpiredSignatureError: Signature has expired
nbf in 1 h                         ImmatureSignatureError: The token is not yet valid (nbf)
typ JWT (an ID token)              InvalidTokenError: unexpected typ
unknown kid                        PyJWKClientError: Unable to find a signing key that matches: "2026-09-z"
HS256 keyed with the public PEM    InvalidAlgorithmError: The specified alg value is not allowed
--- golang-jwt v5 + keyfunc v3
ok                                 ACCEPTED sub=user-42
aud for another API                token has invalid claims: token has invalid audience
no exp                             token has invalid claims: token is missing required claim: exp claim is required
expired 1 h ago                    token has invalid claims: token is expired
nbf in 1 h                         token has invalid claims: token is not valid yet
typ JWT (an ID token)              unexpected typ
unknown kid                        token is unverifiable: error while executing keyfunc: key not found "2026-09-z"
failed keyfunc: could not read JWK from storage
HS256 keyed with the public PEM    token signature is invalid: signing method HS256 is invalid

Most of the library messages above have their own page in the error catalog with causes and fixes, for example unexpected "aud" claim value, jwt not active and no applicable key found in the JSON Web Key Set.

Checklist

  1. One verifier configuration per issuer, built at startup: JWKS URL, issuer, audience, algorithm list.
  2. Algorithms: a fixed allowlist, usually one value. Never derived from the token, never containing none.
  3. Keys: only from the configured JWKS or key file; kid used as a lookup key in a map; jku, x5u and embedded jwk ignored for access tokens.
  4. Claims: exp required; iss and aud compared exactly; nbf honored; leeway of 30 to 60 seconds.
  5. Token type: typ checked (at+jwt for RFC 9068 access tokens) or another claim that separates access tokens from ID tokens.
  6. JWKS cache: one client, reused; time-based refresh; unknown-kid refresh with a rate limit.
  7. No decode without verification on a request path.
  8. Tests: one valid token and one token per rule above, each expected to fail. The eight tokens in this article are a starting set.

Sources