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.
Authorization: Bearer path, threat model| Provider | OIDC | Refresh | Auto-detected by |
|---|---|---|---|
| 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.
Enable the plugin in Traefik's static configuration:
# traefik.ymlexperimental:plugins:traefikoidc:moduleName: github.com/lukaszraczylo/traefikoidcversion: v0.7.10
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.
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
apiVersion: traefik.io/v1alpha1kind: Middlewaremetadata:name: oidc-authnamespace: traefikspec:plugin:traefikoidc:providerURL: https://accounts.google.comclientID: 1234567890.apps.googleusercontent.comclientSecret: urn:k8s:secret:traefik-oidc:CLIENT_SECRETsessionEncryptionKey: urn:k8s:secret:traefik-oidc:SESSION_KEYcallbackURL: /oauth2/callbacklogoutURL: /oauth2/logoutpostLogoutRedirectURI: /# 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/.
| 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. |
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. |
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. |
logLevel | info | debug, info, error. |
audience | clientID | Custom access-token audience (Auth0 custom APIs). |
strictAudienceValidation | false | Reject mismatched audiences. Set true in production. |
allowOpaqueTokens / requireTokenIntrospection | false | Accept opaque access tokens via RFC 7662. |
disableReplayDetection | false | Disable JTI cache. Use Redis instead for multi-replica. |
allowPrivateIPAddresses | false | Permit private-IP providerURL (internal Keycloak, etc.). |
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. |
sessionMaxAge (previously a fixed 30 days). Existing cookies become invalid
on upgrade, so users re-authenticate one time.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).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.
Each replica keeps its own in-memory JTI cache → false positive "token replay detected" when the same token hits different replicas. Two options:
disableReplayDetection: true (loses replay protection).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.
Each instance must use a unique cookiePrefix and sessionEncryptionKey,
otherwise a session minted by one instance can grant access through another.
See issue #87.
Opt-in path for API clients that present Authorization: Bearer <jwt> 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: trueaudience: https://api.example.com # REQUIRED when bearer is enabled# optional, defaults shown:bearerIdentifierClaim: sub # claim used as X-Forwarded-UserstripAuthorizationHeader: true # drop the raw token before forwardingbearerEmitWWWAuthenticate: true # RFC 6750 hint on 401sbearerOverridesCookie: false # cookie wins when both are present (safer)maxTokenAgeSeconds: 86400 # 24h cap on iatbearerFailureThreshold: 20 # consecutive 401s/IP before 429 throttle
Hardening built in by default:
enableBearerAuth=true and
audience is unset. Eliminates the "token issued for service B accepted
by A" confusion vector.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.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.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.bearerFailureThreshold consecutive 401s
from one source IP, further bearer requests from that IP are rejected
with 429 Too Many Requests + Retry-After.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.RevokeToken still terminates a bearer token immediately.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.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.
Browser clients cannot follow an OIDC 302 redirect on an SSE stream or a
WebSocket upgrade. The middleware handles this automatically:
Accept: text/event-stream) and WebSocket (Upgrade: websocket)
requests skip the OIDC redirect.X-Forwarded-User is forwarded from the session.No configuration needed — this is implicit behavior.
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.
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.
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_jwtclientAssertionKeyPath: /etc/traefik/oidc/client-key.pemclientAssertionKeyID: my-key-2026# clientAssertionAlg: RS256 # default; or PS256/384/512, ES256/384/512
Or inline:
clientAuthMethod: private_key_jwtclientAssertionPrivateKey: |-----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.
APITraefik reserves TRAEFIK_API_*. User vars whose name contains API (e.g.
OIDC_ENCRYPTION_SECRET_API) make the plugin fail with
invalid handler type: <nil>. Rename to anything without the literal API
substring. See #98.
Forward identity to backends via Go templates over ID-token claims and tokens:
headers:- name: X-User-Emailvalue: "{{{{.Claims.email}}}}"- name: Authorizationvalue: "Bearer {{{{.AccessToken}}}}"- name: X-User-Rolesvalue: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
Available bindings: .Claims.<field>, .AccessToken, .IdToken,
.RefreshToken. Names are case-sensitive (.Claims, not .claims).
Escape with quadruple braces. If you see
can't evaluate field AccessToken in type bool, Traefik's YAML parser ate your{{ }}. The fix that actually works is{{{{ }}}}— the YAML pass turns it into{{ }}for the Go template engine. Other escaping tricks (literal blocks, single quotes) do not work reliably.
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.
| 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 | Template not escaped — use {{{{ }}}}. |
tls: failed to verify certificate: x509: certificate signed by unknown authority | Internal CA — set caCertPath / caCertPEM. |
invalid handler type: <nil> | 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.
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.
See LICENSE.