How to Verify a JWT in Node.js and Python
Decoding a JWT shows its claims; verifying it proves they are genuine. Here are the checks a correct verification makes, written out in Node.js and Python so you can see each step.
To verify a JWT in Node.js or Python, you check its signature and then its claims, in that order. The JWT Decoder shows what a token contains, and its page explains why decoding proves nothing about whether the token is genuine. This guide covers the step that does.
In production, use a maintained library such as jose for Node.js or PyJWT for Python. The code below spells out the checks for an HMAC-signed token (HS256) so you know what that library must do, and what to look for when reviewing code that verifies tokens.
The checks a JWT verification must make
- Algorithm. Accept only the algorithm you expect. Never take it from the token's own header, or an attacker can choose
noneor switch algorithms. - Signature. Recompute it over the first two parts with your key and compare in constant time.
- Expiry. Reject the token when
expis in the past, and whennbf(not before) is in the future. - Issuer and audience.
issmust be the identity provider you trust, andaudmust name your service, so a token issued for another application is refused.
Verify a JWT in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyHs256(token, secret, { issuer, audience }) {
const [header, payload, signature] = token.split(".");
if (JSON.parse(Buffer.from(header, "base64url")).alg !== "HS256") throw new Error("unexpected alg");
const expected = createHmac("sha256", secret).update(header + "." + payload).digest();
const actual = Buffer.from(signature, "base64url");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new Error("bad signature");
const claims = JSON.parse(Buffer.from(payload, "base64url"));
const now = Math.floor(Date.now() / 1000);
if (claims.exp <= now) throw new Error("expired");
if (claims.nbf && claims.nbf > now) throw new Error("not yet valid");
if (claims.iss !== issuer || claims.aud !== audience) throw new Error("wrong issuer or audience");
return claims;
}Tested against three tokens: a valid one returned its claims; one with the payload changed to "sub": "admin" failed with bad signature; and one with "alg": "none" and an empty signature failed with unexpected alg. timingSafeEqual matters: a normal comparison stops at the first different byte, and the time it takes can leak how much of a forged signature was right.
Verify a JWT in Python
import base64, hashlib, hmac, json, time
def b64url(data):
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
def verify_hs256(token, secret, issuer, audience):
header, payload, signature = token.split(".")
if json.loads(b64url(header))["alg"] != "HS256":
raise ValueError("unexpected alg")
expected = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url(signature)):
raise ValueError("bad signature")
claims = json.loads(b64url(payload))
if claims["exp"] <= time.time():
raise ValueError("expired")
if claims.get("nbf", 0) > time.time():
raise ValueError("not yet valid")
if claims.get("iss") != issuer or claims.get("aud") != audience:
raise ValueError("wrong issuer or audience")
return claimshmac.compare_digest is Python's constant-time comparison. The padding helper is needed because JWT segments drop the = characters that Python's decoder expects.
Tokens signed with RS256 or ES256
Most identity providers sign with a private key (RS256 or ES256) and publish the public keys at a JWKS URL. The checks are the same, but the signature step uses the public key matching the token's kid header. Here a library is not optional: it fetches and caches the keys, picks the right one and handles key rotation. Still pin the algorithm in its options.
Related tools
Decode Base64 back to readable text, including URL-safe input.
Compute SHA-1, SHA-256, SHA-384, and SHA-512 hashes of text or files.
Convert between Unix timestamps and human-readable dates, both ways.
Pretty-print JSON with configurable indentation and instant validation.
Related guides
Decoding Base64 is one call in every language, and each call has a trap: atob garbles accents, Buffer accepts junk, and Python rejects missing padding. Here is the correct version of each.
A published SHA-256 checksum only helps if you compare it. Here are the exact commands for each operating system, a one-step check against a checksum file, and code for Node.js and Python.
JSON has no date type, so every API picks a format for dates. Here is how Unix timestamps and ISO 8601 strings compare, the bugs each one invites, and what to use where.
Two formatters can disagree about the same JSON file. Here is how a team picks one, applies it in every editor and before every commit, and makes CI enforce it.