How to Verify a JWT Signature (JWT Validation) in Node.js and Python, with JWKS
Decoding a JWT only shows you what the token claims. Verifying it proves the claims were issued by someone holding the right key and have not been changed. The signature check is one line in any library. The mistakes are in what you pass to that line: which algorithms you accept, where the key comes from, and which claims you check afterwards.
What verification actually checks
A JWS-signed JWT has three Base64url parts: header, payload and signature. The signature is computed over the exact string base64url(header) + "." + base64url(payload). Verifying means recomputing or checking that signature with the expected key, and rejecting the token if it does not match.
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9 header {"alg":"RS256","kid":"abc123"}
.eyJzdWIiOiI0MiIsImV4cCI6MTc5MDAwMDAwMH0 payload {"sub":"42","exp":1790000000}
.kR3v...signature... signature over "<header>.<payload>"
Two families of algorithms are common. HMAC (HS256, HS384, HS512) uses one shared secret to both sign and verify, so every verifier could also mint tokens. Asymmetric algorithms (RS256, PS256, ES256, EdDSA) sign with a private key and verify with a public key, so an API can verify tokens without being able to create them. Identity providers such as Auth0, Okta, Entra ID, Cognito and Google use asymmetric keys.
A valid signature alone does not make a token acceptable. After the signature, check the registered claims: exp (expiry), nbf (not before), iss (issuer) and aud (audience). A correctly signed token that has expired, or was issued for a different API, must still be rejected.
Verifying in Node.js
The jose library works in Node, browsers, Deno and edge runtimes, and verifies the signature and the standard claims in one call:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
export async function verifyAccessToken(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // pin what you expect
issuer: 'https://auth.example.com/',
audience: 'https://api.example.com',
clockTolerance: 30, // seconds of skew allowed on exp/nbf
});
return payload; // throws on any failure
}
With the older jsonwebtoken package, pass the algorithms explicitly as well:
const jwt = require('jsonwebtoken');
const payload = jwt.verify(token, publicKeyPem, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com/',
audience: 'https://api.example.com',
});
// jwt.decode() does NOT verify anything — never use it for authorisation
Verifying in Python
PyJWT requires the algorithms argument when verifying, and its PyJWKClient fetches and caches signing keys from a JWKS URL:
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient('https://auth.example.com/.well-known/jwks.json')
def verify_access_token(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=['RS256'],
audience='https://api.example.com',
issuer='https://auth.example.com/',
leeway=30,
options={'require': ['exp', 'iss', 'aud']},
)
jwt.decode(token, options={'verify_signature': False}) is useful for debugging and dangerous anywhere else. Search your codebase for it. It is a common way for an unverified token to end up in an authorisation decision.
For HMAC tokens, pass the shared secret as bytes and use at least 256 bits of randomness. RFC 7518 requires the HS256 key to be at least as long as the hash output. A short human-chosen secret can be brute-forced offline from any single token.
Where the key comes from: JWKS and kid
Providers publish their public keys as a JSON Web Key Set, usually at /.well-known/jwks.json. Its location is listed as jwks_uri in the OpenID Connect discovery document at /.well-known/openid-configuration. Each key has a kid, and the token header names the kid it was signed with.
{
"keys": [
{ "kty": "RSA", "kid": "abc123", "use": "sig", "alg": "RS256",
"n": "0vx7agoebGcQSuu...", "e": "AQAB" }
]
}
Cache the key set, and refetch it when a token arrives with an unknown kid. That is how providers rotate keys without downtime. Rate-limit the refetch, or an attacker can make your service hammer the JWKS endpoint by sending random kid values. createRemoteJWKSet and PyJWKClient both cache for you.
Only fetch keys from a URL you configured. Never follow a jku or x5u URL from the token header, and never accept an embedded jwk. That would let whoever made the token also supply the key that verifies it.
The attacks the options exist to stop
alg: none. The JWT specification allows unsecured tokens whose header says "alg": "none" and whose signature is empty. A library that trusts the header's alg will accept a forged token with no signature at all. Pinning algorithms to the values you issue closes this.
Algorithm confusion. Suppose an API expects RS256 and loads the RSA public key as a string. An attacker changes the header to HS256 and signs the token with HMAC, using the public key, which is public, as the secret. A library that picks the algorithm from the header then verifies it successfully. The fix is the same: an explicit allow-list, and key objects typed for one algorithm.
Accepting any valid signature. If several services share one identity provider, a token issued for service A is signed with the same key as one for service B. Without an aud check, B accepts A's tokens. Always validate the audience, and the issuer when more than one tenant or provider could sign.
These three account for most published JWT vulnerabilities. RFC 8725, JWT Best Current Practices, lists them together with the required mitigations.
Verifying by hand to debug a failure
When a library rejects a token you believe is valid, reproduce the check step by step. The most common causes are the wrong key, meaning a kid that is not in the JWKS you loaded, a token signed by a different environment's provider, an audience mismatch, and clock skew on exp or nbf.
# HS256: recompute the signature with openssl and compare
HEADER_PAYLOAD='eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9'
printf '%s' "$HEADER_PAYLOAD" \
| openssl dgst -sha256 -hmac 'your-256-bit-secret' -binary \
| base64 | tr '+/' '-_' | tr -d '='
# the output must equal the third part of the token
Two details break hand-made checks. The signature is Base64url without padding, not standard Base64, hence the tr calls. And the signing input is the original encoded header and payload, not a re-serialised copy of the decoded JSON. Changing key order or whitespace changes the signature.
A verification checklist
Pin the accepted algorithms. Load keys from a configured JWKS URL or key file, never from the token. Require and check exp, and nbf when present, with a small clock tolerance. Check iss against the exact issuer string, including a trailing slash if the provider uses one. Check aud against this service's identifier. Reject tokens whose typ or token_use shows an ID token where an access token is expected. Only then read sub, scopes or roles.
Treat any exception from the verify call as a 401. Do not fall back to decoding without verification.
Sources and further reading
RFC 7519 defines JWT and its registered claims; RFC 7515 defines JWS signatures; RFC 7517 defines JWK and JWK Sets; RFC 7518 defines the algorithms and HMAC key-length requirements. RFC 8725 (JWT Best Current Practices) describes alg: none, algorithm confusion and audience validation. The jose and PyJWT documentation cover the options used above.
Frequently asked questions
How do I verify a JWT signature?
Use a maintained library with the expected key and an explicit algorithm list: `jwtVerify(token, key, { algorithms: ['RS256'] })` with `jose`, or `jwt.decode(token, key, algorithms=['RS256'])` with PyJWT. Then check `exp`, `iss` and `aud`; good libraries do this when you pass the expected values.
Can I verify a JWT without the secret key?
For asymmetric algorithms such as RS256 or ES256, yes: you only need the public key, usually from the issuer's JWKS endpoint. For HMAC algorithms such as HS256, no: verification needs the same shared secret that signed the token.
What is the difference between decoding and verifying a JWT?
Decoding Base64url-decodes the header and payload so you can read them, and anyone can do it. Verifying checks the signature with the right key and validates the claims. Only a verified token can be trusted.
Why does JWT verification fail with invalid signature?
Usually the wrong key: a `kid` missing from the loaded JWKS, a token from another environment, or an HS256 secret with different bytes. It can also be a modified token, or an algorithm that is not in your allow-list.
Should I check exp myself?
Most libraries check `exp` and `nbf` automatically during verification, but some only do so when the claim is present. Make `exp` required, allow a small clock tolerance, and keep access tokens short-lived.
What is a JWKS?
A JSON Web Key Set: a JSON document listing an issuer's public signing keys, each identified by `kid`. Verifiers fetch it, cache it, and select the key whose `kid` matches the token header.