JWT HS256 by Hand: alg none, Weak Secrets, exp/aud

Build and verify an HS256 JWT with Python's stdlib, forge an alg none token against a naive verifier, crack a weak secret in 0.05 ms, then fix all three.

Series: Security Basics lecture 4/6 (intermediate). For developers who issue or check JSON Web Tokens in an API, a mobile app backend or a single-page app, usually through a library they have never looked inside. By the end you will have built and verified an HS256 JWT with nothing but the standard library, and you will know the three mistakes that turn a JWT into an open door.

JWTs fail in boring ways. OWASP's Top 10 (2021 edition) lists the relevant ones under A02 Cryptographic Failures (weak keys), A07 Identification and Authentication Failures (accepting tokens that should be rejected) and A01 Broken Access Control (trusting a role claim anyone can edit). Instead of describing them, we write a small verifier, break it on purpose, measure the break, and fix it. Everything below ran on Python 3.9.6, PHP 8.5.6 and OpenSSL 4.1.0 on an Apple Silicon Mac (M1 Pro). No JWT library was installed; that is the point. Every attack runs only against tokens and keys we made locally.

You will need the idea of a MAC from lecture 1 and random keys from lecture 3.

A JWT is three base64url strings and one HMAC

An HS256 JWT has three parts separated by dots:

  1. Header: JSON such as {"alg":"HS256","typ":"JWT"}.
  2. Payload: JSON "claims" such as sub (subject, the user), exp (expiry, Unix seconds), aud (audience, who the token is for).
  3. Signature: HMAC-SHA256 of the ASCII bytes base64url(header) + "." + base64url(payload), keyed with a shared secret.

base64url is base64 with - and _ instead of + and /, and with the = padding removed. Here is the whole thing in 15 lines:

# jwt_min.py - HS256 JWT by hand with the Python standard library only
import base64, hashlib, hmac, json

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

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

def sign_hs256(payload: dict, key: bytes) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    h = b64url(json.dumps(header, separators=(",", ":")).encode())
    p = b64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode())
    sig = hmac.new(key, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
    return f"{h}.{p}.{b64url(sig)}"

if __name__ == "__main__":
    KEY = b"demo-key-for-this-post-only-0001"     # 32 bytes, NOT a real secret
    tok = sign_hs256({"sub": "user-42", "name": "Alex", "role": "user",
                      "iat": 1794614400, "exp": 1794618000}, KEY)
    print(tok)
    for part in tok.split(".")[:2]:
        print(b64url_decode(part).decode())
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFsZXgiLCJyb2xlIjoidXNlciIsImlhdCI6MTc5NDYxNDQwMCwiZXhwIjoxNzk0NjE4MDAwfQ.tYECXfS5xKO3rsp1e3ArWetCHPiygT8spAmzNzjhBfc
{"alg":"HS256","typ":"JWT"}
{"sub":"user-42","name":"Alex","role":"user","iat":1794614400,"exp":1794618000}

Note what just happened: the second loop printed the payload without the key. A signed JWT is not encrypted. Anyone who sees the token (browser devtools, a proxy log, a crash report) can read every claim. The 187-character token also travels on every request, unlike a 43-character random session ID.

Flow diagram. The header JSON with alg HS256 and the payload JSON with the claims are each base64url-encoded without padding. They are joined with a dot into the signing input. HMAC-SHA256 with the secret key runs over that input, and the 32-byte result is base64url-encoded as the third part. The token is header dot payload dot signature.
An HS256 JWT is two base64url JSON blobs and an HMAC-SHA256 tag over exactly those bytes. Anyone can read the payload; only the key holder can produce a matching signature.

To prove the format is standard, we checked the same token in PHP and with the OpenSSL CLI. Both produced the same signature:

<?php
function b64url_decode(string $s): string { return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4)); }
[$h, $p, $s] = explode('.', $argv[1]);
$key = 'demo-key-for-this-post-only-0001';
$hdr = json_decode(b64url_decode($h), true, 8, JSON_THROW_ON_ERROR);
if (($hdr['alg'] ?? null) !== 'HS256') exit("alg not allowed\n");
$good = hash_hmac('sha256', "$h.$p", $key, true);
echo hash_equals($good, b64url_decode($s)) ? "PHP: signature OK\n" : "PHP: bad signature\n";
$ php verify.php "$TOKEN"
PHP: signature OK
$ printf %s "$(echo $TOKEN | cut -d. -f1-2)" | openssl dgst -sha256 -hmac demo-key-for-this-post-only-0001 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='
tYECXfS5xKO3rsp1e3ArWetCHPiygT8spAmzNzjhBfc

