Skip to content

JWT authentication at the Traefik edge — forwardAuth and the jwt plugin paths

Verify JWTs at the Traefik edge — the forwardAuth pattern with a tiny verifier sidecar, and the plugin-based approach for self-contained validation.

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

  1. 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)
    }
  2. Run it as a sidecar or as a small deployment inside the cluster. Health check on /health, metrics on /metrics if you want to track validation rates.

  3. 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-Proto and X-Forwarded-Host — the verifier cannot reconstruct the original URL for audience claims; set trustForwardHeader: true on 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 (RS256 or ES256 only).
  • 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.