JWT verification at the edge offloads auth from every backend and lets Traefik enforce token validity uniformly. Traefik does not ship a built-in JWT middleware — the two production-grade paths are forwardAuth to a small verifier service, or a community plugin that does the verification inline. This article walks through both, the security trade-offs, and the operational signals you need either way.
How to verify
# A request with no token must be rejected at the edge
curl -sI https://api.example.com/private | head -1
# 401 Unauthorized
# A request with a valid token must reach the backend
TOKEN=$(curl -s -X POST https://auth.example.com/token -d '...' | jq -r .access_token)
curl -sI -H "Authorization: Bearer $TOKEN" https://api.example.com/private | head -1
# 200 OK
# An expired token must be rejected without hitting the backend
curl -sI -H "Authorization: Bearer expired-token" https://api.example.com/private | head -1
# Backend access log should show NO entry for the rejected request
What’s happening
A JWT carries three parts: header, payload, signature. Verification needs the issuer’s public key (RSA, EC) or shared secret (HS), the algorithm declared in the header, and a check on standard claims — exp (expiry), nbf (not-before), iss (issuer), aud (audience). The Traefik edge does not have native JWT smarts; it gets them either by asking another service (“forwardAuth”) or by running a plugin that does the work inline.
forwardAuth is the safe default. Traefik forwards the inbound request’s Authorization header to a separate HTTP endpoint, and decides admission based on the response code: 2xx admits the request and any response headers can be passed through to the backend (commonly X-User-Id, X-User-Email), non-2xx rejects with the same status. The verifier is tiny — a Go or Python service with a JWKS cache, the issuer’s public keys, and the validation logic. It is also the only path that lets you do anything clever (claim transformation, multi-issuer routing, token introspection against a remote IdP).
Plugin-based JWT runs the validation inside Traefik via yaegi. It is faster (no extra hop) but less flexible — most plugins implement a single validation flow, do not refresh JWKS automatically, and cannot do introspection. For symmetric-key signing or static RSA keys with simple claims, plugin is fine. For anything involving rotation or multi-IdP, use forwardAuth.
The thing both approaches share: the JWKS fetch. Modern IdPs publish their signing keys at a well-known URL (/.well-known/jwks.json). Caching that JWKS with a sane TTL is the difference between an edge that handles key rotation gracefully and one that 401s every request for 30 minutes after a rotation.
The procedure
Pattern A — forwardAuth with a verifier sidecar
-
Write the verifier. A 60-line Go service that fetches JWKS, caches it, and validates incoming Authorization headers.
// verifier/main.go package main import ( "log" "net/http" "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" ) var jwks keyfunc.Keyfunc func main() { var err error jwks, err = keyfunc.NewDefault([]string{"https://auth.example.com/.well-known/jwks.json"}) if err != nil { log.Fatal(err) } http.HandleFunc("/verify", verify) log.Fatal(http.ListenAndServe(":4180", nil)) } func verify(w http.ResponseWriter, r *http.Request) { authz := r.Header.Get("Authorization") if len(authz) < 8 || authz[:7] != "Bearer " { http.Error(w, "no bearer", http.StatusUnauthorized); return } tok, err := jwt.Parse(authz[7:], jwks.Keyfunc, jwt.WithIssuer("https://auth.example.com"), jwt.WithAudience("api.example.com")) if err != nil || !tok.Valid { http.Error(w, "invalid", http.StatusUnauthorized); return } claims := tok.Claims.(jwt.MapClaims) if sub, ok := claims["sub"].(string); ok { w.Header().Set("X-User-Id", sub) } if email, ok := claims["email"].(string); ok { w.Header().Set("X-User-Email", email) } w.WriteHeader(http.StatusOK) } -
Run it as a sidecar or as a small deployment inside the cluster. Health check on
/health, metrics on/metricsif you want to track validation rates. -
Wire it into Traefik as a forwardAuth middleware.
http: middlewares: jwt-auth: forwardAuth: address: "http://jwt-verifier.platform.svc.cluster.local:4180/verify" trustForwardHeader: true authResponseHeaders: - X-User-Id - X-User-Email routers: api: rule: "Host(`api.example.com`) && PathPrefix(`/private`)" entryPoints: [websecure] service: api-backend middlewares: [jwt-auth]
Pattern B — plugin-based JWT
For deployments where the extra hop isn’t acceptable, a plugin like traefik-jwt-plugin runs inline.
experimental:
plugins:
jwt:
moduleName: github.com/23deg/jwt
version: v0.6.4
http:
middlewares:
jwt-inline:
plugin:
jwt:
SigningMethod: RS256
Keys:
- "https://auth.example.com/.well-known/jwks.json"
Issuers:
- "https://auth.example.com"
Audience:
- "api.example.com"
routers:
api:
rule: "Host(`api.example.com`)"
entryPoints: [websecure]
service: api-backend
middlewares: [jwt-inline]
The plugin needs to support JWKS refresh; if it caches indefinitely, key rotation breaks the edge. Pin a plugin version and read its source.
Common pitfalls
- forwardAuth that does not forward
X-Forwarded-ProtoandX-Forwarded-Host— the verifier cannot reconstruct the original URL for audience claims; settrustForwardHeader: trueon the middleware. - Using basic-auth fallback alongside JWT — clients pick whichever Traefik checks first and the JWT path becomes optional; remove the fallback.
- JWKS fetched at every request — at high RPS this DoSes your IdP. Cache with a 5-minute TTL and refresh on signature verification failure.
- Trusting
alg: none— some libraries fall back to this when a kid lookup fails. Always set an explicit algorithm allow-list (RS256orES256only). - Long expiry tokens (24h+) without revocation — once you trust a long token, you cannot un-trust it without invalidating every active session.
JWT at the edge is one of the highest-value patterns to centralize, but the rotation discipline (keys, audience, allow-list of algorithms) is what fails in year two. In the engagements we run, JWT verifiers — sidecar or plugin — are versioned in Git alongside the IngressRoute that references them, monitored for non-2xx rates, and rotated on a schedule. That is part of managed operations. For broader auth patterns see traefik-oauth2-proxy-integration.