One subtle rule hides here: verify over the bytes you received, never over JSON you re-serialized. If your verifier parses the payload and dumps it again, a different key order or spacing (", " vs ",") changes the bytes and every signature fails, or worse, a lenient parser lets two different byte strings mean "the same" claims.

Common mistake: trusting alg from the token → forged admin → fix

Many early JWT libraries let the token pick its own algorithm, because the header says which one was used. RFC 7518 even defines "alg": "none" for "unsecured" JWTs. Put those together and you get this verifier, which we wrote deliberately badly:

# jwt_naive.py - a deliberately BAD verifier (local demo). Save next to jwt_min.py.
import hashlib, hmac, json
from jwt_min import b64url, b64url_decode, sign_hs256

KEY = b"demo-key-for-this-post-only-0001"

def verify_naive(token: str, key: bytes) -> dict:
    h, p, s = token.split(".")
    header = json.loads(b64url_decode(h))
    if header["alg"] == "HS256":
        good = b64url(hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest())
        if s != good:
            raise ValueError("bad signature")
    elif header["alg"].lower() == "none":
        pass                                  # "unsigned token, nothing to check"
    else:
        raise ValueError("unsupported alg")
    return json.loads(b64url_decode(p))

T = 1794614400
good = sign_hs256({"sub": "user-42", "name": "Alex", "role": "user",
                   "aud": "api.example.com", "iat": T, "exp": T + 900}, KEY)
print("naive, real token :", verify_naive(good, KEY)["role"])

h, p, s = good.split(".")
claims = json.loads(b64url_decode(p)); claims["role"] = "admin"
p2 = b64url(json.dumps(claims, separators=(",", ":")).encode())
try:
    verify_naive(f"{h}.{p2}.{s}", KEY)
except ValueError as e:
    print("naive, edited role:", e)

forged = b64url(b'{"alg":"none","typ":"JWT"}') + "." + p2 + "."   # empty signature
print("naive, alg=none   :", verify_naive(forged, KEY)["role"])
naive, real token : user
naive, edited role: bad signature
naive, alg=none   : admin

Editing the payload alone was caught: the HMAC no longer matched. But change the header to none, drop the signature, and the verifier hands back role: admin. No key was needed. The .lower() makes it worse: None, NONE and nOnE all pass too.

Fix: the server decides the algorithm, from its own configuration, before it looks at anything else. Compare the signature in constant time, then check the claims. This is the order in the figure below.

def verify(token: str, key: bytes, *, audience: str, leeway: int = 30, now=None) -> dict:
    try:
        h, p, s = token.split(".")
    except ValueError:
        raise ValueError("malformed token")
    header = json.loads(b64url_decode(h))
    if header.get("alg") != "HS256":           # allowlist, decided by the SERVER
        raise ValueError(f"alg not allowed: {header.get('alg')!r}")
    good = hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(b64url_decode(s), good):
        raise ValueError("bad signature")
    claims = json.loads(b64url_decode(p))
    now = time.time() if now is None else now
    if "exp" not in claims or now > claims["exp"] + leeway:
        raise ValueError("expired")
    if "nbf" in claims and now < claims["nbf"] - leeway:
        raise ValueError("not yet valid")
    aud = claims.get("aud")
    if audience not in (aud if isinstance(aud, list) else [aud]):
        raise ValueError(f"wrong audience: {aud!r}")
    return claims

# append to jwt_naive.py (add "import time" at the top), same tokens as above
for t, label in ((forged, "alg=none"), (f"{h}.{p2}.{s}", "edited role"), (good, "real token")):
    try: print("fixed,", label.ljust(11), ":", verify(t, KEY, audience="api.example.com", now=T + 60)["role"])
    except ValueError as e: print("fixed,", label.ljust(11), ":", e)
for when, aud in ((T + 2000, "api.example.com"), (T + 60, "admin.example.com")):
    try: verify(good, KEY, audience=aud, now=when)
    except ValueError as e: print("fixed, now=T+%-5d aud=%s: %s" % (when - T, aud, e))
