← Back to the section

When a browser or a mobile application calls the API, it puts a JWT token into the request header. The server's job is to make sure the token is genuine: signed by a trusted Identity Provider, not expired, intended for this exact service. If you write the validation by hand, it's easy to miss one step — and an attacker passes as an authorized user. That's why ready-made libraries with a proven implementation are used.

In Go the standard pair is github.com/golang-jwt/jwt/v5 for parsing the token and github.com/MicahParks/keyfunc/v3 for fetching the public keys from the Identity Provider.

Why a JWKS cache is needed

A JWT is signed with the Identity Provider's private key (Keycloak, Auth0, and others). To verify the signature, the service must know the public key. The problem: keys change (rotation) — and the service must fetch new ones automatically.

The solution is the JWK Set (JWKS): the Identity Provider publishes a set of public keys at a URL (jwks_uri). The keyfunc library downloads them at startup and holds them in memory. When a token arrives with an unknown kid (key identifier), keyfunc automatically refreshes the cache — key rotation doesn't require any of your code.

There's no need to store public keys by hand in configuration or files: that's unreliable and breaks when a key changes.

JWTValidator: how the validation is arranged

The JWTValidator struct is initialized once at application startup. It holds the JWKS cache and the token parameters: where it was issued from (issuer) and whom it's intended for (audience).

// adapters/in/http/security/jwt.go
package security

import (
    "fmt"

    "github.com/MicahParks/keyfunc/v3"
    "github.com/golang-jwt/jwt/v5"
)

type Principal struct {
    Sub   string
    Roles []string
}

type JWTValidator struct {
    jwks     *keyfunc.JWKS
    issuer   string
    audience string
}

func NewJWTValidator(jwksURI, issuer, audience string) (*JWTValidator, error) {
    jwks, err := keyfunc.NewDefault([]string{jwksURI})
    if err != nil {
        return nil, fmt.Errorf("jwks init: %w", err)
    }
    return &JWTValidator{jwks: jwks, issuer: issuer, audience: audience}, nil
}

func (v *JWTValidator) Validate(tokenStr string) (*Principal, error) {
    token, err := jwt.Parse(tokenStr, v.jwks.Keyfunc,
        jwt.WithIssuer(v.issuer),
        jwt.WithAudience(v.audience),
        jwt.WithValidMethods([]string{"RS256", "ES256"}),
        jwt.WithExpirationRequired(),
    )
    if err != nil || !token.Valid {
        return nil, &apperr.AuthError{Reason: "invalid token"}
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return nil, &apperr.AuthError{Reason: "malformed claims"}
    }
    return &Principal{
        Sub:   claims["sub"].(string),
        Roles: extractRoles(claims),
    }, nil
}

jwt.Parse with these options checks:

  • The signature — through the public key from the JWKS cache.
  • exp — the token isn't expired. Without WithExpirationRequired() the library lets expired tokens through silently, so this option is mandatory.
  • iss — the token was issued by your Identity Provider, not by a foreign one.
  • aud — the token is intended for this service, not another.
  • alg — only RS256 and ES256 are allowed; an unsigned token (alg: none) is rejected.

Middleware for chi

The token validation doesn't need to be repeated in every handler. For this, chi has middleware — a function that sits before all handlers and runs on every request.

// adapters/in/http/middleware/authn.go
package middleware

import (
    "context"
    "net/http"
    "strings"

    "github.com/example/orders/adapters/in/http/httperr"
    "github.com/example/orders/adapters/in/http/security"
    "github.com/example/orders/core/apperr"
)

type ctxKey string

const principalKey ctxKey = "principal"

func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            header := r.Header.Get("Authorization")
            if !strings.HasPrefix(header, "Bearer ") {
                httperr.Write(w, r, &apperr.AuthError{Reason: "missing bearer token"})
                return
            }
            principal, err := v.Validate(strings.TrimPrefix(header, "Bearer "))
            if err != nil {
                httperr.Write(w, r, err)
                return
            }
            ctx := context.WithValue(r.Context(), principalKey, principal)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

The middleware extracts the token from the Authorization: Bearer <token> header, validates it, and puts the parsed *Principal into context.Context. Handlers retrieve it through PrincipalFrom(ctx).

