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. WithoutWithExpirationRequired()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— onlyRS256andES256are 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:
issisn't checked — an attacker slips in a token from their own Identity Provider, and the service accepts it.audisn't checked — a token issued for another service is accepted as its own.alg: noneisn'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.Parsemust be called withWithExpirationRequired()— otherwise expired tokens pass silently.- The algorithm is restricted via
WithValidMethods([]string{"RS256", "ES256"})to reject unsigned tokens. - The
AuthNmiddleware is placed on the router once; the parsed*Principalgoes 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.
What to read next
- RBAC: mapping roles —
extractRolesfromrealm_access.rolesandscope,RequireRoleson chi groups. - ABAC: resource ownership — the
order.CustomerID == principal.Subcheck 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.