# 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*](https://quietbytes.hashnode.dev/series/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 synthetic fixtures first: 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 — a validator that only ever sees happy-path tokens hasn't proven anything.

Then the real test: I went back into `jwt-attack-lab`, added a single `print()` line to `attack_none.py` and `attack_kid.py` to capture the actual forged tokens those scripts generate — the same ones that successfully broke my Flask API's `/profile` endpoint months ago — and ran them straight through this validator.

Both were rejected, on the first try, for exactly the reasons I built each defense:

```plaintext
Invalid: token is unverifiable: error while executing keyfunc: algorithm not allowed: none
Invalid: token is unverifiable: error while executing keyfunc: kid not allowed: ../requirements
```

That second one is worth pausing on. The error message echoes back `../requirements` — the literal path-traversal payload from Attack Lab Part 5 — because the `kid` allowlist check reads the raw, attacker-controlled header value before making any decision. It doesn't just reject the token; it can tell you *what* it rejected and *why*, which is exactly the kind of clear failure a security tool should produce instead of a generic "invalid token."

This is the moment the project stopped feeling like an exercise and started feeling like a real defense: not "this should work in theory," but "here is the exact forged token that broke my own API in June, failing against code I wrote in September."

## 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:* [*GitHub*](https://github.com/Snitch-1302/jwt-validator)
