Writing a Lightweight Multi-issuer JWT Validation Library in Go
Overview
I learned Go way back in 2019 while it was still maturing. I published a few repos, all created around the time Covid hit, because I had nothing better to do than learn the hot new programming language. I have always loved its simplicity, but as time went on I stopped having time to code for enjoyment and Go evolved and matured.
- A client for NASA's Small Body Database Close-Approach API
- A library that integrated with an early Google AI tool to generate a color palette
- A wrapper for Digital Ocean's Go client
My day job for the last 8 years has had me write Java, and I believe there comes a point in every Java engineer's life
where they get tired of the verbosity. Don't get me wrong I
think Java and Spring Boot are excellent for writing enterprise software. If done well the verbosity makes it hard to
misinterpret the code's intent, but sheesh, if I have to read a method named something like
findCustomerWithPreferencesAndActiveLogoAndMostRecentOrder my brain may explode.
Recently I built a multi-issuer JWT setup using Java and Spring Security, where one issuer was signing tokens with a shared HMAC secret and another was an IdP issuing asymmetrically signed tokens validated against a
JWKS endpoint. Spring Security's out-of-the-box resource-server configuration assumes a single issuer and is oriented
around JWKS validation, so supporting a symmetric issuer alongside an asymmetric one required custom JwtDecoder wiring
and issuer-based routing rather than the default autoconfiguration. I learned a lot about how things work. However, like
the rest of Spring, a lot of the internals were abstracted away, and I relied on the magic just working because other
engineers who were smarter than me built this to just work. At a high level I did learn how routing based on iss worked,
the various validations needed to ensure the JWT was valid, and how we translate a valid JWT to a Principal that would
be useful in a microservice environment. What I was missing, though, was a deeper understanding of the mechanics
underneath.
For RS256 issuers, how exactly does JWKS caching and rotation work? When a key rotates, how does the verifier know to fetch a new one without dropping requests? And for HS256, what does the validation path actually look like when there's no public key to fetch, just a shared secret both sides trust? Spring Security answered all of these questions for me, silently, in code I never read. I wanted to actually know. So I rebuilt the whole thing from scratch in Go.
The result is polytoken, a configurable multi-issuer JWT validation service in Go. It handles both HS256 and RS256 issuers, JWKS caching and rotation, and maps verified tokens to a normalized Principal so downstream services don't have to care which issuer produced them. The code is on GitHub if you want to follow along.
Architecture
Before writing any Go, I sketched the architecture I wanted to replicate. A token arrives, something needs to decide which issuer it came from, that issuer's validator needs to verify it correctly, and the result needs to be a normalized identity object my HTTP layer can use regardless of which issuer produced it. That gave me five concrete things to build.
Config
Who doesn't love an easy-to-use YAML config? The magic of providing a few lines of config and having something just work has become the backbone of modern software. I wanted to ensure the config for polytoken was simple enough that another engineer could just provide some properties, and they were good to go, no pun intended.
Issuers are declared in a YAML file. Each issuer has a name, the iss value it mints, a type, and type-specific
settings.
issuers:
- name: customer
issuer: https://customer.example.com
type: hs256
hs256:
secret: change-me
- name: internal
issuer: https://internal.okta.com/oauth2/default
type: rs256
rs256:
jwksUrl: https://internal.okta.com/oauth2/default/v1/keys
Adding an issuer is a config change with no code change. The configuration is validated at startup rather than at request time. If an HS256 issuer is missing its secret, or an RS256 issuer has no JWKS URL, the service refuses to start. Better to fail loudly at boot than silently at 2am when a token fails to validate and nobody knows why.
Factory
In Java, a factory is a class. You write a ValidatorFactory, give it a create() method, maybe annotate it with
@Component, inject it wherever you need it. It feels natural because in Java everything lives on an object. Behavior
without a class to hold it does not really exist.
Go disagrees.
When I went to build the factory layer for polytoken, my instinct was to reach for a struct with a method. That is the Java translation. But the more I thought about it, the more I realized the factory did not need to hold any state. It just needed to map an issuer type to a constructor. In Go, functions are first-class values. You can store them in maps, pass them as arguments, call them later. The factory is not a class. It is a map.
type validatorFactory func(cfg config.IssuerConfig) (TokenValidator, error)
var registry = map[config.IssuerType]validatorFactory{
config.TypeHS256: buildHS256,
config.TypeRS256: buildRS256,
}
validatorFactory is a named function type: a thing that takes an IssuerConfig and returns a validator or an error.
The map says "for this issuer type, here is the constructor to call." Adding a new signing scheme means writing one
constructor function and adding one line to the map. No existing code changes.
The lookup is a standard map read with a comma-ok check:
func BuildValidator(cfg config.IssuerConfig) (TokenValidator, error) {
factory, ok := registry[cfg.Type]
if !ok {
return nil, fmt.Errorf("validator: unknown issuer type %q", cfg.Type)
}
return factory(cfg)
}
factory is a function pulled out of a map. factory(cfg) calls it. That took me a minute to internalize coming from
Java.
You are not calling a method on an object, you are calling a value that happens to be a function.
The Go heuristic I took away from this: reach for a function first, reach for a struct only when there is state to hold.
A struct with one method and no fields is a smell that you probably just wanted a function. Coming from Java where
everything is a class by default, that inversion took some getting used to.
Resolver
One of the more important design decisions in polytoken is one that is easy to get wrong: routing and verification are separate concerns, and only one of them is a security decision.
When a token arrives, something needs to decide which validator should handle it before any cryptographic verification
can happen. The natural thing to reach for is the iss claim, which identifies the issuer. But here is the catch: at
routing time, the token is unverified. Anyone can put anything in the iss claim.
This is fine, and here is why. Routing by unverified iss only determines which validator runs. The chosen validator
then
performs full cryptographic verification. An attacker who fakes the iss claim to steer routing to a different validator
still cannot forge a valid signature for that validator's key. Routing picks the lane. Verification proves the token.
The security boundary is in verification, not routing.
The resolver itself is lightweight. It holds a slice of validators and loops until it finds one that claims the token:
type Resolver struct {
validators []validator.TokenValidator
}
func (r *Resolver) Validate(ctx context.Context, token string) (*principal.Principal, error) {
for _, v := range r.validators {
if v.CanHandle(token) {
return v.Validate(ctx, token)
}
}
return nil, ErrNoValidator
}
CanHandle reads the unverified iss via ParseUnverified and compares it to the validator's expected issuer. No
signature check, no network call, just claim reading. The assumption is that each configured issuer has a unique iss
value, so at most one validator claims any given token. If nothing matches, ErrNoValidator is returned as a sentinel
so callers can distinguish "no issuer matched" from "verification failed."
Validator
Both validators implement the same interface: CanHandle for routing and Validate for verification. What they do
inside
those methods is completely different, and that difference is the whole point. HS256 is a symmetric operation with a
shared secret. RS256 is an asymmetric operation with a public key fetched from the network. The interface hides that
complexity from everything above it.
package validator
import "context"
import "polytoken/internal/principal"
type TokenValidator interface {
Validate(ctx context.Context, token string) (*principal.Principal, error)
CanHandle(token string) bool
}
HS256
The way I like to remember the difference between HS256 and RS256 is HS stands for hidden secret. (It actually stands for HMAC-SHA, but hidden secret is how I remember it.) A shared secret is used to ensure the signature of the JWT is valid. This is a simpler approach than RS256 but tried and true.
The validator holds two things: the expected issuer and the secret as a byte slice.
type Hs256Validator struct {
issuer string
secret []byte
}
Verification is straightforward. Parse the token, provide the secret as the signing key, and let the library do the HMAC operation. The interesting part is what you pass alongside it:
parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
return v.secret, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(v.issuer),
jwt.WithExpirationRequired(),
)
WithValidMethods is the algorithm pinning option. It tells the parser to reject any token whose alg header is not
HS256. This prevents the algorithm-confusion attack where an attacker changes the alg header to none or switches to
RS256 and provides the public key as the HMAC secret. The token looks valid but was never signed correctly. Pinning the
algorithm closes that door entirely.
WithIssuer validates the iss claim matches the configured issuer. WithExpirationRequired rejects tokens with no
exp claim. Both are strict by design. A token that does not say who issued it or when it expires is not a token worth
trusting.
On a successful parse, both validators call the same helper to map claims to a Principal:
return principalFromClaims(claims)
principalFromClaims extracts sub, iss, exp, iat, scopes, roles, and anything leftover into a catch-all claims
map. The result is the same Principal type regardless of which validator ran. Downstream code never knows whether the
token was HS256 or RS256. That normalization is the whole point of the interface.
RS256
If HS stands for hidden secret, RS stands for really (please) stop sharing your secret. (The real derivation is RSA-SHA, but this sticks better.) Rather than a shared secret that both parties hold, RS256 uses an asymmetric keypair. The issuer signs tokens with a private key that never leaves their system. Verifiers use the corresponding public key, which the issuer publishes openly. You can verify without being able to forge. That is the whole point of asymmetric cryptography.
The validator holds the expected issuer and a JWKS cache instead of a secret:
type Rs256Validator struct {
issuer string
cache *jwks.Cache
}
Verification follows the same structure as HS256 but the keyfunc is more involved. Instead of returning a static secret,
it reads the kid from the token header and looks up the corresponding public key from the cache:
parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("rs256: unable to convert kid to string")
}
return v.cache.Key(ctx, kid)
}, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
jwt.WithIssuer(v.issuer),
jwt.WithExpirationRequired(),
)
The keyfunc pattern is worth pausing on. You are not passing a key, you are passing a function that returns a key given the parsed token header. That function closes over the cache, the same first-class-function pattern from the factory section. Go keeps reaching for this idea: behavior is a value you can pass around, not something that has to live on a class.
kid is the key identifier in the token header. The issuer stamps it onto every token so verifiers know which public
key to use. The cache looks it up, fetches from the JWKS endpoint if needed, and returns the RSA public key. Algorithm
pinning works the same as HS256: WithValidMethods rejects anything that is not RS256, closing the door on
algorithm-confusion attacks.
On success, principalFromClaims produces the same normalized Principal as the HS256 path. The HTTP layer above never
knows which validator ran.
The cache is where the real concurrency work lives, and it deserves its own section.
JWKS Caching and Rotation
This is the section Spring Security was hiding from me. I knew RS256 tokens were verified against a public key fetched from a URL. I did not know what happened under the hood when that key changed.
Issuers rotate their signing keys periodically. When they do, they publish both the old and new keys in the JWKS document for an overlap window, so tokens signed with either key still verify. Eventually the old key is removed. If your verifier only fetched the JWKS once at startup, it would start rejecting valid tokens the moment the old key disappeared.
The solution is a cache that knows how to refresh itself.
The JWKS document is a JSON object with a keys array. Each key has a kid (key ID), a type, and the key material. For
RSA keys that means a base64url-encoded modulus (n) and exponent (e):
{
"keys": [
{
"kty": "RSA",
"kid": "test-key-1",
"n": "v-peg6bD...",
"e": "AQAB"
}
]
}
The cache stores keys in memory, keyed by kid. When the RS256 validator needs a key, it asks the cache. Cache hit
returns immediately. Cache miss means the issuer may have rotated to a new key, so the cache fetches the JWKS endpoint,
parses the response, and swaps in a fresh key map. Key rotation is handled transparently without any configuration
change.
func (c *Cache) Key(ctx context.Context, kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
key, ok := c.keys[kid]
c.mu.RUnlock()
if ok {
return key, nil
}
// kid not found, issuer may have rotated
if err := c.Refresh(ctx); err != nil {
return nil, err
}
c.mu.RLock()
key, ok = c.keys[kid]
c.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("jwks: key %q not found after refresh", kid)
}
return key, nil
}
A subtlety worth flagging: if several goroutines miss the cache at the same time, they will all call Refresh at once.
For a handful of concurrent requests that is harmless, but under load with many unknown kid values, it becomes a
thundering herd hammering the JWKS endpoint. I come back to throttling this in the wrap-up.
Many requests verify tokens simultaneously, so the cache needs to be safe for concurrent use. Reads take a shared read lock so multiple goroutines can look up keys in parallel without blocking each other. Refreshes build a fresh key map and swap it in under a write lock. The network fetch happens outside the lock entirely. Holding a lock during an HTTP call would serialize every concurrent request behind a single network round trip.
That last detail is where I hit my first real Go concurrency bug.
War Stories
No project is complete without a few moments where you stare at your screen wondering what you did wrong. Here are the ones that cost me the most time.
The Deadlock
The JWKS cache uses a read/write mutex for concurrent safety. Reads take a shared lock, writes take an exclusive lock.
Straightforward enough. What I did not think through was what happens when Key calls Refresh while still holding the
read lock.
Key acquired the read lock to check if the kid existed. When it did not find the key, it called Refresh to fetch new
keys from the JWKS endpoint. Refresh tried to acquire a write lock to swap in the new key map. But the read lock was
still held. Write locks cannot be acquired while any read lock is active. Every request hung indefinitely. Classic
deadlock.
The fix was embarrassingly simple once I saw it: release the read lock before calling Refresh, then re-acquire it
after.
c.mu.RLock()
key, ok := c.keys[kid]
c.mu.RUnlock() // release before refresh
if !ok {
c.Refresh(ctx) // no lock held during network call
c.mu.RLock() // re-acquire to read result
key, ok = c.keys[kid]
c.mu.RUnlock()
}
The lesson: never hold a lock during a network call and never call a function that acquires a lock while you are already holding one. Go does not protect you from this. Java's higher-level concurrency abstractions tend to hide these details. Go hands you the mutex and trusts you to get it right. I did not, the first time.
The Docker Localhost Trap
When I containerized polytoken with Docker Compose, I had two services: the polytoken server and an nginx container
serving the JWKS document. In my config I had the RS256 issuer pointing at http://localhost:9000/jwks.json. It worked
perfectly when running locally. Inside Docker, it returned nothing.
The problem is that localhost inside a container refers to the container itself, not your host machine, and not any
other container. The polytoken container was looking for a JWKS server on its own loopback interface, where nothing was
running.
Docker Compose gives each service its own DNS name on the shared network. The fix was changing the JWKS URL in config to use the service name instead:
jwksUrl: http://jwks:80/jwks.json
jwks resolves to the nginx container within the Compose network. One config change, everything worked. If you are
running services in Docker Compose and something cannot reach something else, check whether you are using localhost
somewhere you should be using a service name. You almost certainly are.
The Key Mismatch
This one cost me embarrassingly long to debug. I had the polytoken server running, the JWKS endpoint serving a key, and
a freshly minted RS256 token. Every request came back 401. The logs said signature verification failed. I had checked
the config three times. The JWKS was serving. The token had the right iss. Nothing made sense.
The issue: every time you run the mint CLI it generates a fresh RSA keypair. The token is signed with that run's private key. The JWKS document contains that run's public key. If you mint a token, then mint again to get a new JWKS document; the token and the JWKS are now from different key pairs. The signature will never be verified. They have to come from the same mint invocation.
I had minted the token, saved the JWKS, minted again to double-check the output, saved that JWKS instead, and wondered why nothing worked. The fix is straightforward: mint once, save both outputs immediately, never mix runs. It is obvious in hindsight and invisible when you are debugging at 11pm.
What Go Taught Me
Go is a really awesome lightweight programming language. It has different concepts than Java but does a good job of making complex things simple. After eight years of Java, I expected the learning curve to be steeper. What I did not expect was to feel like I had been overcomplicating things.
The factory. In Java a factory is a class. You write the interface, the implementations, the constructor, the annotations. In Go, it is a map from a type to a function. That is it. The first time I wrote this, I kept waiting for the part where I needed a class to hold it. That part never came. Go just lets functions be values, and once that clicks, a whole category of boilerplate disappears.
Graceful shutdown. In Java shutting down a server gracefully means ExecutorService, awaitTermination, shutdown
hooks, thread management. In Go, it is a goroutine running the server, a channel waiting for a signal, and
srv.Shutdown. Three concepts, ten lines. The same thing I would have spent thirty minutes wiring up in Spring took one
focused reading of the docs.
Implicit interfaces. I defined the TokenValidator interface after I had already written both validators. They
satisfied it immediately without changing a line. No implements, no declaration, no recompile dance. I will be honest:
this is not groundbreaking. But it is quietly nice. In Java the interface declaration is a commitment you make up front.
In Go, it is something you discover after the fact, and that changes how you think about design.
The thing I keep coming back to is that Go trusts you. It gives you the mutex and expects you to use it correctly. It gives you goroutines and expects you to think about what they share. It does not wrap everything in abstractions to protect you from yourself. That is uncomfortable at first, and then it is liberating. You stop fighting the framework and start thinking about the problem.
Wrapping Up
If I were to do this again, I would think harder about physical key management earlier. The JWKS cache
refresh-throttling
is something I intentionally deferred, and I still think about it. Under a heavy load with many unknown kid values you
could hammer the JWKS endpoint with refetching. Adding a minimum interval between refreshes is the right next step, and
it
is on the list.
I would also add the RefreshTtl config field back sooner. I removed it when I realized nothing was reading it, which
was the right call at the time, but it belongs back in once the throttling is actually implemented. Dead config is worse
than no config.
The project is not production middleware, and it was never trying to be. Auth0's go-jwt-middleware and similar
libraries exist for that. This was about understanding what those libraries are doing, and Go turned out to be the
perfect vehicle for that. It does not let you hide behind abstractions. If you want the mutex to work correctly, you
have
to think about the mutex. If you want the cache to be concurrent, you have to design it that way. I came in wanting to
understand the internals of multi-issuer JWT validation, and I came out understanding Go concurrency, first-class
functions, implicit interfaces, and why the routing-vs-verification split matters for security. That feels like a good
trade.
The code is on GitHub at github.com/evancaplan/polytoken. Clone it, run
docker compose up, mint a token with the CLI, and see it work. If you are a Java engineer thinking about Go, I hope
this gave you a real project to anchor the concepts on. That is what it did for me.