Files
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

21 KiB

Configuration Reference

Complete reference for all Traefik OIDC middleware configuration options.

Table of Contents


Required Parameters

Parameter Type Description Example
providerURL string Base URL of the OIDC provider https://accounts.google.com
clientID string OAuth 2.0 client identifier 1234567890.apps.googleusercontent.com
clientSecret string OAuth 2.0 client secret. Required when clientAuthMethod is unset, client_secret_post, or client_secret_basic. Optional when clientAuthMethod: private_key_jwt. your-client-secret
sessionEncryptionKey string Key for encrypting session data (min 32 bytes) your-32-byte-encryption-key-here
callbackURL string Path where provider redirects after authentication /oauth2/callback

Basic Configuration Example

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oidc-auth
spec:
  plugin:
    traefikoidc:
      providerURL: https://accounts.google.com
      clientID: your-client-id.apps.googleusercontent.com
      clientSecret: your-client-secret
      sessionEncryptionKey: your-32-byte-encryption-key-here
      callbackURL: /oauth2/callback

Client Authentication

The middleware supports three client authentication methods at the token and revocation endpoints. The default is client_secret_post (current behavior); private_key_jwt is opt-in and backwards compatible.

Method Default Description
client_secret_post yes client_id + client_secret in the request body.
client_secret_basic no RFC 6749 §2.3.1 — client_id + client_secret in the Authorization: Basic header (form-urlencoded then base64); not in the body.
private_key_jwt no RFC 7523 §2.2 — plugin signs a short-lived JWT with a private key and sends it as client_assertion.

Select via clientAuthMethod:

clientAuthMethod: private_key_jwt

client_secret_post

Default. The plugin sends client_id and client_secret as form parameters in the token / revocation request body. No additional configuration required.

private_key_jwt

Asymmetric client authentication per RFC 7523 §2.2. Use this when your IdP enforces short secret TTLs, when policy mandates secretless clients, or when you want to avoid distributing a shared secret to the proxy.

For each token / revocation request the plugin builds a JWS with:

  • iss = sub = clientID
  • aud = token endpoint URL
  • iat = now, exp = now + 60s
  • jti = random hex per request
  • kid header = clientAssertionKeyID

Required fields:

Parameter Type Default Description
clientAuthMethod string client_secret_post Set to private_key_jwt.
clientAssertionPrivateKey string none Inline PEM private key. Mutually exclusive with clientAssertionKeyPath. PKCS#8, PKCS#1, and SEC1 formats accepted.
clientAssertionKeyPath string none Path to PEM private key on disk. Mutually exclusive with clientAssertionPrivateKey.
clientAssertionKeyID string none kid header inserted in the JWS. Must match the public key registered with the IdP.
clientAssertionAlg string RS256 One of RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512.

When clientAuthMethod: private_key_jwt, clientSecret is optional.

Example — inline PEM:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oidc-auth
spec:
  plugin:
    traefikoidc:
      providerURL: https://idp.example.com
      clientID: my-client-id
      sessionEncryptionKey: your-32-byte-encryption-key-here
      callbackURL: /oauth2/callback
      clientAuthMethod: private_key_jwt
      clientAssertionKeyID: key-2026-01
      clientAssertionAlg: RS256
      clientAssertionPrivateKey: |
        -----BEGIN PRIVATE KEY-----
        MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
        MZj4ev7QnMa1mYV3Kx1jRkH5YwXQ7N2J2j8K5pP6h0oZmXq1yQv4r8wZb3sH9D2k
        ... (truncated) ...
        -----END PRIVATE KEY-----

Example — key on disk:

clientAuthMethod: private_key_jwt
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
clientAssertionKeyID: key-2026-01
clientAssertionAlg: RS256

Generating an RS256 key with OpenSSL:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
  -out client-key.pem
openssl rsa -in client-key.pem -pubout -out client-pub.pem

Register client-pub.pem (or its JWK form) with your IdP under the same kid you set in clientAssertionKeyID.

Notes:

  • The private key is parsed once at plugin startup. Key rotation requires a Traefik reload.
  • Assertion lifetime is fixed at 60 seconds.
  • A fresh random jti is generated per request.
  • The aud claim is the token endpoint URL (from discovery).
  • Tracking issue: #135.

client_secret_basic

Per RFC 6749 §2.3.1, the plugin sends the client credentials in an Authorization: Basic header instead of the body. Both halves (client_id, client_secret) are form-urlencoded individually, joined with a colon, then base64-encoded. Use this when your IdP requires Basic auth at the token endpoint and rejects credentials in the body.

