Profile
Back to NewsBack
GitHub Trending 1 min
Reader Mode
lukaszraczylo/traefikoidc: Traefik OIDC plugin middleware

lukaszraczylo/traefikoidc: Traefik OIDC plugin middleware

18 hours ago

Traefik OIDC Middleware

OpenID Connect authentication middleware for Traefik. Replaces forward-auth + oauth2-proxy. Auto-detects all major OIDC providers, validates ID tokens, manages sessions, and forwards user identity to downstream services.

Documentation

Provider support

| Provider | OIDC | Refresh | Auto-detected by | |----------|------|---------|------------------| | Google | Full | Yes | accounts.google.com | | Azure AD | Full | Yes | login.microsoftonline.com, sts.windows.net | | Auth0 | Full | Yes | *.auth0.com | | Okta | Full | Yes | .okta.com, .oktapreview.com, *.okta-emea.com | | Keycloak | Full | Yes | host containing keycloak, or /realms/ in path (covers KC <17 /auth/realms/ and 17+ /realms/) | | AWS Cognito | Full | Yes | cognito-idp.*.amazonaws.com | | GitLab | Full | Yes | gitlab.com | | GitHub | OAuth 2.0 only — no ID token, no refresh | No | github.com | | Generic | Full | Yes | any RFC-compliant .well-known/openid-configuration |

Authentication and claim extraction use the ID token. Ensure your
provider includes required claims (email, roles, groups) in the ID token,
not just the access token or UserInfo endpoint.

Install

Enable the plugin in Traefik's static configuration:

# traefik.yml
experimental:
  plugins:
    traefikoidc:
      moduleName: github.com/lukaszraczylo/traefikoidc
      version: v1.0.29

Then attach the middleware in your dynamic configuration (see Quickstart below).

This middleware tracks the current Traefik helm chart release. If it fails to load, update Traefik first.

Verify release signatures

Release checksums are signed with cosign keyless signing:

cosign verify-blob \
  --certificate-identity-regexp "https://github.com/lukaszraczylo/traefikoidc/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  --bundle "traefikoidc_v<version>_checksums.txt.sigstore.json" \
  traefikoidc_v<version>_checksums.txt

Quickstart

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oidc-auth
  namespace: traefik
spec:
  plugin:
    traefikoidc:
      providerURL: https://accounts.google.com
      clientID: 1234567890.apps.googleusercontent.com
      clientSecret: urn:k8s:secret:traefik-oidc:CLIENT_SECRET
      sessionEncryptionKey: urn:k8s:secret:traefik-oidc:SESSION_KEY
      callbackURL: /oauth2/callback
      logoutURL: /oauth2/logout
      postLogoutRedirectURI: /
      # forceHTTPS defaults to true (secure-by-default). Only set false if you
      # serve OIDC over plaintext HTTP for local dev.
      allowedUserDomains: [company.com]
      allowedRolesAndGroups: [admin, developer]
      excludedURLs: [/health, /metrics]

More example configs in examples/.

Required parameters

| Parameter | Description | |-----------|-------------| | providerURL | Issuer URL (used for OIDC discovery). | | clientID | OAuth 2.0 client ID. | | clientSecret | OAuth 2.0 client secret. Supports urn:k8s:secret:ns:name:key. Required when clientAuthMethod is unset, client_secret_post, or client_secret_basic; optional with private_key_jwt. | | sessionEncryptionKey | Cookie encryption key, min 32 bytes. | | callbackURL | Callback path, e.g. /oauth2/callback. |

Common optional parameters

Full reference in docs/CONFIGURATION.md.

