Validating a JWT properly

Authentication and authorization Mid-level Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

What do the three lines print?

Python 3.13
import base64
import hashlib
import hmac
import json


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def sign(claims: dict[str, object], key: bytes) -> str:
    head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64(json.dumps(claims).encode())
    mac = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256)
    return f"{head}.{body}.{b64(mac.digest())}"


def verify(token: str, key: bytes, now: int) -> dict[str, object]:
    head, body, sig = token.split(".")
    if json.loads(unb64(head)).get("alg") != "HS256":
        raise ValueError("unexpected alg")
    mac = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256)
    if not hmac.compare_digest(b64(mac.digest()), sig):
        raise ValueError("bad signature")
    claims: dict[str, object] = json.loads(unb64(body))
    exp = claims.get("exp")
    if not isinstance(exp, int) or exp <= now:
        raise ValueError("expired")
    return claims


key = b"server-side-secret"
tok = sign({"sub": "user-7", "exp": 2_000_000_000}, key)
print(json.loads(unb64(tok.split(".")[1]))["sub"])  # anyone can read it
print(verify(tok, key, now=1_900_000_000)["sub"])
try:
    verify(tok, b"wrong-key", now=1_900_000_000)
except ValueError as e:
    print(e)

Options
Hint 1 of 1

Is the payload encrypted?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A user-7, user-7, bad signature

Correct. The payload is readable without the key; verification with the right key passes and the wrong key fails.

A JWT payload is only base64url, so anyone can read it. The signature is what proves it came from you: recomputing the HMAC with the right key matches, and with any other key it doesn't.

Why not the others?
  • B An error, then user-7, bad signature. Reading the payload needs no key: it's only base64url.
  • C user-7, user-7, user-7. A different key produces a different MAC.
  • D user-7, expired, bad signature. exp is 2,000,000,000, after the 'now' passed in.

30-second answer

The payload of a signed JWT (JWS) is only base64url-encoded, not encrypted: anyone holding the token can read it, so never put secrets in it. Before trusting it, verify the signature with a key you chose (and an algorithm you pinned, never 'none' or whatever the header says), then check exp and nbf, the issuer (iss) and the audience (aud) for your service. Only then use sub and scopes.

Practise saying it · 35 s. No recording, just a timer

How do you validate a JWT?

Say your answer out loud, then check which of these points you covered:

  • Payload is readable
  • Verify signature, pinned alg
  • exp and nbf
  • iss and aud
  • JWKS by kid

Deep dive

Algorithm confusion: accepting HS256 with an RSA public key as the HMAC secret lets attackers forge tokens; pin the algorithm per key.

Fetch keys from the issuer's JWKS URL and cache them; select by kid.

Allow a small clock skew (a minute or so) for exp and nbf.

Encrypted tokens (JWE) exist, but most APIs use signed-only tokens.

Sign and verify HS256 by hand (use a library in production) Ran on Python 3.13.5 · mypy --strict
Python 3.13
import base64
import hashlib
import hmac
import json


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def sign(claims: dict[str, object], key: bytes) -> str:
    head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64(json.dumps(claims).encode())
    mac = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256)
    return f"{head}.{body}.{b64(mac.digest())}"


def verify(token: str, key: bytes, now: int) -> dict[str, object]:
    head, body, sig = token.split(".")
    if json.loads(unb64(head)).get("alg") != "HS256":
        raise ValueError("unexpected alg")
    mac = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256)
    if not hmac.compare_digest(b64(mac.digest()), sig):
        raise ValueError("bad signature")
    claims: dict[str, object] = json.loads(unb64(body))
    exp = claims.get("exp")
    if not isinstance(exp, int) or exp <= now:
        raise ValueError("expired")
    return claims


key = b"server-side-secret"
tok = sign({"sub": "user-7", "exp": 2_000_000_000}, key)
print(json.loads(unb64(tok.split(".")[1]))["sub"])  # anyone can read it
print(verify(tok, key, now=1_900_000_000)["sub"])
try:
    verify(tok, b"wrong-key", now=1_900_000_000)
except ValueError as e:
    print(e)

Common mistakes

  • Decoding without verifying.
  • Trusting the alg in the header.
  • Skipping the audience check, so a token for another service works on yours.

Senior take & follow-ups

Say the payload is readable, then the verification order: signature with pinned alg, exp/nbf, iss, aud.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why check aud?

    Show hint

    A valid token meant for another API shouldn't work on yours.

  2. Where do keys come from?

    Show hint

    The issuer's JWKS endpoint, cached and rotated.

Suggest a fix if something here is wrong or out of date.