clientAuthMethod: client_secret_basic
clientID: your-client-id
clientSecret: your-client-secret

Optional Parameters

Parameter Type Default Description
logoutURL string callbackURL + "/logout" Path for logout requests
postLogoutRedirectURI string / Redirect URL after logout
logLevel string info Logging verbosity (debug, info, error)
forceHTTPS bool true Force HTTPS for redirect URIs (set false only for plaintext HTTP local dev)
rateLimit int 100 Maximum requests per second
excludedURLs []string none Paths that bypass authentication, matched at a path-segment or file-extension boundary
revocationURL string auto-discovered Token revocation endpoint
oidcEndSessionURL string auto-discovered Provider's end session endpoint
enablePKCE bool false Enable PKCE for authorization code flow
minimalHeaders bool false Reduce forwarded headers
clientAuthMethod string client_secret_post Client authentication method at token/revocation endpoints. One of client_secret_post, client_secret_basic, private_key_jwt. See Client Authentication.
clientAssertionPrivateKey string none Inline PEM private key for private_key_jwt. Mutually exclusive with clientAssertionKeyPath. PKCS#8 / PKCS#1 / SEC1.
clientAssertionKeyPath string none Path to PEM private key on disk for private_key_jwt. Mutually exclusive with clientAssertionPrivateKey.
clientAssertionKeyID string none kid header for private_key_jwt assertions. Required when clientAuthMethod: private_key_jwt.
clientAssertionAlg string RS256 Signing algorithm for private_key_jwt. One of RS256/384/512, PS256/384/512, ES256/384/512.

TLS Termination at Load Balancer

forceHTTPS defaults to true, so redirect URIs always use https://. This is the correct default behind any TLS-terminating load balancer (AWS ALB, Google Cloud LB, Azure App Gateway) — X-Forwarded-Proto cannot be trusted (ALB may overwrite it).

Set forceHTTPS: false only when you serve OIDC over plaintext HTTP (local dev). Otherwise leave it at default.

Streaming Endpoints (SSE and WebSocket)

The middleware automatically bypasses the OIDC redirect for two request kinds that browsers cannot follow a 302 on:

Bypass Triggered by
Server-Sent Events (SSE) Accept: text/event-stream
WebSocket upgrade Upgrade: websocket + Connection: upgrade (RFC 6455)

These requests do not require any explicit configuration — they are handled implicitly. However, the bypass is not unauthenticated:

  • A valid, encrypted session cookie is required. Requests without one are rejected (the connection cannot proceed to the backend).
  • The session cookie is sealed with sessionEncryptionKey, so the authenticated flag cannot be forged.
  • Validation is cookie-only — no JWK fetch / signature verification — so streaming endpoints keep working when the OIDC provider is briefly unavailable.
  • The user identifier from the session is forwarded as X-Forwarded-User (and X-Auth-Request-User unless minimalHeaders: true).

For browser clients, the user must complete the normal OIDC flow on a regular HTTP page first; the resulting session cookie is then reused on the SSE / WebSocket connection.


Security Options

Audience Validation

Parameter Type Default Description
audience string clientID Expected audience for access token validation
strictAudienceValidation bool false Reject sessions with audience mismatch
allowOpaqueTokens bool false Enable opaque token support via RFC 7662
requireTokenIntrospection bool false Require introspection for opaque tokens

Production Security Configuration

audience: "https://my-api.example.com"
strictAudienceValidation: true

Opaque Token Support

allowOpaqueTokens: true
requireTokenIntrospection: true
strictAudienceValidation: true

Other Security Options

Parameter Type Default Description
disableReplayDetection bool false Disable JTI-based replay attack detection
allowPrivateIPAddresses bool false Allow private IPs in provider URLs

Bearer-token (M2M) authentication

Opt-in path that accepts Authorization: Bearer <jwt> instead of the cookie session flow. M2M-only, default off, audience-mandatory. See docs/BEARER_AUTH.md for the threat model and operational guidance.