In the router the middleware is attached once:

r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles("customer"))
    r.Post("/orders", h.CreateOrder)
    r.Get("/orders/{id}", h.GetOrder)
})

Only the parsed *Principal is kept in the context, not the token string itself. If you put the raw token there, handlers get the ability to parse it again — which means they can do it incorrectly.

Configuration via environment variables

jwks_uri, issuer, and audience come from environment variables — not from the code and not from the repository.

// cmd/app/config.go
type Config struct {
    JWKSUri  string `envconfig:"JWKS_URI,required"`
    Issuer   string `envconfig:"JWT_ISSUER,required"`
    Audience string `envconfig:"JWT_AUDIENCE,required"`
}

// cmd/app/wire.go
jwtValidator, err := security.NewJWTValidator(cfg.JWKSUri, cfg.Issuer, cfg.Audience)
if err != nil {
    slog.Error("jwks init failed", "err", err)
    os.Exit(1)
}

If the JWKS is unavailable at startup — the application won't start. That's correct: it's better to fail immediately than to accept unauthorized requests.

401 and 403 — what's the difference

These aren't just different numbers. For a client application, each code carries a specific instruction to act on.

401 Unauthorized means "the request is not authenticated": the token is missing, the signature is invalid, or the token has expired. The client should start the refresh flow or ask the user to log in again.

403 Forbidden means "the user is known, but has no rights." The token is valid, but the role doesn't fit. Refresh won't help here — you need to show "access denied."

If you mix them up and return 403 on an expired token, the client shows "access denied" and doesn't start the refresh. The user can't continue working until they manually log in again.

// adapters/in/http/httperr/render.go
func Write(w http.ResponseWriter, r *http.Request, err error) {
    var authErr *apperr.AuthError
    if errors.As(err, &authErr) {
        w.WriteHeader(http.StatusUnauthorized)   // 401
        json.NewEncoder(w).Encode(problem{Status: 401, Detail: "unauthenticated"})
        return
    }
    var forbErr *apperr.ForbiddenError
    if errors.As(err, &forbErr) {
        w.WriteHeader(http.StatusForbidden)       // 403
        json.NewEncoder(w).Encode(problem{Status: 403, Detail: "forbidden"})
        return
    }
    // ...
}

Why hand-written token parsing is dangerous

A JWT looks simple: three base64 strings separated by dots. It seems easy to parse by hand. In practice, a manual implementation almost always misses one of these cases:

  • iss isn't checked — an attacker slips in a token from their own Identity Provider, and the service accepts it.
  • aud isn't checked — a token issued for another service is accepted as its own.
  • alg: none isn't rejected — an unsigned token passes as valid.
  • Clock skew isn't accounted for — an expired token passes because of a clock discrepancy of a few minutes.
  • The JWKS isn't cached — every request hits the Identity Provider, which becomes a single point of failure.
  • On a key change, all valid tokens get 401 until the cache refreshes.

The golang-jwt/jwt/v5 library with keyfunc/v3 covers all these cases. Reinventing this by hand means reproducing the same logic with inevitable omissions.

In short

  • To validate a JWT, use golang-jwt/jwt/v5 — hand-written signature parsing is unreliable.
  • Public keys are taken from the Identity Provider's JWKS endpoint via keyfunc/v3; the library caches them and refreshes them on a key change automatically.
  • jwt.Parse must be called with WithExpirationRequired() — otherwise expired tokens pass silently.
  • The algorithm is restricted via WithValidMethods([]string{"RS256", "ES256"}) to reject unsigned tokens.
  • The AuthN middleware is placed on the router once; the parsed *Principal goes into the context, not the raw token.
  • 401 — the token is invalid, the client starts the refresh. 403 — the token is valid, but there are no rights. Mixing them up means breaking the UX.
  • jwks_uri, issuer, audience — always from environment variables, not from the code.
  • RBAC: mapping roles — extractRoles from realm_access.roles and scope, RequireRoles on chi groups.
  • ABAC: resource ownership — the order.CustomerID == principal.Sub check in the handler.
  • Service-to-service — the Client Credentials Flow and mTLS for internal calls.
  • PII and secrets — what must not go into error.Error() and logs.