A home-lab server rack under warm light

What My Self-Hosted Keycloak + oauth2-proxy Setup Taught Me About Azure Entra ID

I can reproduce every OIDC gotcha in my home lab before Microsoft support would open the ticket.

I can reproduce nearly every Entra ID / Azure AD B2C gotcha in my home lab before Microsoft support would even open a ticket. Keycloak + oauth2-proxy is that good a mirror if you wire it the way a commercial IdP actually works.

This is three specific patterns I've hit in self-hosted SSO that map directly to enterprise OIDC, with the one-setting fixes and the mental model that generalizes.

Gotcha 1: the return-domain whitelist everyone forgets

oauth2-proxy in reverse-proxy mode sets WHITELIST_DOMAINS. If you're running under .calegix.com and set OAUTH2_PROXY_WHITELIST_DOMAINS=calegix.com (no leading dot), subdomains don't match. A user authenticating at mautic.calegix.com gets bounced to the OIDC provider, authenticates, comes back with ?rd=https://*.calegix.com/... and oauth2-proxy refuses the redirect because *.calegix.com isn't in the whitelist.

The fix is literally one character:

# Wrong - only matches calegix.com exactly
OAUTH2_PROXY_WHITELIST_DOMAINS=calegix.com

# Right - matches any subdomain
OAUTH2_PROXY_WHITELIST_DOMAINS=.calegix.com

Entra ID has the same shape of trap. The RedirectUris / ReplyUrls array accepts exact matches only, no wildcard by default. To cover subdomains in an enterprise app registration you either list every subdomain explicitly or use Entra's 'App ID URI' patterns with care.

The mental model: 'trust this identity provider for this domain' is never 'and its subdomains' unless you explicitly say so. Every IdP tightens this over time because wildcards are a common source of open-redirect vulns.

Gotcha 2: two header families, one semantic meaning

oauth2-proxy emits identity headers in two different families depending on mode:

- Reverse-proxy mode (--pass-user-headers) emits X-Forwarded-User, X-Forwarded-Email, X-Forwarded-Groups, X-Forwarded-Preferred-Username.

- Forward-auth mode (--set-xauthrequest) emits X-Auth-Request-User, X-Auth-Request-Email, X-Auth-Request-Groups, X-Auth-Request-Preferred-Username.

These carry the same information. They just have different names.

If your downstream service hardcodes the X-Forwarded-* family and you later move it behind a forward-auth middleware (because you started also routing through Traefik's forwardAuth), identity vanishes and the service starts 401-ing inexplicably.

The fix: make your downstream accept both families. Traefik's forwardAuth config makes this explicit:

svc-auth:
  forwardAuth:
    address: 'http://127.0.0.1:4180'
    trustForwardHeader: true
    authResponseHeaders:
      - X-Forwarded-User
      - X-Forwarded-Email
      - X-Forwarded-Groups
      - X-Forwarded-Preferred-Username
      - X-Auth-Request-User
      - X-Auth-Request-Email
      - X-Auth-Request-Groups
      - X-Auth-Request-Preferred-Username

And in the downstream code, read either:

const email =
  req.headers.get("X-Forwarded-Email") ||
  req.headers.get("X-Auth-Request-Email") ||
  null;

Entra ID equivalent: the X-MS-CLIENT-PRINCIPAL headers emitted by Azure App Service's built-in auth vs. the headers a downstream service expects when you're routing through Application Gateway with custom header mapping. Different header names, same job. Accept both.

Gotcha 3: SameSite=None on session cookies for iframe-visible flows

KC's AUTH_SESSION_ID cookie defaults to SameSite=Lax in some realms, SameSite=None Secure in others depending on version. If you need an iframe embedded on origin A to complete an OIDC redirect through the IdP on origin B and come back, the IdP's session cookie has to ride across that redirect. Lax-site cookies sometimes don't (Chrome in particular has gotten stricter here year over year).

The fix: force SameSite=None on session cookies. In KC this is realm-level:

In the KC admin UI:
  Realm Settings โ†’ Advanced โ†’ Cookie SameSite โ†’ None

Or via kcadm.sh:
  kcadm.sh update realms/primary -s 'attributes."cookieSameSite"=None'

Entra ID equivalent: the 'cookie policy' in B2C custom policies, and the 'allowed redirect URIs' trickery you do to keep cross-origin iframe flows functional. Same mental model: the session cookie has to be allowed to ride cross-site, which means SameSite=None + Secure.

Bonus: PKCE is non-negotiable, but S256 is the only choice

oauth2-proxy's config:

OAUTH2_PROXY_CODE_CHALLENGE_METHOD=S256

PKCE with plain (not S256) is a downgrade attack waiting to happen. Modern IdPs require S256. If you ever see code_challenge_method=plain in your auth logs, something upstream is misconfigured.

This isn't enterprise-specific - it's 2026 OIDC hygiene. But enterprise IdPs are where I see this missed most often, because legacy apps from 2017 assumed plain.

Why the home lab mirrors matter

Enterprise identity systems - Entra ID, Okta, Ping are slow to experiment with. You can't freely delete a test tenant. You can't debug what's in a session by dumping a cookie table. You especially can't reset a user's state without waiting for a rotation window.

Self-hosted KC + oauth2-proxy with the same underlying OIDC + forward-auth pattern lets me:

- Delete and recreate realms in 30 seconds.

- Dump the DB and inspect AUTH_SESSION_ID lifetimes directly.

- Reproduce a failing flow three times in a row before the vendor's support chat opens.

Every enterprise OIDC issue I've had in the day job has been debuggable in my home lab first. The patterns match because the specs match.

Investment thesis

If you're an IT manager or platform engineer about to roll out Entra ID at scale, give yourself one weekend to build the same thing self-hosted. Hit every gotcha here. Break it in ways you can't break the production tenant. When Microsoft's docs tell you 'this setting affects cross-origin behavior,' you'll know exactly which cookie and exactly which flow they mean.

The home lab isn't the production system. It's the training ground.

Related posts

No comments yet