Skip to main content

Command Palette

Search for a command to run...

Building a Concurrent JWT Validator in Go - and What It Taught Me About Go's Concurrency Model

Updated
โ€ข5 min readโ€ขView as Markdown
R
Cybersecurity graduate focused into privacy engineering and IAM. I write about the projects I build - what worked, what broke, and what I'd do differently. ๐Ÿ‘พ

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