| Parameter | Default | Purpose | |-----------|---------|---------| | forceHTTPS | true | Forces https:// in redirect URIs. Leave at default behind any TLS-terminating LB (AWS ALB, GCP LB, Azure App Gateway). Set false only for plaintext HTTP local dev. | | logoutURL | callbackURL + "/logout" | RP-initiated logout path. | | postLogoutRedirectURI | / | Where to send users after logout. | | scopes | appended to openid profile email | Extra OAuth scopes. Set overrideScopes: true to replace defaults. | | extraAuthParams | none | Map of extra query parameters appended to the authorization request (e.g. screen_hint: signup, login_hint, ui_locales, prompt). Plugin-managed params (client_id, state, nonce, redirect_uri, code_challenge, scope, response_type, …) cannot be overridden. | | excludedURLs | none | Paths that bypass auth, matched at a path-segment or file-extension boundary (e.g. /public matches /public, /public/sub and /public.json, but not /publicsecret). | | allowedUserDomains | none | Restrict to email domains. | | allowedUsers | none | Restrict to specific addresses (or claim values when userIdentifierClaim != email). | | allowedRolesAndGroups | none | Require any of these roles/groups from ID-token claims. | | roleClaimName / groupClaimName | roles / groups | For namespaced claims (Auth0). | | userIdentifierClaim | email | Use sub, oid, upn, or preferred_username for users without email. | | enablePKCE | false | PKCE on the auth code flow. | | cookieDomain | auto | Set explicitly for multi-subdomain setups (.example.com). | | cookiePrefix | _oidc_raczylo_ | Unique prefix per middleware instance to isolate sessions. | | cookiePath | / | Restrict cookies to a path prefix. Set to the middleware's path (e.g. /app) to prevent the browser from sending OIDC cookies to unprotected paths, avoiding 431 "Request Header Or Cookie Too Large" errors on mixed-use domains. | | sessionMaxAge | 86400 | Session lifetime in seconds. | | refreshGracePeriodSeconds | 60 | Proactively refresh tokens this many seconds before expiry. | | maxRefreshTokenAgeSeconds | 21600 | Heuristic max stored refresh-token lifetime (6h). Past this, the plugin treats the RT as expired without contacting the IdP — returns 401 to AJAX, full re-auth on navigations. Set 0 to disable. Tune to match your IdP's RT TTL. | | rateLimit | 100 | Requests/sec. Min 10. | | perSourceLoginRateLimit | 0 (off) | Throttle OIDC auth events (authorization-code callback + login initiation) per external client source, auth events per minute. Keys and classifies the source by RemoteAddr only, never X-Forwarded-For, so a client cannot spoof it. Internal/loopback sources (proxies, in-cluster) are never throttled. 0 disables. RemoteAddr is the TCP peer. Behind a load balancer or CDN, the limiter keys on the proxy address: a private address is never throttled, and a public address puts every client in one shared bucket. | | logLevel | info | debug, info, error. | | audience | clientID, or resource if set | Custom access-token audience (Auth0 custom APIs). | | resource | none | RFC 8707 resource indicator, alternative to audience for supporting IdPs (see docs/RFC8707.md). | | strictAudienceValidation | false | Reject mismatched audiences. Set true in production. | | allowOpaqueTokens / requireTokenIntrospection | false | Accept opaque access tokens via RFC 7662. | | revocationURL / oidcEndSessionURL / introspectionURL | "" | Override the discovered revocation / end-session / introspection endpoints; take precedence when discovery omits them. | | maxIdentifierLength | 256 | Bound the post-sanitisation length of the bearer identity header (X-Forwarded-User). | | bearerFailureWindowSeconds | 60 | Rolling window (s) over which per-IP bearer 401s are counted toward the throttle. | | bearerFailurePenaltySeconds | 60 | Seconds an IP is kept in the 429 penalty box after the bearer failure threshold. | | disableReplayDetection | false | Disable JTI cache. Use Redis instead for multi-replica. | | allowPrivateIPAddresses | false | Permit private-IP providerURL (internal Keycloak, etc.). | | allowUnauthenticatedPreflight | false | Bypass auth for a genuine CORS preflight (OPTIONS + Origin + Access-Control-Request-Method); the response body is still discarded. Default requires auth for every OPTIONS request, preflight or not. | | streamingRefresh | false | Refresh an expired session on SSE/WebSocket requests. Reject the request when the IdP revokes the grant. | | minimalHeaders | false | Reduce forwarded headers (mitigates HTTP 431). | | stripAuthCookies | false | Strip OIDC cookies from backend hop (mitigates HTTP 431). | | caCertPath / caCertPEM | none | Trust an internal CA for the provider's TLS. | | insecureSkipVerify | false | Local dev only. Disables TLS verification, logs a security warning. | | clientAuthMethod | client_secret_post | Client auth method. Set private_key_jwt for RFC 7523 JWT assertions (Entra ID, Okta, Auth0, Keycloak). See Client authentication via private key JWT. | | clientAssertionPrivateKey | none | Inline PEM private key for private_key_jwt. Mutually exclusive with clientAssertionKeyPath. | | clientAssertionKeyPath | none | File path to PEM private key for private_key_jwt. | | clientAssertionKeyID | none | JWS kid header. Required when clientAuthMethod=private_key_jwt; must match the public key registered with the IdP. | | clientAssertionAlg | RS256 | JWS alg for private_key_jwt. Supported: RS256/384/512, PS256/384/512, ES256/384/512. | | enableBackchannelLogout / backchannelLogoutURL | false / none | OIDC Back-Channel Logout (server-to-server). | | enableFrontchannelLogout / frontchannelLogoutURL | false / none | OIDC Front-Channel Logout (iframe). | | redis | disabled | See docs/REDIS.md. | | dynamicClientRegistration | disabled | See docs/DCR.md. |

