xenonnn4wxenonnn4w
security notebook · differential testing

Chirpy Security Lab: when a valid JWT is not valid enough

15th September 2026

A JWT can have a correct signature and still be wrong for the application accepting it. That was the question behind this lab: what exactly does Chirpy's validator promise, and which assumptions existed only in my head?

I pinned the original validator, generated synthetic tokens around its policy boundaries, and ran every case against both the baseline and a hardened implementation. The result is a reproducible before/after matrix—not a live exploit demo. The complete lab, evidence, and tests are on GitHub.

18
synthetic token cases
7
baseline acceptance differences
5
policy categories tightened
0
expectation mismatches

The trust boundary

The token's header, payload, URL, and Authorization formatting are all untrusted. Signature verification must happen before a subject becomes an identity. Then a second decision still remains: does that identity own the requested resource? Treating those as separate gates kept the audit honest.

HTTP headerattacker-controlledcredential parserone Bearer tokenJWT policysignature + claimsowner checkcaller vs resourcemutationstate changesidentity is only an input to authorization
Every arrow is a trust decision; a verified signature is only the middle one.

Baseline vs application policy

The harness builds credentials with a public synthetic key. Each case carries two expected outcomes and the CLI fails if either validator drifts from them. Seven inputs accepted by the baseline are rejected by the stricter policy; forged, unsigned, expired, malformed, and wrong-key tokens remain rejected by both.

missing expacceptrejecttokens must end
wrong / missing issacceptrejectone trusted issuer
HS384 or HS512acceptrejectHS256 allowlist
future iatacceptrejectreject impossible issuance
nil UUID subjectacceptrejectno anonymous sentinel
wrong key / tamperedrejectrejectsignature preserved

This matters for interpretation. Accepting HS384 with a known test key is a policy mismatch because Chirpy emits HS256; it is not evidence that HS384 is weak or that an attacker can forge a signature. Likewise, a token without exp requires a trusted issuer to have signed it in the first place.

Five policy gaps

01

Expiration

Require exp instead of validating it only when present.

02

Issuer

Accept tokens only from the chirpy issuer.

03

Algorithm

Pin validation to the HS256 method the service emits.

04

Issued-at

Reject an iat in the future when the claim is present.

05

Subject

Parse the UUID and reject the all-zero identity.

Some choices are deliberately left alone. iat stays optional, matching the library's semantics, but is checked when present. Audience validation is deferred because Chirpy does not emit aud; adding it safely would require changing issuer and consumer together.

The hardened validator

The hardened implementation turns those assumptions into parser options. Construction rejects keys shorter than 32 bytes and copies the key material. Validation allowlists HS256, requires exp and issuer chirpy, checks iat, and rejects a missing, malformed, or zero UUID subject. Callers receive one generic invalid-token error.

token, err := jwt.ParseWithClaims(tokenString, claims, keyFn,
    jwt.WithValidMethods([]string{"HS256"}),
    jwt.WithIssuer("chirpy"),
    jwt.WithExpirationRequired(),
    jwt.WithIssuedAt(),
)

id, err := uuid.Parse(claims.Subject)
if err != nil || id == uuid.Nil {
    return uuid.Nil, ErrInvalidToken
}

Authorization comes after authentication

A small in-memory HTTP fixture models Chirpy's owner-only delete route. Bob receives 403 when deleting Alice's chirp; Alice can delete it afterward, proving Bob's request preserved state. A repeat returns 404, proving the successful request did mutate state. Invalid tokens and malformed headers get the same preservation check.

Bob DELETE Alice's chirp   → 403  (state preserved)
Alice DELETE her chirp    → 204  (one mutation)
Alice DELETE it again     → 404  (resource is gone)

16 concurrent owner calls → exactly one 204; the rest 404

The owner comparison and deletion happen under one lock, and the suite runs under Go's race detector. Authentication also runs before parsing the resource ID, so anonymous callers cannot use parsing behavior as a path around the credential boundary.

Making the evidence reproducible

The repository is designed to be run, not merely read. The audit CLI prints the committed Markdown matrix and exits non-zero on drift. CI checks formatting, vet, race-enabled coverage, the audit, a fuzz target for malformed tokens, and reachable dependency vulnerabilities. No database, credentials, Docker daemon, or live Chirpy instance is required.

go run ./cmd/audit   # print and verify the 18-case matrix
go test -race ./...  # token policy + HTTP authorization
make fuzz            # fuzz malformed token input
make check           # vet, coverage, race tests, and audit

What this does not prove

This is a scoped lab, not a full assessment of the deployed service. It does not cover TLS, production key management, refresh-token rotation, rate limiting, account recovery, browser storage, PostgreSQL isolation, or cloud configuration. The HTTP fixture is new lab code; it does not claim an ownership bypass in Chirpy, whose delete handler already checks the owner and uses an owner-qualified SQL statement.

That restraint is part of the result. The useful claim is precise: under a known synthetic key, the hardened validator rejects seven unsupported token shapes the baseline accepts, while preserving the baseline's rejection of forged and malformed credentials. Everything needed to reproduce that claim lives in the repository.

Tags

#security#jwt#go#fuzzing#authorization#threat-modeling#testing