fixed, alg=none    : alg not allowed: 'none'
fixed, edited role : bad signature
fixed, real token  : user
fixed, now=T+2000  aud=api.example.com: expired
fixed, now=T+60    aud=admin.example.com: wrong audience: 'api.example.com'
Decision flow. Split the token into three parts; if not three, reject. Is alg exactly HS256 as configured on the server? If not, reject. Does compare_digest of the recomputed HMAC and the signature match? If not, reject. Is now before exp plus a small leeway? If not, reject as expired. Is the expected audience in aud? If not, reject. Only then are the claims trusted.
A verifier the server controls: the algorithm comes from server config, never from the token, and every check fails closed. The naive verifier in this post skipped the first box and accepted alg none.

Why compare_digest instead of !=? A plain comparison may stop at the first differing byte, which can leak timing. Whether that leak is measurable in Python is exactly what lecture 6 tests. Use the constant-time function either way; it costs nothing.

Rule: never let the token choose how it is verified. In a real library this means passing algorithms=["HS256"] (or the equivalent) explicitly, every time.

Weak secrets: cracked in 0.05 ms, measured

HS256 is only as strong as its key. If the key is a word, anyone who has one valid token can test guesses offline, with no rate limit and no logs on your side: compute the HMAC with each candidate and compare it with the signature. We tried it against our own token with a tiny invented list of 20 words, each in 5 variants (as-is, Capitalized, +123, +2026, Capitalized!), 100 candidates in total:

# jwt_crack.py - LOCAL demo: a weak HS256 secret vs a tiny invented list
import hashlib, hmac, secrets, time
from jwt_min import sign_hs256, b64url_decode

WORDS = ["secret", "password", "changeme", "jwtsecret", "mysecret", "admin", "letmein",
         "qwerty", "dragon", "sunshine", "himitsu", "sakura", "saranghae", "hangang",
         "secreto", "contrasena", "futbol", "tequila", "dev", "test"]   # 20 invented words
def candidates():
    for w in WORDS:
        for v in (w, w.capitalize(), w + "123", w + "2026", w.capitalize() + "!"):
            yield v.encode()

token = sign_hs256({"sub": "user-42", "role": "user", "exp": 1794618000}, b"jwtsecret")
h, p, s = token.split(".")
msg, target = f"{h}.{p}".encode(), b64url_decode(s)

t0 = time.perf_counter_ns(); tried = 0
for key in candidates():
    tried += 1
    if hmac.compare_digest(hmac.new(key, msg, hashlib.sha256).digest(), target):
        print(f"found {key.decode()!r} after {tried} of 100 candidates "
              f"in {(time.perf_counter_ns() - t0) / 1e6:.3f} ms")
        break

rates = []                            # raw HMAC rate: 200,000 random keys, 5 runs
keys = [secrets.token_bytes(12) for _ in range(200_000)]
for _ in range(5):
    t0 = time.perf_counter_ns()
    for k in keys: hmac.new(k, msg, hashlib.sha256).digest()
    rates.append(len(keys) / ((time.perf_counter_ns() - t0) / 1e9))
rates.sort()
print(f"HMAC-SHA256 guesses/s (median of 5): {rates[2]:,.0f}  runs={[round(x) for x in rates]}")
found 'jwtsecret' after 16 of 100 candidates in 0.049 ms
HMAC-SHA256 guesses/s (median of 5): 544,716  runs=[540019, 542826, 544716, 552984, 555892]

The second part of the script times raw HMAC-SHA256 over 200,000 random 12-byte keys, 5 runs, median. Plain single-threaded Python managed about 545,000 guesses per second. Dedicated cracking tools on GPUs are far faster; we did not run any, so the table uses an assumed 10¹⁰/s for that column.

Secret Space Expected time at measured 544,716/s At assumed 10¹⁰/s
a dictionary word (+ variants) ~100 to millions milliseconds to seconds instant
8 random lowercase letters 26⁸ 2.2 days 10 s
12 random letters and digits 62¹² ~94 million years ~5,100 years
secrets.token_bytes(32) 2²⁵⁶ ~3.4 × 10⁶³ years ~1.8 × 10⁵⁹ years

Fix: generate the key once with a CSPRNG and keep it out of the code.

$ python3 -c "import secrets; print(secrets.token_urlsafe(32))"
$ openssl rand -base64 32

Store it in an environment variable or a secret manager, not in Git. Keep a kid (key ID) header so you can rotate keys: verify against the key named by kid, but only from your own fixed set of keys.

Rule: an HS256 secret is a key, not a password. 32 random bytes, never a word.

exp, aud, nbf and clock skew