Production gotchas

Upgrading from an earlier release

  • Sessions are re-issued once. Session cookies are now AES-256 encrypted
(previously signed only) and their cryptographic lifetime tracks sessionMaxAge (previously a fixed 30 days). Existing cookies become invalid on upgrade, so users re-authenticate one time.
  • A rollback to v1.0.25 or earlier also invalidates sessions. Those
releases cannot read the encrypted cookies that v1.0.26 and later issue. They log securecookie: the value is not valid and No state in callback, and the browser loops through the login. After a rollback, tell users to delete the _oidc_raczylo_* cookies (or the cookies with your cookiePrefix) for the affected hosts. The cookies are HttpOnly, so users must delete them in the browser site-data settings.
  • Invalid configuration now fails closed at startup instead of being
silently accepted: a sessionEncryptionKey shorter than 32 bytes, a rateLimit below 10, a missing callbackURL, or a non-HTTPS remote providerURL are rejected. Plaintext HTTP is permitted only for loopback hosts (local development).
  • A private_key_jwt RSA key under 2048 bits now fails startup instead
of signing locally and failing invalid_client at every token exchange (RFC 7518 §3.3). Generate a 2048-bit-or-larger key before upgrading. No override exists.
  • **A discovered http:// endpoint under an https:// providerURL is now
dropped**, not used as-is: the plugin refuses to send a client secret or token over plaintext HTTP even when the IdP's own discovery document advertises it that way. A dropped token, jwks_uri, or authorization endpoint breaks login; a SECURITY:-tagged log line names the endpoint. See Discovered Endpoint Validation. No override exists.

TLS termination at a load balancer

forceHTTPS defaults to true, so redirect URIs always use https://. This is the right default behind AWS ALB, GCP LB, Azure App Gateway, or any LB that terminates TLS — X-Forwarded-Proto is unreliable (ALB may overwrite it).

Only set forceHTTPS: false when you actually serve OIDC over plaintext HTTP (local dev). See issue #82.

Multi-replica deployments

Each replica keeps its own in-memory JTI cache → false positive "token replay detected" when the same token hits different replicas. Two options:

  1. Set disableReplayDetection: true (loses replay protection).
  2. Enable Redis for shared state (recommended) — see docs/REDIS.md.
