lukaszraczylo 546ceb949c security: remediate audit findings (ranks 1–16 + 22 Lows) + yaegi load validation (#144)
* fix(security): encrypt session cookies + fail closed on invalid config

Batch 1 of security audit remediation (ranks 1, 2, 6).

- session.go: derive independent HMAC + AES-256 keys via stdlib HKDF-SHA256
  and build the gorilla cookie store with both, so session cookies are now
  encrypted, not merely signed. The single-key store previously left OIDC
  access/refresh/ID tokens recoverable from raw cookie bytes. Cookie format
  changes, so existing sessions are invalidated on deploy (one-time re-login).
- main.go: call config.Validate() at construction and error out on failure,
  instead of silently substituting a public hardcoded encryption key for
  empty/short keys (which allowed session forgery). The yaegi analyzer
  passes via .traefik.yml testData.
- settings.go: isValidSecureURL permits plaintext HTTP for loopback hosts
  only (RFC 8252); remote providers must still use HTTPS.
- tests: complete configs that did not satisfy Validate(); add regression
  tests in security_audit_fixes_test.go.

Configs below documented minimums (rateLimit < 10, key < 32 chars) are now
rejected at startup (fail closed).

* fix(security): validate discovered OIDC endpoints + pin introspection host

Batch 2 of security audit remediation (ranks 3, 4).

- url_helpers.go: add validateDiscoveredEndpoint, an SSRF screen for endpoints
  taken from the provider discovery document (jwks_uri, token, authorization,
  revocation, end_session, introspection, registration). Blocks link-local
  (cloud metadata 169.254.169.254), multicast, unspecified and private
  addresses (unless allowPrivateIPAddresses); blocks loopback unless the
  configured providerURL is itself loopback (dev/test). Cross-domain JWKS
  hosts (e.g. Google) stay allowed. Add sameHost helper.
- main.go: updateMetadataEndpoints screens every discovered endpoint and
  blanks any that fail (fail closed downstream). The introspection endpoint
  carries the client secret via HTTP Basic, so it is additionally pinned to
  the providerURL host to stop a poisoned discovery document exfiltrating the
  secret to an attacker-controlled host.
- tests: regression tests for the SSRF guard and the host pin.

* fix(security): close open redirects + anchor excluded-URL matching

Batch 3 of security audit remediation (ranks 5, 14, 15).

- auth_flow.go: run the stored incoming path through normalizeLogoutPath
  before using it as the post-login redirect, so //evil.com and /\evil.com
  payloads become host-relative (open-redirect, rank 5).
- url_helpers.go: excluded-URL matching is anchored at a natural boundary
  (exact, sub-path "/", or file extension "."), so excluding "/public" no
  longer also bypasses auth on "/publicsecret"; "/favicon" still matches
  "/favicon.ico" (rank 14).
- internal/utils: X-Forwarded-Host is sanitized (first value only; reject
  CRLF/whitespace/multi-value) before building redirect URLs (rank 15).
- helpers.go: the logout redirect used when there is no provider end-session
  endpoint is host-relative, never an absolute URL derived from the
  client-controllable request host (logout open-redirect, rank 15).
- tests: update two logout cases that asserted the old absolute redirect;
  add regression tests.

* fix(security): reject unverified Azure tokens; fix transport TLS reuse

Batch 4 of security audit remediation (ranks 7, 11).

- token_validation_rs.go: an Azure nonce-bearing access token that cannot be
  cryptographically verified no longer returns "authenticated" when there is
  no ID token to corroborate it; it refreshes (if possible) or forces
  re-authentication instead of failing open (rank 7).
- http_client_pool.go: the at-limit transport-reuse path now takes the write
  lock before mutating refCount (fixes a data race) and only reuses a
  transport whose TLS settings (CA pool + InsecureSkipVerify) match the
  caller's, never one with a different trust store; if none matches it returns
  nil so the caller falls back to a verifying default transport (rank 11).
- tests: add a transport-pool TLS-isolation regression test.

* fix(security): stop logging templated header values (token leak)

Batch 5 of security audit remediation (rank 16).

middleware.go: templated downstream headers commonly carry the access token
(e.g. "Authorization: Bearer {{.AccessToken}}"). The debug log line printed
the full header value, leaking credentials into logs. Log the header name and
byte length instead.

* fix(security): cache-key collision, cache-config divergence, fleet cleanup

Batch 6 of security audit remediation (ranks 9, 10, 12).

- token_manager.go: detectTokenType keys its cache on a SHA-256 hash of the
  full token instead of the first 32 chars (which are only the base64url JWT
  header). Distinct tokens sharing alg+kid no longer collide and get
  mis-classified (rank 10).
- cache_manager.go: the process-global cache manager is initialized once and
  shared across plugin instances; it now logs a loud warning when a later
  instance requests a different explicit Redis backend that is silently
  ignored, surfacing the cross-instance state-isolation hazard (rank 9).
- singleton_resources.go / main.go / utilities.go: track a process-global live
  instance count; the shared singleton-token-cleanup task is stopped only when
  the LAST instance shuts down, so one instance's Close() (e.g. a config reload)
  no longer kills cleanup for surviving instances (rank 12).
- tests: update TestDetectTokenTypeCaching for the new key; add regression tests.

* fix(security): bound introspection cache + cookie lifetime to config

Batch 7 of security audit remediation (ranks 8, 13).

- token_introspection.go: when requireTokenIntrospection is enabled, cap the
  positive introspection-result cache at 30s (instead of 5m) so a token
  revoked at the provider stops passing within ~30s, matching the operator's
  near-real-time revocation expectation (rank 8).
- session.go: bind the cookie store's MaxAge to the configured sessionMaxAge,
  so the cookie codec's cryptographic timestamp validity is no longer fixed at
  gorilla's 30-day default; a stolen cookie is valid only for the configured
  session lifetime (rank 13).
- tests: add a cookie-lifetime regression test.

* fix(security): low-severity hardening (cache, DoS caps, PKCE, throttle)

Batch 8 of security audit remediation — low severity
(ranks 24, 25, 27, 29, 31, 36, 37, 41, 45, 46, 49).

- universal_cache.go: updateLocalCache updates an existing key in place instead
  of orphaning its LRU element and double-counting currentSize/currentMemory
  (rank 36 — the only production-reachable bug in this batch).
- jwk.go / metadata_cache.go / token_introspection.go: bound response bodies
  with io.LimitReader (1 MiB) to prevent memory exhaustion from a hostile or
  buggy provider (ranks 24, 25).
- jwk.go: skip JWKs not usable for signature verification (use != sig, or
  key_ops without "verify") when building the key set (rank 49).
- auth_flow.go: fail closed at the callback when PKCE is enabled but the code
  verifier is missing, instead of silently dropping it (rank 27).
- utilities.go / main.go: match allowedUserDomains case-insensitively (rank 31).
- bearer_auth.go: a single success no longer wipes an active per-IP penalty;
  the counter resets only when no penalty is in effect (rank 29).
- main.go: handle (not discard) the NewSessionManager error (rank 37).
- error_recovery.go: take a write lock in isServiceDegraded (it deletes from a
  map); compare retryable-error substrings case-insensitively (ranks 45, 46).
- singleton_resources.go: bind the generic-cache cleanup goroutine to the
  resource-manager shutdown channel so it cannot outlive its owner (rank 41).
- tests: update the bearer throttle test to the corrected penalty semantics.

* fix(security): header sanitization, issuer pinning, fail-closed paths

Batch 9 of security audit remediation (ranks 18, 19, 20, 21, 22, 30, 33, 34).

- middleware.go / bearer_auth.go: sanitize claim-derived values on the cookie
  auth path before injecting them into downstream headers. Drop group/role and
  identifier values containing control chars, bidi-override runes, or the
  , ; = delimiters (a comma would inject phantom entries into X-User-Groups);
  reject control/bidi/over-length in rendered templated header output (but
  permit , ; = in free-form values such as a bearer token). The bearer path
  already sanitized; the cookie path did not (ranks 33, 34).
- main.go / metadata_cache.go: pin the discovered issuer to the configured
  provider host (sameHost) and refuse/never-cache a mismatch, so a poisoned
  discovery document cannot redefine the JWT trust anchor (ranks 21, 22).
- token_introspection.go: when a distinct API audience is configured, fail
  closed on a missing or mismatched introspection audience; aud parsed as
  string-or-array per RFC 7662 (rank 19).
- logout.go: front-channel logout requires a matching issuer; an empty iss is
  rejected (blocks unauthenticated forced-logout via a known sid) (rank 30).
- token_validation_rs.go: an opaque access token with no ID token and no
  successful introspection fails closed (re-auth) instead of authenticating
  (ranks 18, 20).
- tests: realistic same-host provider mocks; regression tests for the header
  sanitization distinction and the fail-closed paths.

* chore(security): remove unwired dead code with latent footguns

Batch 10 of security audit remediation — delete confirmed-dead, unwired
subsystems (ranks 26, 35, 50). None had a production caller (grep-verified);
removal eliminates the latent footguns and ~2.1k lines of dead code.

- token_validator.go (deleted): an unused *TokenValidator whose validateJWT set
  Valid=true with NO signature verification — a severe footgun if ever wired
  (rank 50). The wired RS-aware validators are unaffected.
- security_monitoring.go (deleted): an unused *SecurityMonitor / ExtractClientIP
  that trusted spoofable X-Forwarded-For / X-Real-IP. The live bearer throttle
  uses clientIPForBearer (RemoteAddr-only), unchanged (rank 35).
- dynamic_client_registration.go: removed the RFC 7592 management methods
  (Update/Read/DeleteClientRegistration) that dereferenced an attacker-
  influenced RegistrationClientURI with the registration token attached and no
  HTTPS/SSRF gate, and had no callers. The wired RFC 7591 RegisterClient and
  credential-store helpers are kept (rank 26).
- tests: removed the tests covering the deleted code.

* chore: add Makefile with yaegi load validation

No Makefile existed. The new `yaegi-validate` target interprets the plugin
under the yaegi interpreter the same way Traefik loads it, catching yaegi-only
incompatibilities (unsupported stdlib symbols, reflection edge cases) that the
native `go build` / `go test` toolchain does not. Importing the plugin forces
yaegi to interpret every file plus its vendored deps; CreateConfig + New
exercise the instantiation path.

- cmd/yaegicheck/main.go: the load driver, marked //go:build ignore so it is
  excluded from `go build ./...` (avoids VCS-stamping a main binary, which
  fails in git-worktree layouts) yet is run explicitly by yaegi.
- Makefile: build / fmt / vet / lint / test / vendor / yaegi-validate / check
  targets; `make check` runs vet + tests + yaegi-validate.

Verified: `make yaegi-validate` passes on this branch — the HKDF cookie
encryption, net-based endpoint validation, and claim sanitizers all interpret
and instantiate cleanly under yaegi.

* ci: bump workflow Go toolchain to 1.25; pin yaegi-validate to v0.16.1

Traefik v3.7.1 (the deployed version) is built with `go 1.25.0`, so the PR and
release workflows now use Go 1.25.x to match the toolchain Traefik uses.

Important distinction: the CI Go version is the build TOOLCHAIN. The plugin's
actual interpreter-compatibility ceiling is the yaegi version Traefik bundles
(v0.16.1, which declares go 1.21 and ships a ~Go 1.22 stdlib symbol surface),
NOT the CI Go version. That ceiling is enforced by `make yaegi-validate` plus
the go.mod language directive — e.g. it is why HKDF is hand-rolled with
hmac+sha256 rather than Go 1.24's crypto/hkdf, which yaegi v0.16.1 lacks.

Also pin Makefile YAEGI_VERSION to v0.16.1 (what Traefik v3.7.1 vendors) so
yaegi-validate exercises the real deployed interpreter instead of @latest,
which could pass on a newer yaegi that supports symbols the deployed one does
not.

* docs: align README/CONFIGURATION with branch behavior changes

- excludedURLs: documented as segment/extension-boundary matching (was
  "prefix-matched") — "/public" no longer also matches "/publicsecret" (rank 14).
- Front-channel logout now requires a matching `iss`; requests without one are
  rejected with 400 (rank 30).
- Add an "Upgrading from an earlier release" note: session cookies are now
  AES-256 encrypted with lifetime tracking sessionMaxAge (one-time re-login on
  upgrade), and invalid configuration (rateLimit < 10, key < 32 bytes, missing
  callbackURL, non-HTTPS remote providerURL) now fails closed at startup.

* fix: remove staticcheck-flagged unused functions; wire staticcheck into make check

CI Static Analysis (standalone staticcheck) failed with U1000 "unused":
- dynamic_client_registration.go: deleteCredentialsFromStore — its only caller
  was the RFC 7592 DeleteClientRegistration removed in the dead-code batch.
- token_test.go: createTestJWTSimple — its only callers were the TokenValidator
  tests removed in the same batch.
Both confirmed to have zero remaining callers and removed. build / vet /
go test ./... / staticcheck ./... all green.

The pre-commit hook runs golangci-lint, but CI runs standalone staticcheck
(which flags U1000). Add a `staticcheck` Makefile target and include it in
`make check` so this class of finding is caught locally before push.

* fix(test): stabilize flaky TestWorkerPool_TaskPanic

tasksFailed is incremented in the worker's deferred recover(), which runs after the panicking task's own defer wg.Done(). wg.Wait() could therefore return before the failure was recorded, so reading the counter immediately raced and flaked on slow CI runners. Poll until the failure lands (2s budget) instead. Verified 200x plain + 50x under -race/GOMAXPROCS=1.
2026-05-30 14:10:32 +01:00
2026-01-04 01:59:50 +00:00
2025-10-16 10:56:28 +01:00
2026-04-19 10:12:00 +01:00
2025-12-09 01:38:02 +00:00
2025-10-16 10:56:28 +01:00
2026-04-19 10:12:00 +01:00
2025-10-16 10:56:28 +01:00
2025-04-10 01:39:57 +01:00
2025-10-08 11:44:00 +01:00
2026-04-19 10:12:00 +01:00
2025-10-16 10:56:28 +01:00
2025-10-16 10:56:28 +01:00
2025-10-01 12:13:10 +01:00
2025-09-25 12:52:53 +01:00
2025-10-08 11:44:00 +01:00
2026-01-08 22:50:46 +00:00
2025-02-01 12:16:50 +00:00
2025-12-09 01:38:02 +00:00
2025-10-16 10:56:28 +01:00
2025-12-09 01:38:02 +00:00
2025-12-09 01:38:02 +00:00

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: 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.

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.
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.

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.
  • 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).

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 <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: 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.
  • Replay protection preserved. The bearer path skips the JTI Set (so the same token can be reused) but the Get stays active — RevokeToken still terminates a bearer token immediately.
  • 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 is forwarded from the session.
  • Validation is cookie-only (no JWK fetch), so streaming keeps working during brief IdP outages.

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: <nil>. 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.<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.

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 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.

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.

S
Description
Mirror of github.com/lukaszraczylo/traefikoidc
Readme MIT 5.1 MiB
Languages
Go 99.8%