This is a verifier lab, not a token-forging tutorial. Target is a 40-line Go program that parses a JWT, refuses alg=none, refuses a swapped aud, and accepts one HS256 token minted with a lab secret. Goal: prove that decoded JSON is not identity. I paste jwt.io-style dumps so the header is visible; I do not publish a token that would verify against anyone else’s key.
1Figure 1. Claims are fiction until verify() returns nil against a pinned algorithm and key.
2Authorization: Bearer hdr.payload.sig
3 -> decode JSON (untrusted)
4 -> Verify(alg pin, key, iss, aud, exp)
5 -> claims OR VERIFY_FAIL
Lab layout
1labs/jwt_lab/
2 verifier.go # only HS256, only aud=lab-api, only iss=lab-issuer
3 tokens/ # *.txt three segments, lab secret only
4 mint_hs256.py # creates the happy-path token
Secret for this notebook: lab-secret-not-for-prod. If a dump ever contains a real AKIA, sk-, or an iss I do not own, it gets [REDACTED] and the file is deleted.
What a JWT looks like when decoded, not when trusted
Three Base64url segments. Header names alg. Payload is JSON claims. Signature binds header+payload only if you verify with the key you intended for that algorithm.
Happy-path token I minted (truncated in the notebook; full value lives in tokens/ok.jwt on the lab VM):
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
2.
3eyJpc3MiOiJsYWItaXNzdWVyIiwic3ViIjoibGFiLXVzZXIiLCJhdWQiOiJsYWItYXBpIiwiZXhwIjoxNTY0Mjk3MjAwfQ
4.
5[REDACTED_HMAC]
jwt.io-style decode (I do this with python3 -c, not by pasting production tokens into a browser):
1import base64, json, sys
2def b64url(s):
3 s += "=" * ((4 - len(s) % 4) % 4)
4 return base64.urlsafe_b64decode(s.encode())
5
6hdr, body, sig = sys.argv[1].split(".")
7print("header ", json.dumps(json.loads(b64url(hdr)), indent=2))
8print("payload", json.dumps(json.loads(b64url(body)), indent=2))
9print("sig_len", len(sig))
1header {
2 "alg": "HS256",
3 "typ": "JWT"
4}
5payload {
6 "iss": "lab-issuer",
7 "sub": "lab-user",
8 "aud": "lab-api",
9 "exp": 1564297200
10}
11sig_len 43
That JSON is what an attacker types. Treat it as a comment until Verify succeeds.
Verifier that pins alg, iss, aud
1// verifier.go — lab only, HS256, no RSA, no JWKS fetch
2package main
3
4import (
5 "fmt"
6 "os"
7 "time"
8
9 "github.com/golang-jwt/jwt/v5"
10)
11
12var labKey = []byte("lab-secret-not-for-prod")
13
14func main() {
15 tok, err := os.ReadFile(os.Args[1])
16 if err != nil {
17 fmt.Println("READ_FAIL", err)
18 os.Exit(1)
19 }
20 parsed, err := jwt.Parse(string(tok), func(t *jwt.Token) (interface{}, error) {
21 if t.Method != jwt.SigningMethodHS256 {
22 return nil, fmt.Errorf("rejected alg %v", t.Header["alg"])
23 }
24 return labKey, nil
25 }, jwt.WithValidMethods([]string{"HS256"}),
26 jwt.WithIssuer("lab-issuer"),
27 jwt.WithAudience("lab-api"),
28 jwt.WithLeeway(2*time.Second),
29 )
30 if err != nil {
31 fmt.Println("VERIFY_FAIL", err)
32 os.Exit(2)
33 }
34 fmt.Println("VERIFY_OK sub=", parsed.Claims.(jwt.MapClaims)["sub"])
35}
WithValidMethods([]string{"HS256"}) is the line that makes alg in the attacker-controlled header irrelevant. If a library lets the header pick the algorithm, you do not have a verifier, you have a decoder.
Artifact: alg=none is rejected
I built an unsigned token with the same payload claims (header {"alg":"none","typ":"JWT"}, empty signature). Decode:
1header {
2 "alg": "none",
3 "typ": "JWT"
4}
5payload {
6 "iss": "lab-issuer",
7 "sub": "admin", // attacker-typed, meaningless
8 "aud": "lab-api",
9 "exp": 1564297200
10}
11sig_len 0
Run:
1$ go run verifier.go tokens/none.jwt
2VERIFY_FAIL rejected alg none
3$ echo $?
42
The library never reached claim checks. sub=admin in the JSON did not become a session. That is the whole alg=none story I want in a review: the verifier must fail closed before claims.
A second negative: HS256 token with aud=other-api.
1$ go run verifier.go tokens/wrong-aud.jwt
2VERIFY_FAIL token has invalid audience
Decode still shows pretty JSON. Verify is what matters.
Failure modes I actually tick in review
- Header-selected alg. Search for
jwt.Parse(withoutWithValidMethods/ equivalentalgorithms=["HS256"]in PyJWT. If the key is an RSA public key and the lib will HMAC with it underHS256, that is algorithm confusion. I do not demonstrate the HMAC; I file “pin the algorithm”. alg=noneaccepted. Old libs. The lab token above is the regression test.- Verify skipped.
jwt.decode(token, options={"verify_signature": False})in Python, or base64-splitting in JS middleware. Decode-only is a bug, not a helper. - Wrong
aud/ missingaud. Microservices that accept any token from the IdP, including ones minted for a different API. expignored, ornbfin the future accepted with huge leeway.- Weak HS256 secret. I do not run a cracker. I
wc -cthe secret file.lab-secret-not-for-prodis 22 bytes;secretis a finding.
PyJWT equivalent of the Go pin (this is the shape I want in Python services):
1import jwt # PyJWT
2claims = jwt.decode(
3 token,
4 key="lab-secret-not-for-prod",
5 algorithms=["HS256"], # list, not None
6 audience="lab-api",
7 issuer="lab-issuer",
8 options={"require": ["exp", "iss", "aud", "sub"]},
9)
Passing algorithms=["none"] or omitting algorithms on old PyJWT is a review blocker. I keep a unit test that feeds tokens/none.jwt and asserts InvalidAlgorithmError.
Sanitized reproduction (reject / crash only)
The none-token path is the repro. I also keep a crash from feeding garbage, because on-call pastes whole Authorization headers into internal tools.
1$ go run verifier.go tokens/garbage.jwt
2VERIFY_FAIL token is malformed: token contains an invalid number of segments
3
4$ python3 - <<'PY'
5import jwt
6try:
7 jwt.decode("not-a-jwt", key="x", algorithms=["HS256"])
8except Exception as e:
9 print(type(e).__name__, e)
10PY
11# DecodeError Not enough segments
ASAN is not involved. What I treat as the loud failure is process exit 2 plus a log line that does not include the token.
App log from the lab HTTP wrapper (token redacted):
12019-07-22T11:18:04+08:00 lab-api GET /v1/me
2 authz: VERIFY_FAIL rejected alg none
3 authz_hdr: Bearer eyJhbGciOi[REDACTED]
4 src: 127.0.0.1 req_id: [REDACTED]
5 # 401, no Set-Cookie, no user id
Failed-auth logs are the right artifact. A 200 after alg=none is the incident.
Review grep list (this repo, then the service)
I run the same greps on every JWT service. Hits are tickets until the unit test exists.
1$ rg -n "verify_signature.:.False|WithoutVerification|ParseUnverified|jwt\.decode\([^,]+\)"
2$ rg -n "algorithms\s*=\s*None|WithValidMethods"
3$ rg -n "jku|x5u|x5c" --glob "*.go" --glob "*.py"
ParseUnverified in golang-jwt is a decoder. If it sits in a middleware that then reads sub, that is the skip-verify class. jku / x5u in a header we honor is “fetch a key from attacker URL”. The lab verifier has no JWKS client on purpose.
A table I paste into the PR when someone asks “but jwt.io says it’s valid”:
| Token | jwt.io decode | lab verifier |
|---|---|---|
| HS256, lab secret, aud=lab-api | pretty JSON | VERIFY_OK |
| alg=none, sub=admin | pretty JSON | VERIFY_FAIL rejected alg none |
| HS256, wrong aud | pretty JSON | invalid audience |
| garbage | error | malformed |
jwt.io is a decoder with an optional verify box. The box is not our API.
Mitigation
- Pin algorithms in code, not in the token. HS256 or RS256, never both on the same key material.
- Require
iss,aud,exp,sub. Reject missingaud. - Never log full tokens. Prefix + length, or HMAC of the token with a local log key.
- Rotate the HS256 secret as a secret, not as a “config string” in git.
grep -R lab-secretin CI should fail (this notebook uses a dummy on purpose). - For RS256: pin the JWKS URL you own, pin
kid, refuse tokens withkidpointing at a URL (jku/x5uunset). - Unit test:
none, empty sig, wrongaud, expiredexp, garbage. All must 401.
What I file after this lab
- Verifier: Go
jwt/v5+ PyJWT, methods["HS256"],iss=lab-issuer,aud=lab-api - Happy path:
VERIFY_OK sub=lab-user alg=none:VERIFY_FAIL rejected alg none, HTTP 401, token truncated in logs- Wrong
aud:invalid audience - Bug class if a service decodes first and reads
subbefore verify - Fix:
WithValidMethods/algorithms=["HS256"], required claims, noverify_signature: False
Commands appendix
1python3 decode_jwt.py "$(cat tokens/ok.jwt)"
2go run verifier.go tokens/ok.jwt
3go run verifier.go tokens/none.jwt
4go run verifier.go tokens/wrong-aud.jwt
5# never: jwt.io with a production Authorization header