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

  1. Header-selected alg. Search for jwt.Parse( without WithValidMethods / equivalent algorithms=["HS256"] in PyJWT. If the key is an RSA public key and the lib will HMAC with it under HS256, that is algorithm confusion. I do not demonstrate the HMAC; I file “pin the algorithm”.
  2. alg=none accepted. Old libs. The lab token above is the regression test.
  3. 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.
  4. Wrong aud / missing aud. Microservices that accept any token from the IdP, including ones minted for a different API.
  5. exp ignored, or nbf in the future accepted with huge leeway.
  6. Weak HS256 secret. I do not run a cracker. I wc -c the secret file. lab-secret-not-for-prod is 22 bytes; secret is 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”:

Tokenjwt.io decodelab verifier
HS256, lab secret, aud=lab-apipretty JSONVERIFY_OK
alg=none, sub=adminpretty JSONVERIFY_FAIL rejected alg none
HS256, wrong audpretty JSONinvalid audience
garbageerrormalformed

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 missing aud.
  • 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-secret in CI should fail (this notebook uses a dummy on purpose).
  • For RS256: pin the JWKS URL you own, pin kid, refuse tokens with kid pointing at a URL (jku / x5u unset).
  • Unit test: none, empty sig, wrong aud, expired exp, 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 sub before verify
  • Fix: WithValidMethods / algorithms=["HS256"], required claims, no verify_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