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:
- Header: JSON such as
{"alg":"HS256","typ":"JWT"}. - Payload: JSON "claims" such as
sub(subject, the user),exp(expiry, Unix seconds),aud(audience, who the token is for). - 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.
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'
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 noexp; a token without expiry is a permanent password. Keep access tokens short (5 to 15 minutes).nbf(not before) andiat(issued at): useful for detecting tokens from the future.aud(audience): a token minted forapi.example.commust not openadmin.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,typorcrit. 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
- 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? - Write a one-line change to
sign_hs256that adds"kid": "2026-11"to the header. Which function in your verifier must change, and how, so thatkidcannot be abused?
Answers
- 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.
- Use
header = {"alg": "HS256", "typ": "JWT", "kid": "2026-11"}. The verifier looks up the key in a fixed dictionary, for exampleKEYS = {"2026-11": ..., "2026-05": ...}, and rejects unknownkidvalues. It must never usekidas a path, URL or SQL value.
Next
- Previous: Security Basics 3/6: secure random tokens with secrets
- Next: Security Basics 5/6: AES-GCM symmetric encryption done right, for when the payload must actually be secret
- Back to the programming learning roadmap, and over HTTPS only: HTTPS, TLS and certificates
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.