Parameter Type Default Description
enableBearerAuth bool false Master switch. Startup fails if true with empty audience or with bearerIdentifierClaim=email.
bearerIdentifierClaim string "sub" JWT claim used as the principal identifier. "email" is rejected at startup.
stripAuthorizationHeader bool true Strip Authorization from forwarded requests after successful bearer auth.
bearerEmitWWWAuthenticate bool true Emit RFC 6750 WWW-Authenticate: Bearer error="..." hints on 401.
bearerOverridesCookie bool false Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins.
maxTokenAgeSeconds int64 86400 Upper bound on iat claim age (24h). 0 disables the check.
maxIdentifierLength int 256 Length cap on the sanitised principal identifier.
bearerFailureThreshold int 20 Consecutive 401s from one source IP that trip the throttle.
bearerFailureWindowSeconds int 60 Rolling window for counting 401s.
bearerFailurePenaltySeconds int 60 429 + Retry-After duration after the threshold trips.

Session Management

Parameter Type Default Description
sessionMaxAge int 86400 (24h) Maximum session age in seconds
refreshGracePeriodSeconds int 60 Seconds before expiry to attempt refresh
maxRefreshTokenAgeSeconds int 21600 Heuristic max age (in seconds) of a stored refresh token. Once exceeded, requests treat the RT as expired up front (returns 401 to AJAX, triggers full re-auth on navigations) instead of grant-spamming the IdP with invalid_grant retries. IdPs do not advertise RT TTL on the wire, so this is intentionally a conservative heuristic — tune to match your provider. Set 0 to disable. Default 21600 (6h).
cookieDomain string auto-detected Domain for session cookies
cookiePrefix string _oidc_raczylo_ Prefix for cookie names

Multi-Subdomain Setup

cookieDomain: .example.com  # Share cookies across subdomains

Multiple Middleware Instances

When running multiple middleware instances with different authorization requirements, use unique prefixes:

# User authentication middleware
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oidc-userauth
spec:
  plugin:
    traefikoidc:
      cookiePrefix: "_oidc_userauth_"
      sessionEncryptionKey: user-encryption-key-min-32-bytes
      # ... other config
---
# Admin authentication middleware
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oidc-adminauth
spec:
  plugin:
    traefikoidc:
      cookiePrefix: "_oidc_adminauth_"
      sessionEncryptionKey: admin-encryption-key-min-32-bytes
      allowedUsers:
        - admin@example.com
      # ... other config

Extended Session Duration

sessionMaxAge: 604800  # 7 days
# Common values:
# 3600     - 1 hour (high security)
# 86400    - 1 day (default)
# 259200   - 3 days
# 604800   - 7 days
# 2592000  - 30 days

Access Control

User Restrictions

Parameter Type Description
allowedUserDomains []string Restrict to specific email domains
allowedUsers []string Specific email addresses allowed
allowedRolesAndGroups []string Required roles or groups
roleClaimName string JWT claim for roles (default: roles)
groupClaimName string JWT claim for groups (default: groups)
userIdentifierClaim string Claim for user ID (default: email)

Domain Restriction

allowedUserDomains:
  - company.com
  - subsidiary.com

Specific User Access

allowedUsers:
  - user@example.com
  - contractor@external.org

Role-Based Access Control

allowedRolesAndGroups:
  - admin
  - developer
roleClaimName: "https://myapp.com/roles"  # For namespaced claims (Auth0)

Access Control Logic

  • If only allowedUsers is set: Only specified emails can access
  • If only allowedUserDomains is set: Only specified domains can access
  • If both are set: Access granted if email is in allowedUsers OR domain is in allowedUserDomains
  • If neither is set: Any authenticated user can access

Users Without Email (Azure AD)

For Azure AD service accounts or users without email:

userIdentifierClaim: sub  # Options: sub, oid, upn, preferred_username
allowedUsers:
  - "abc12345-6789-0abc-def0-123456789abc"  # User object ID

Headers Configuration

Default Headers

The middleware sets these headers for downstream services:

Header Description
X-Forwarded-User User's email address
X-User-Groups Comma-separated user groups
X-User-Roles Comma-separated user roles
X-Auth-Request-Redirect Original request URI
X-Auth-Request-User User's email address
X-Auth-Request-Token User's ID token

Minimal Headers Mode

For "431 Request Header Fields Too Large" errors:

minimalHeaders: true  # Only forwards X-Forwarded-User

Custom Templated Headers

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

Template Variables:

  • {{.Claims.field}} - ID token claims
  • {{.AccessToken}} - Raw access token
  • {{.IdToken}} - Raw ID token
  • {{.RefreshToken}} - Raw refresh token

Important: Use double curly braces ({{{{ and }}}}) to escape templates in YAML.


Security Headers

Security Profiles

Profile Use Case Security Level
default Standard web apps High
strict Maximum security Very High
development Local development Medium
api API endpoints High
custom Custom requirements Configurable