For IdP-initiated logout (back/front-channel) in multi-replica setups, Redis is required so a logout on one instance invalidates sessions on the others. Front-channel logout requests must include a matching iss query parameter; requests that omit it are rejected with 400.

Multiple middleware instances on the same host

Each instance must use a unique cookiePrefix and sessionEncryptionKey, otherwise a session minted by one instance can grant access through another. See issue #87.

Bearer-token (M2M) authentication

Opt-in path for API clients that present Authorization: Bearer instead of logging in via the browser flow. Default off. When enabled, the middleware validates the bearer JWT against the configured OIDC provider (signature, issuer, audience, expiry) and forwards the request downstream with the principal headers — no cookie session is created.

enableBearerAuth: true
audience: https://api.example.com   # REQUIRED when bearer is enabled

optional, defaults shown:

bearerIdentifierClaim: sub # claim used as X-Forwarded-User stripAuthorizationHeader: true # drop the raw token before forwarding bearerEmitWWWAuthenticate: true # RFC 6750 hint on 401s bearerOverridesCookie: false # cookie wins when both are present (safer) maxTokenAgeSeconds: 86400 # 24h cap on iat bearerFailureThreshold: 20 # consecutive 401s/IP before 429 throttle

Hardening built in by default:

  • Audience required. Startup fails if enableBearerAuth=true and
audience is unset. Eliminates the "token issued for service B accepted by A" confusion vector.
  • ID tokens explicitly rejected. Bearer is access-token-only. ID tokens
(detected via nonce, typ: at+jwt, token_use, scope, or audience shape) return 401.
  • alg and kid pinned at the entrypoint. Asymmetric-only allowlist
(RS256/384/512, PS256/384/512, ES256/384/512); kid length and charset capped — both checked before any JWKS fetch so attacker noise can't amplify into upstream calls.
  • Identifier sanitised. Default identifier source is sub; email is
rejected unless explicitly opted in (which the middleware still refuses to avoid the unverified-email spoofing footgun). Control characters, bidi- override codepoints, and the delimiters , ; = are all rejected before the value reaches X-Forwarded-User.
  • Multi-audience tokens require azp. When aud is an array of more
than one element, the token must carry azp == clientID.
  • iat upper-age bound. Tokens older than maxTokenAgeSeconds are
rejected even if exp is far in the future.
  • Per-IP 401 throttle. After bearerFailureThreshold consecutive 401s
from one source IP, further bearer requests from that IP are rejected with 429 Too Many Requests + Retry-After.
  • Cookie-wins by default. When both a session cookie and an
Authorization: Bearer header arrive on the same request, the cookie path runs (safer against browser/extension/proxy bearer injection). Set bearerOverridesCookie: true for the AWS/GCP/Kubernetes convention.
  • Revocation enforced. No access-token JTI is recorded for replay;
RevokeToken (called from logout) blacklists the raw token (and a JWT's jti), and both the JWT and the opaque bearer path reject a blacklisted token.
  • Excluded URLs strip Authorization. When enableBearerAuth=true,
excluded paths (e.g. /health, /metrics) get the Authorization header removed before forwarding so the token can't leak into public endpoint logs.
  • Optional real-time revocation. Set requireTokenIntrospection: true
to call RFC 7662 introspection on every cache miss; revoked tokens fail immediately. Introspection endpoint failures return 503 (distinguishes infra outage from credential rejection).

Obtaining bearer tokens — minting is the IdP's job, not the middleware's. The canonical M2M flow is OAuth 2.0 client_credentials (RFC 6749 §4.4); Google requires JWT bearer assertion (RFC 7523) instead. Minimal Auth0-shape request:

curl -s -X POST https://issuer.example.com/oauth/token \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type":    "client_credentials",
    "client_id":     "your-m2m-client-id",
    "client_secret": "your-m2m-client-secret",
    "audience":      "https://api.example.com",
    "scope":         "api:read api:write"
  }'