The signature only proves the token was made by a key holder. The claims decide whether it should be accepted now, here:

  • exp (expiration): reject after it. We also reject tokens that have no exp; a token without expiry is a permanent password. Keep access tokens short (5 to 15 minutes).
  • nbf (not before) and iat (issued at): useful for detecting tokens from the future.
  • aud (audience): a token minted for api.example.com must not open admin.example.com, even if both share a key. Our output above shows the rejection.
  • iss (issuer): check it when several services can mint tokens.
  • Leeway: servers' clocks differ by a few seconds. We allow 30 seconds; larger values quietly extend every token.

Where to keep the token (described, not run)

Storage is where many real incidents happen, so here are the trade-offs. We did not run a browser for this post.

Place XSS can read it? Sent automatically (CSRF risk)? Notes
localStorage yes no one XSS bug leaks every token
Cookie with HttpOnly; Secure; SameSite=Lax no yes, limited by SameSite pair with CSRF defenses; see cookies and sessions
Memory only (JS variable) only while the page runs no lost on reload; pair with a refresh token in an HttpOnly cookie
Mobile: iOS Keychain / Android Keystore n/a no not SharedPreferences or plain files

And the question to ask first: do you need a JWT at all? JWTs cannot be revoked before exp without a server-side list, which is a session store again. For one web app talking to its own backend, a random session ID in an HttpOnly cookie (lecture 3) is simpler and revocable. JWTs earn their place when separate services must check a token without calling a central store. The PHP session security post covers the session side.

Other pitfalls worth knowing, described only: algorithm confusion (a server that accepts both RS256 and HS256 may verify an HS256 token using the RSA public key as the HMAC secret, so allowlist one algorithm per key), kid injection (a kid used as a file path or SQL value; see SQL injection with PDO), jku/x5u headers that point to attacker-hosted keys, and "decode" vs "verify" functions in libraries, where decode skips the signature.

Honest limits / version notes

  • JWT is RFC 7519 (2015); the signed form is JWS, RFC 7515; "none" is defined in RFC 7518. RFC 8725 (2020) collects best current practices, including the algorithm allowlist. We did not run any JWT library (PyJWT, firebase/php-jwt, jose), so we make no claim about their defaults in a given version. Read your library's docs for the parameter that pins the algorithm.
  • Our code supports only HS256 and compact serialization, and does not check iss, typ or crit. It is a teaching aid, not something to ship.
  • The 544,716 guesses/s figure is single-threaded CPython 3.9.6. The 10¹⁰/s column is an assumption.
  • The token length (187 characters) is for our payload; real tokens are usually longer.

Exercises

  1. Your teammate's verifier first decodes the payload, checks exp, then checks the signature. The results are the same for valid tokens. Is there any problem?
  2. Write a one-line change to sign_hs256 that adds "kid": "2026-11" to the header. Which function in your verifier must change, and how, so that kid cannot be abused?

Answers

  1. It works, but it parses attacker-controlled JSON and runs logic on it before knowing the token is genuine. Any bug in that code (a crash on a huge number, a log line with the claims) is reachable without a key. Verify the signature first, then trust and check the claims.
  2. Use header = {"alg": "HS256", "typ": "JWT", "kid": "2026-11"}. The verifier looks up the key in a fixed dictionary, for example KEYS = {"2026-11": ..., "2026-05": ...}, and rejects unknown kid values. It must never use kid as a path, URL or SQL value.

Next

FAQ

Is the payload of a JWT encrypted?

No. A signed JWT (JWS) is base64url-encoded JSON plus a signature. Anyone holding the token can decode the payload; we printed it with base64.urlsafe_b64decode and no key. The signature only proves who made it and that it was not changed. Never put passwords or personal data you would not show the user in it.

What is the alg none JWT vulnerability?

A verifier that reads the algorithm from the token's own header can be told alg is none, meaning no signature. Our naive verifier accepted a forged token with an empty signature and role changed to admin. The fix is to fix the algorithm on the server (an allowlist of exactly HS256) and reject anything else before looking at the signature.

How long should an HS256 secret be?

Use 32 random bytes from a CSPRNG, for example secrets.token_bytes(32). A dictionary word was found in 0.05 ms from a 100-candidate list in our test. At our measured 544,716 guesses per second, 32 random bytes would take about 3.4 × 10^63 years on average.

  • #security
  • #intermediate
  • #jwt
  • #python
  • #php
  • #hmac
  • #OWASP

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