Basic Configuration

securityHeaders:
  enabled: true
  profile: "default"

API with CORS

securityHeaders:
  enabled: true
  profile: "api"
  corsEnabled: true
  corsAllowedOrigins:
    - "https://your-frontend.com"
    - "https://*.example.com"
  corsAllowCredentials: true

Custom Security Configuration

securityHeaders:
  enabled: true
  profile: "custom"

  # Content Security Policy
  contentSecurityPolicy: "default-src 'self'; script-src 'self'"

  # HSTS
  strictTransportSecurity: true
  strictTransportSecurityMaxAge: 31536000
  strictTransportSecuritySubdomains: true
  strictTransportSecurityPreload: true

  # Frame and Content Protection
  frameOptions: "DENY"
  contentTypeOptions: "nosniff"
  xssProtection: "1; mode=block"
  referrerPolicy: "strict-origin-when-cross-origin"

  # CORS
  corsEnabled: true
  corsAllowedOrigins: ["https://app.example.com"]
  corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
  corsAllowedHeaders: ["Authorization", "Content-Type"]
  corsAllowCredentials: true
  corsMaxAge: 86400

  # Custom Headers
  customHeaders:
    X-Custom-Header: "value"

  # Server Identification
  disableServerHeader: true
  disablePoweredByHeader: true

CORS Origin Patterns

corsAllowedOrigins:
  - "https://example.com"        # Exact match
  - "https://*.example.com"      # Subdomain wildcard
  - "http://localhost:*"         # Port wildcard (development)

Scope Configuration

Default Behavior (Append Mode)

scopes:
  - roles
  - custom_scope
# Result: ["openid", "profile", "email", "roles", "custom_scope"]

Override Mode

overrideScopes: true
scopes:
  - openid
  - profile
  - custom_scope
# Result: ["openid", "profile", "custom_scope"]

Advanced Options

Dynamic Client Registration (RFC 7591)

Dynamic Client Registration allows the middleware to automatically register itself with the OIDC provider, eliminating the need to manually create client credentials.

Basic Configuration (Single Instance):

dynamicClientRegistration:
  enabled: true
  initialAccessToken: "your-token"  # Optional, if provider requires it
  persistCredentials: true
  credentialsFile: "/tmp/oidc-credentials.json"
  clientMetadata:
    redirect_uris:
      - "https://your-app.com/oauth2/callback"
    client_name: "My Application"
    application_type: "web"
    grant_types:
      - "authorization_code"
      - "refresh_token"

Multi-Replica Deployment (Kubernetes):

For Kubernetes deployments with multiple replicas, use Redis storage to share credentials across all instances and prevent registration race conditions:

dynamicClientRegistration:
  enabled: true
  persistCredentials: true
  storageBackend: "redis"  # Share credentials via Redis
  redisKeyPrefix: "myapp:dcr:"  # Optional custom prefix
  clientMetadata:
    redirect_uris:
      - "https://your-app.com/oauth2/callback"
    client_name: "My Application"

redis:
  enabled: true
  address: "redis:6379"
  cacheMode: "redis"

Storage Backend Options:

Backend Description Use Case
file Store credentials in local file Single instance deployments
redis Store credentials in Redis Multi-replica Kubernetes deployments
auto Use Redis if available, fallback to file Flexible deployments (default)

Multi-Replica Deployment

Without Redis, disable replay detection:

disableReplayDetection: true

With Redis (recommended):

redis:
  enabled: true
  address: "redis:6379"
  cacheMode: "hybrid"

See REDIS.md for complete Redis configuration.


Kubernetes Secrets

Reference secrets instead of hardcoding sensitive values:

providerURL: urn:k8s:secret:oidc-secret:ISSUER
clientID: urn:k8s:secret:oidc-secret:CLIENT_ID
clientSecret: urn:k8s:secret:oidc-secret:SECRET

Create the secret:

kubectl create secret generic oidc-secret \
  --from-literal=ISSUER=https://accounts.google.com \
  --from-literal=CLIENT_ID=your-client-id \
  --from-literal=SECRET=your-client-secret \
  -n traefik

Environment Variable Naming

Important: Avoid using "API" as a substring in environment variable names when using ${VAR} syntax in Traefik configuration. Traefik reserves TRAEFIK_API_* variables and the substring may cause conflicts.

# Bad - may cause issues
sessionEncryptionKey: ${OIDC_SECRET_API}

# Good
sessionEncryptionKey: ${OIDC_SECRET_SVC}