The audience you request from the IdP must match the audience you configured on the middleware. Per-provider endpoints, parameter names, and gotchas (Entra v2 endpoint, Cognito Resource Servers, Keycloak audience mappers, Google's opaque-token quirk) are documented in docs/BEARER_AUTH.md.

Full threat model, configuration matrix, and follow-up gaps in docs/BEARER_AUTH.md.

SSE and WebSocket endpoints

Browser clients cannot follow an OIDC 302 redirect on an SSE stream or a WebSocket upgrade. The middleware handles this automatically:

  • SSE (Accept: text/event-stream) and WebSocket (Upgrade: websocket)
requests skip the OIDC redirect.
  • They are not unauthenticated — a valid encrypted session cookie is
required, otherwise the request is rejected. The session must already exist (i.e. the user logged in via a normal HTTP page first).
  • X-Forwarded-User and the configured headers are forwarded from the
session. Header templates render from the claims in the session cookie.
  • Validation is cookie-only (no JWK fetch), so streaming keeps working during
brief IdP outages. Back-channel and front-channel logout still apply: a session that the IdP logged out is rejected.
  • By default, the token expiry is not checked, so a session stays usable
for streaming until the session cookie expires. Set streamingRefresh: true to refresh an expired token on the request. If the IdP rejects the refresh (invalid_grant), the request gets 401. If the IdP cannot be reached, the request is forwarded as before. On a WebSocket upgrade, the refreshed session cookie arrives on the 101 Switching Protocols response.

No configuration needed — this is implicit behavior.

HTTP 431 from backends

Either the ID token or the chunked OIDC cookies overflow your backend's header buffer. Combine these as needed:

minimalHeaders: true     # drop X-Auth-Request-Token et al.
stripAuthCookies: true   # strip _oidc_raczylo_* cookies on the backend hop

Cookies remain in the browser; only the Traefik→backend hop is affected. See #64, #122.

Internal CA for the provider

If the provider's TLS cert is signed by a private CA (self-hosted GitLab, internal Keycloak, ADFS):

caCertPath: /etc/ssl/certs/internal-ca.pem

or, inline:

caCertPEM: | -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----

Both can be combined. An unparseable bundle fails the plugin at startup. See #125.

Client authentication via private key JWT

Use when your IdP enforces short-lived secrets or pushes secretless client auth — Microsoft Entra ID / Azure AD, Okta, Auth0, Keycloak. Instead of sending a static clientSecret, the plugin signs a short-lived JWT and submits it as client_assertion per RFC 7523.

Minimal config:

clientAuthMethod: private_key_jwt
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
clientAssertionKeyID: my-key-2026

clientAssertionAlg: RS256 # default; or PS256/384/512, ES256/384/512

Or inline:

clientAuthMethod: private_key_jwt
clientAssertionPrivateKey: |
  -----BEGIN PRIVATE KEY-----
  ...
  -----END PRIVATE KEY-----
clientAssertionKeyID: my-key-2026

Accepted PEM forms: PKCS#8 (PRIVATE KEY), PKCS#1 (RSA PRIVATE KEY), SEC1 (EC PRIVATE KEY). The assertion uses iss=sub=clientID, aud=tokenURL, 60s lifetime, random hex jti per request. Sent on /token (auth-code + refresh) and /revoke. The kid must match the public key registered with the IdP.

clientSecret becomes optional with private_key_jwt. Existing client_secret_post setups are unaffected. Keys are parsed once at startup — rotation requires a Traefik reload.

See issue #135.

Environment variable names containing API

Traefik reserves TRAEFIK_API_*. User vars whose name contains API (e.g. OIDC_ENCRYPTION_SECRET_API) make the plugin fail with invalid handler type: . Rename to anything without the literal API substring. See #98.

Templated headers

Forward identity to backends via Go templates over ID-token claims and tokens:

headers:
  - name: X-User-Email
    value: "{{.Claims.email}}"
  - name: Authorization
    value: "Bearer {{.AccessToken}}"
  - name: X-User-Roles
    value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"

Available bindings: .Claims., .AccessToken, .IdToken (or .IDToken), .RefreshToken. Names are case-sensitive (.Claims, not .claims).

Header templates are validated at startup (a failing template stops the middleware from loading). Only a fixed set of claim fields may be emitted — standard OIDC claims plus common provider claims (email, name, given_name, family_name, preferred_username, sub, groups, roles, realm_access, resource_access, oid, tid, upn, hd, picture, locale, email_verified, and a few more; see safeClaimsFields in template_validation.go). To emit a claim not on that list, add it to allowedClaims:

allowedClaims:
  - employee_id
headers:
  - name: X-Employee-Id
    value: "{{.Claims.employee_id}}"

Rendering the whole context ({{.}}, {{$}}), the whole claims map ({{.Claims}}), or any non-listed claim is rejected — this prevents a template from accidentally forwarding raw tokens or unlisted claims. range/with must target a specific listed claim (e.g. {{range .Claims.groups}}); get/default are the only functions allowed.

File-provider users: escape the braces. Traefik's file provider runs
every dynamic configuration file through Go templating before the plugin
sees it. Plain {{.AccessToken}} then fails with
can't evaluate field AccessToken in type bool. Wrap the expression in a
raw string so the file provider emits it literally:
`value: "{{{{.Claims.email}}}}". All other providers (Kubernetes CRD,
Docker labels, Consul, ...) pass the value through untouched — use the plain
form there. Quadruple braces ({{{{ }}}}) do not work anywhere: the file
provider fails to parse them, and every other path hands them to the plugin
verbatim, where template validation rejects them (issues #149, #151).

Default downstream headers

When a request is authenticated, the middleware sets:

| Header | Notes | |--------|-------| | X-Forwarded-User | User's email (always). | | X-User-Groups | Comma-separated. | | X-User-Roles | Comma-separated. | | X-Auth-Request-User | User's email. | | X-Auth-Request-Redirect | Original request URI. | | X-Auth-Request-Token | Full ID token — the largest header; suppressed by minimalHeaders. |

Plus security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy) controlled by the securityHeaders section — see docs/CONFIGURATION.md.

Common errors

| Symptom | Cause | |---------|-------| | Token verification failed | Wrong/unreachable providerURL, or clock skew. | | Session encryption key too short | sessionEncryptionKey is < 32 bytes. | | No matching public key found | JWKS endpoint down, or kid mismatch. | | Access denied: Your email domain is not allowed | User's domain not in allowedUserDomains. | | Access denied: You do not have any of the allowed roles or groups | Claims missing or not in allowedRolesAndGroups. | | can't evaluate field AccessToken in type bool | File provider templated your header value — escape it: "{{{{.AccessToken}}}}" (see "Templated headers"). | | tls: failed to verify certificate: x509: certificate signed by unknown authority | Internal CA — set caCertPath / caCertPEM. | | invalid handler type: | Env var name contains API — rename it. | | false positive replay detected | Multi-replica without Redis — see Multi-replica deployments. | | Google sessions expire after ~1h | Consent screen still in "Testing" mode. Do not add offline_access — Google rejects it; the middleware sets access_type=offline automatically. |

Provider-specific issues (Keycloak mappers, Azure AD group overage, Auth0 namespaced claims, Cognito regions, GitLab self-hosted) live in docs/PROVIDERS.md.

Set logLevel: debug to surface detail.

Telemetry

On first plugin instantiation this middleware sends a single anonymous adoption ping — project name, version, timestamp; no identifiers, no request data, no token contents. Fire-and-forget with a 2-second timeout; cannot block plugin load or panic.

Local source: telemetry.go. Disclosure mirrors oss-telemetry — Disabling telemetry.

Quick opt-out: set any of DO_NOT_TRACK=1, OSS_TELEMETRY_DISABLED=1, or TRAEFIKOIDC_DISABLE_TELEMETRY=1`.

License

See LICENSE.

Chat with me