Building a Concurrent JWT Validator in Go - and What It Taught Me About Go's Concurrency Model
Draft note: being written stage by stage as the project progresses. Sections below will grow; this note comes out before publishing.
A companion build to JWT Attack Lab โ eight parts spent breaking a Flask API's JWT verification six different ways. This one flips the direction: building a validator meant to reject every one of those forged tokens, in a language I hadn't touched in years.
Why this exists
Attack Lab answered "how does JWT verification break?" This project answers a different question: can I build a verifier that actually holds up against the same payloads I forged to break the last one? Not a rewrite of the Flask app โ a standalone Go CLI tool that reads a folder of tokens and validates them, with every check traceable back to a specific vulnerability from the earlier series.
There's a second reason, just as real: I picked Go specifically to learn goroutines, channels, and sync.WaitGroup โ the concurrency primitives that show up constantly in cybersecurity tooling (think: scanning hundreds of hosts, validating a batch of certificates, checking a directory of tokens issued to different services). I hadn't written Go in a long time, and it showed immediately.
Getting Go syntax back before touching JWTs
Before any JWT logic, I had to relearn Go basics I'd genuinely forgotten โ and a few of Go's opinions about "safe" code caught me off guard:
Go refuses to compile with unused imports or unused variables. Not a linter warning โ a hard compile error. The first time I imported time for a throwaway test and forgot to use it, go run just refused to build. Coming from languages that let this slide, it took a minute to realize this was intentional strictness, not a bug in my setup.
Error handling isn't optional syntax sugar in Go โ it's the primary control flow. Almost every standard library function that can fail returns (result, error) as a pair, and you're expected to check err != nil immediately, every time. I discarded an error early on with _ just to get something running โ and that exact habit later caused a genuinely confusing bug: an unreadable file silently became an empty string, which then failed token parsing with an unrelated-looking error, instead of failing clearly at the file-read step where the real problem was. Chasing that down was a good early lesson in why Go makes this so explicit.
time.Format doesn't use format codes. No %Y-%m-%d. Go wants you to write out a specific reference date (Mon Jan 2 15:04:05 MST 2006) rearranged into your desired layout. Deeply strange the first time, weirdly memorable after.
The core validation logic
Once the syntax was back, the real work started: using golang-jwt/jwt/v5 to parse and validate a single token. The library's design forces a decision that maps directly onto one of the attack lab's central lessons.
go
keyFunc := func(token *jwt.Token) (interface{}, error) {
alg := token.Method.Alg()
// check alg against a whitelist here, before returning a key
...
return []byte(secret), nil
}
token, err := jwt.Parse(tokenString, keyFunc)
jwt.Parse calls this Keyfunc during parsing โ after the token's header (including its claimed algorithm) is known, but before any key is committed to. This is exactly the structural fix Attack Lab Part 3 landed on: algorithm and key selection can't be decided independently. If they're separate steps, an attacker can supply an unexpected algorithm and the verifier might still hand back a key that works with it โ that's the whole mechanism behind both the alg: none bypass and RS256โHS256 algorithm confusion. Tying them into one function, gated by an explicit whitelist, closes that gap by construction rather than by patching a symptom.
The same function is where the kid header gets checked against a hardcoded allowlist โ the direct defense against Attack Lab Part 5's path-traversal-via-kid attack, which built a file path straight out of an attacker-controlled header value.
Mistakes worth admitting
I generated my first test secret as
"mysecret"on jwt.io and hit an RFC 7518 minimum-key-length error โ the same weak-secret problem Attack Lab Part 4 exploited with hashcat, encountered by accident while making throwaway test fixtures.I spent a confusing few minutes debugging a "signature invalid" error that turned out to be a mismatch between the secret I typed into my Go code and the secret I'd actually used on jwt.io for that specific file โ a reminder that in JWT validation, "invalid signature" often just means "wrong key," not "broken code."
ReadString('\n')keeps the newline character in what it returns โ invisible until you concatenate something after it. Small, but a good early example of why you actually read what a function's documentation promises to return.
What's tested so far
Three deliberately different fixtures: a valid token, one identical except for an expired exp, and one signed with a different secret. Each fails (or passes) for a distinct reason, which is the point โ a validator that only ever sees happy-path tokens hasn't proven anything.
Next: pulling actual forged tokens out of the attack lab's own attack_none.py and attack_kid.py scripts and running them through this exact validator, to see the real payloads rejected instead of just my own synthetic test cases.
What's next
Directory-wide validation (looping over every file instead of one hardcoded path), then the actual point of choosing Go: goroutines, a WaitGroup, and a channel to validate every token in a directory concurrently instead of one at a time โ and a look at why that matters once you're validating hundreds of tokens instead of three.
Code for each stage: https://github.com/Snitch-1302/jwt-validator
