background-shape
Securing Go Microservices with JWT, Patterns That Hold Up
July 10, 2024 · 7 min read · by Muhammad Amal programming

TL;DR — JWT in Go microservices is straightforward if you follow three rules: validate alg against an allowlist, rotate signing keys via JWKS, and stop putting authorization decisions inside the token. golang-jwt v5 makes the first easy. The other two are on you.

JWT has been the default service-to-service auth token for long enough that the bad patterns are well documented and people still ship them. I have done security reviews on a dozen Go services in the last two years and roughly half of them had at least one of the same handful of bugs.

This post is the version of those review comments that I wish I could send as a single link. It covers what I do when I’m wiring up JWT validation in a new Go service, with golang-jwt v5 specifically, and the operational pieces that the library docs don’t cover.

If you’re new to JWT itself, jwt.io has the format breakdown. I’m assuming you know what a header, payload, and signature look like.

The threat model, briefly

Before any code, get clear on what JWT is and isn’t doing for you.

JWT proves that an issuer signed a claim. That’s it. It does not prove the bearer is the legitimate user of that claim — a stolen JWT works exactly like a stolen password until it expires. It does not provide confidentiality — the payload is base64, not encrypted. It does not handle revocation natively — once issued, a JWT is valid until it expires.

If your threat model needs revocation, you either need a short TTL (5-15 minutes is typical) with refresh tokens, or you need a revocation list that every service checks. Both are real engineering work. Pretending JWT solves this for free is how you ship a system where leaked tokens stay valid for 24 hours.

Signing keys, asymmetric or bust

For service-to-service auth, asymmetric keys (RS256 or ES256) are the right default. The issuer holds the private key. Every verifier has the public key. Compromise of a verifier doesn’t let you mint tokens.

HS256 (shared secret) only makes sense if you have one issuer and one verifier, both in the same trust boundary, and you’ve made a deliberate choice. In a microservice fleet with five services that need to validate tokens, HS256 means five copies of the same secret. Don’t.

I use ES256 by default in 2024. Smaller keys, smaller tokens, same security as RS256. The only reason to pick RS256 is if you need compatibility with a JWKS consumer that doesn’t speak EC.

Validation, the part that goes wrong

Here’s a validator I’d actually ship, using golang-jwt v5:

package auth

import (
    "context"
    "errors"
    "fmt"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type Validator struct {
    issuer   string
    audience string
    keys     KeySource // fetches public keys by kid
}

type Claims struct {
    UserID string   `json:"sub"`
    Email  string   `json:"email"`
    Roles  []string `json:"roles"`
    jwt.RegisteredClaims
}

func (v *Validator) Validate(ctx context.Context, raw string) (*Claims, error) {
    parser := jwt.NewParser(
        jwt.WithValidMethods([]string{"ES256"}),
        jwt.WithIssuer(v.issuer),
        jwt.WithAudience(v.audience),
        jwt.WithExpirationRequired(),
        jwt.WithLeeway(30*time.Second),
    )

    claims := &Claims{}
    token, err := parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
        kid, ok := t.Header["kid"].(string)
        if !ok || kid == "" {
            return nil, errors.New("missing kid")
        }
        return v.keys.Get(ctx, kid)
    })
    if err != nil {
        return nil, fmt.Errorf("parse: %w", err)
    }
    if !token.Valid {
        return nil, errors.New("token not valid")
    }
    return claims, nil
}

What’s deliberate in this code:

  • WithValidMethods is an allowlist. Without it, golang-jwt v5 will accept whatever alg the token claims. The classic alg: none attack is patched in v5 by default, but alg confusion attacks (HS256 token verified with RS256 public key as “secret”) have shown up in older libraries. Allowlist your algorithms explicitly.
  • WithIssuer and WithAudience are checked. A token signed by a key you trust, for a different service, is not a token for you. This is the single most overlooked check.
  • WithExpirationRequired. Without this, a token with no exp claim is valid forever. Don’t accept tokens without expiration.
  • 30 seconds of leeway. Clock skew is real. Don’t reject a token because your server’s clock is 800ms ahead of the issuer’s.
  • kid is required. No kid, no validation. This is what makes key rotation possible.

JWKS and rotation

The KeySource interface above is where rotation lives. The right implementation fetches a JWKS document from the issuer, caches it, and looks up keys by kid.

type JWKSKeySource struct {
    url   string
    cache *jwk.Cache // github.com/lestrrat-go/jwx/v2/jwk
}

func (s *JWKSKeySource) Get(ctx context.Context, kid string) (any, error) {
    set, err := s.cache.Get(ctx, s.url)
    if err != nil {
        return nil, fmt.Errorf("jwks fetch: %w", err)
    }
    key, ok := set.LookupKeyID(kid)
    if !ok {
        // Force refresh once - the issuer may have rotated
        if _, err := s.cache.Refresh(ctx, s.url); err != nil {
            return nil, err
        }
        set, _ = s.cache.Get(ctx, s.url)
        key, ok = set.LookupKeyID(kid)
        if !ok {
            return nil, fmt.Errorf("unknown kid %q", kid)
        }
    }
    var raw any
    if err := key.Raw(&raw); err != nil {
        return nil, err
    }
    return raw, nil
}

The “refresh once on unknown kid” pattern is important. When the issuer rotates keys, you’ll start seeing tokens with a kid your cache doesn’t have. A single refresh on cache miss handles this gracefully without re-fetching JWKS on every request.

The lestrrat-go jwk.Cache handles background refresh on a TTL, which is what you want for steady-state. It also handles Cache-Control headers from the JWKS endpoint if your issuer sets them.

Context plumbing

Once a token is validated, you need to get the claims to your handlers. Don’t pass the JWT itself around — it’s a serialization format, not a domain object. Extract claims at the edge and put them in context.

type ctxKey struct{}

func WithClaims(ctx context.Context, c *Claims) context.Context {
    return context.WithValue(ctx, ctxKey{}, c)
}

func FromContext(ctx context.Context) (*Claims, bool) {
    c, ok := ctx.Value(ctxKey{}).(*Claims)
    return c, ok
}

func Middleware(v *Validator) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            raw := bearer(r.Header.Get("Authorization"))
            if raw == "" {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }
            claims, err := v.Validate(r.Context(), raw)
            if err != nil {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }
            next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), claims)))
        })
    }
}

func bearer(h string) string {
    const p = "Bearer "
    if len(h) < len(p) || h[:len(p)] != p {
        return ""
    }
    return h[len(p):]
}

The unexported ctxKey type is the convention for context keys in 2024. It prevents collisions with other packages using the same context.

Authorization belongs elsewhere

The biggest pattern I want to push back on is putting fine-grained permissions inside the JWT. I see tokens with payloads like "permissions": ["orders.read", "orders.write", "customers.read", "billing.write", ...] running to 30-40 entries.

This breaks for two reasons:

  1. Tokens get large. A 4KB header gets clipped by some proxies.
  2. Revoking a permission requires waiting for the token to expire. If you remove someone from a team and their token has 14 minutes left, they still have access for 14 minutes.

What I do instead: JWT carries identity (sub, email, roles at a coarse level, maybe tenant_id). Authorization decisions happen at the service, against a permission store that’s queryable in real time. Cache the lookup if you need throughput. This makes revocation immediate and keeps tokens small.

If you’re doing this in a gateway pattern, the auth check often lives at the gateway — see choosing an API gateway in 2024 for how Kong, Tyk, and Envoy Gateway each handle it.

Common Pitfalls

  • No alg allowlist. Even with v5’s safer defaults, set WithValidMethods explicitly. Future you will thank present you.
  • Skipping issuer/audience checks. A valid signature from a trusted key is not enough. The token must be for your service.
  • HS256 across services. If a junior engineer can get the shared secret onto a laptop, you have a problem.
  • No kid in the token header. You cannot rotate keys. Eventually, you will need to rotate keys.
  • Putting refresh tokens in JWT. Refresh tokens should be opaque, server-side records. JWT is for short-lived access tokens.
  • Logging the full token. I see this in error handlers. Don’t. Log the jti claim or a hash if you need to correlate.
  • Storing JWT in localStorage. Browser context, but worth saying. Use httpOnly cookies for web clients.

Wrapping Up

The JWT validation code in a Go service should be about 50 lines, including the JWKS client and middleware. It’s not where the interesting engineering is. The interesting engineering is in the operational pieces: key rotation that doesn’t break clients, revocation that actually works, token TTLs that match your security posture.

If I had to pick the single most-impactful change for a team adopting JWT: start with a 10-minute access token TTL and a refresh token flow, not a 24-hour access token. The pain of doing the refresh logic right is worth it the first time you have to invalidate a session in a hurry. Build for the breach, not for the happy path.