Put Keycloak in Front of Internal Apps with oauth2-proxy and Nginx

Markaretta DofourMarkaretta Dofour17 min read

To protect an internal app with Keycloak, run oauth2-proxy as an OpenID Connect client of your realm and have Nginx call it through an auth_request subrequest on every request. A request without a session gets a 401 from the subrequest, so Nginx redirects the browser to the Keycloak login page; a request with a valid session gets a 202, and Nginx passes it to the app with the user’s name and email in headers. The app needs no code change, and the whole stack fits in one Docker Compose file.

This post is for platform and IT engineers who run Keycloak 26.x and want pre-authentication in front of something that cannot log users in by itself: an internal dashboard, a metrics UI, a static site, a legacy tool. We give one complete, runnable lab, then cover the three places teams get stuck. If you are here because of the Entra application proxy vulnerability published on 22 October 2026, the first section explains why that bug is a good reason to know what your own pre-authentication layer does.

Key Takeaways

  • CVE-2026-97785, published on 22 October 2026, describes improper authentication in Microsoft’s Entra application proxy (AAD App Proxy) that lets an unauthorized attacker elevate privileges over a network (NVD and its API record).
  • Nginx auth_request sends a subrequest to oauth2-proxy at /oauth2/auth, which answers 202 for a valid session and 401 otherwise.
  • The Keycloak client is confidential, uses PKCE with S256, and carries an audience mapper and a groups mapper.
  • Start oauth2-proxy only after the realm exists, and shrink the token or split the cookie if the session cookie passes 4KB.

Why look at this now: CVE-2026-97785

The NVD entry for CVE-2026-97785 showing the critical CVSS 3.1 base score of 10.0 for Microsoft Entra application proxy

On 22 October 2026 the National Vulnerability Database published CVE-2026-97785 with the description “Improper authentication in AAD App Proxy allows an unauthorized attacker to elevate privileges over a network” (NVD, CVE-2026-97785; the same record is readable as plain JSON from the NVD API). The affected product is Microsoft Entra application proxy, which is Microsoft’s own pre-authentication layer for on-premises web apps. Its documentation says, under “Security benefits”, that it “blocks all traffic without a valid token for applications configured with preauthentication”, and, in its overview, that “You don’t need to change or update your applications to work with application proxy” (Microsoft Learn, Publish on-premises apps with Microsoft Entra application proxy). An authentication flaw in that layer is a flaw in the one thing standing between the network and the apps it publishes.

For what to do about the bug itself, follow Microsoft’s guidance for the service and check the NVD record as it is updated (NVD, CVE-2026-97785). The broader point does not depend on those details: a pre-authentication layer you cannot inspect is a layer you cannot verify, and the alternative, a reverse proxy and an OIDC client you run and read the logs of, is small enough to keep in one file.

The rest of this post builds that alternative. It is not an argument that Entra is worse than Keycloak (for that comparison, see Keycloak vs Microsoft Entra ID: The Self-Hosted Case). It is the setup you need when the identity provider is Keycloak and the app in front of it has no login of its own.

How the three pieces fit together

Nginx has a module, ngx_http_auth_request_module, that authorizes a request based on the result of a subrequest: “If the subrequest returns a 2xx response code, the access is allowed. If it returns 401 or 403, the access is denied with the corresponding error code”, and any other code is treated as an error (nginx.org, ngx_http_auth_request_module). The directive auth_request_set “Sets the request $variable to the given value after the authorization request completes”, and that value may use variables from the subrequest such as $upstream_http_*, which is how Nginx picks up a header from oauth2-proxy and forwards it to the app (nginx.org, ngx_http_auth_request_module).

oauth2-proxy is the thing Nginx asks. Its documentation describes it as “a reverse proxy and static file server that provides authentication using Providers (Google, GitHub, and others) to validate accounts by email, domain or group” (oauth2-proxy docs), and by default it keeps the user’s session in a cookie, where the cookie-secret “will ensure data is encrypted within the cookie data” (oauth2-proxy docs, session storage). Its /oauth2/auth endpoint “only returns a 202 Accepted response or a 401 Unauthorized response without proxying the request through”, which is exactly what auth_request wants, and with the --set-xauthrequest flag it adds X-Auth-Request-User, X-Auth-Request-Groups, X-Auth-Request-Email and X-Auth-Request-Preferred-Username to that response (oauth2-proxy docs, Nginx integration; configuration overview). Keycloak is the identity provider oauth2-proxy talks to, through its keycloak-oidc provider (oauth2-proxy docs, Keycloak OIDC).

An Nginx auth_request round trip through oauth2-proxy and Keycloak

The browser asks Nginx for a page (1), the subrequest to oauth2-proxy answers 401 because there is no session cookie (2), Nginx redirects the browser to the Keycloak login page (3), Keycloak sends the browser back to the callback and oauth2-proxy sets its cookie (4), the next subrequest answers 202 with the user’s name and email (5), and Nginx passes the request to the app with that identity in headers (6).

The app sees only requests that passed the subrequest, and it sees who the user is in a header Nginx set. That is the whole contract, and it is why the app needs no OIDC library, no session code and no change at all.

The lab: what you need and what the host names mean

The lab runs five containers: Postgres, Keycloak 26.8.1, oauth2-proxy v7.15.5, Nginx, and a tiny “internal app” (the traefik/whoami image, a “Tiny Go webserver that prints OS information and HTTP request to output” per its GitHub README), so you can see the identity headers arrive. Keycloak 26.8.1 is the current release at the time of writing (Keycloak releases on GitHub), as is oauth2-proxy v7.15.5 (oauth2-proxy releases on GitHub).

It uses two host names, both served by the same Nginx:

  • sso.example.test is Keycloak. The browser uses it to log in, and oauth2-proxy uses it to discover the realm and redeem the authorization code.
  • app.example.test is the protected app. Everything under /oauth2/ on it goes to oauth2-proxy; everything else is checked with auth_request and then proxied to the app.

To keep the lab runnable on a laptop, it speaks plain HTTP on port 80. Add both names to your hosts file pointing at 127.0.0.1, and read the production notes at the end before you put anything real behind it. You need Docker with Compose and a shell; you need no credentials from anyone.

Step 1: the Keycloak client

Start Postgres, Keycloak, the app and Nginx first, but not oauth2-proxy yet (step 4 gives the exact command), because oauth2-proxy finds Keycloak’s endpoints through OIDC discovery on the realm’s issuer URL (the reference’s skip-oidc-discovery option is the way to turn that off, oauth2-proxy docs, configuration overview), and the realm does not exist until you create it here. Open http://sso.example.test/admin/, log in with the bootstrap admin, create a realm called internal, and in it a client:

  • Client type OpenID Connect, Client ID internal-app.
  • Client authentication On, so the client is confidential and has a secret, which oauth2-proxy needs to redeem the code.
  • Authentication flow: Standard flow on, Direct access grants off.
  • Valid redirect URIs http://app.example.test/oauth2/callback. That path is oauth2-proxy’s callback, not the app’s.
  • Set the PKCE challenge method to S256 (Proof Key for Code Exchange Code Challenge Method on the Advanced tab; for more on PKCE clients, see Keycloak: How To Create A PKCE Authorization Flow Client?). Here it routes this client’s authorization-code requests through PKCE even though the client also has a secret, as the PKCE guide linked above describes for the same setting (labelled PKCE method in current consoles).

Copy the secret from the Credentials tab. Then add two mappers to the client’s dedicated scope (Clients, internal-app, Client scopes, internal-app-dedicated, Configure a new mapper):

  • Audience, with Included Client Audience set to internal-app, added to the ID token and the access token. The oauth2-proxy docs are explicit that the client “must be configured with an audience mapper to include the client’s name in the aud claim”, because oauth2-proxy checks that claim against its own --client-id (oauth2-proxy docs, Keycloak OIDC).
  • Group Membership, with Token Claim Name groups and Full group path on, added to the ID token. With the full path on, the group names you allow in oauth2-proxy start with a slash, like /platform (oauth2-proxy docs, Keycloak OIDC).

Finally create a group platform under Groups, create a user with an email address, and add the user to the group. Only members of that group will get through the proxy.

Step 2: the oauth2-proxy configuration

oauth2-proxy “can be configured via command line options, environment variables or config file”. The reference tables list each option’s flag and config file (TOML) name (oauth2-proxy docs, configuration overview); in our setup, the environment variable for each option is the flag with an OAUTH2_PROXY_ prefix, capitalised, with hyphens turned into underscores. The lab uses environment variables in Compose so nothing hides in a second file. These are the ones that matter:

Setting Value in the lab Why
provider keycloak-oidc The Keycloak-aware OIDC provider, which also understands allowed-role (Keycloak OIDC)
oidc-issuer-url http://sso.example.test/realms/internal The realm’s issuer; the /realms/ path without /auth/ is for Keycloak 17 and later (Keycloak OIDC)
client-id, client-secret internal-app and its secret The client from step 1
redirect-url http://app.example.test/oauth2/callback Must match the client’s valid redirect URI
code-challenge-method S256 PKCE, “recommended” by the reference (overview)
allowed-group /platform Only this Keycloak group gets in
email-domain * Group membership is the gate here; the reference says “Use * to authenticate any email” (overview)
upstream static://202 Nginx does the proxying here, so oauth2-proxy’s upstream uses the static:// form, which the reference lists for a “static response” with the status code you name, and 202 is the code auth_request accepts (overview)
reverse-proxy true Required for the Nginx integration (Nginx integration)
trusted-proxy-ip the Compose network, 172.28.0.0/16 The reference describes it as the “list of direct proxy IPs or CIDR ranges allowed to supply X-Forwarded-* headers when --reverse-proxy is enabled” (overview)
set-xauthrequest true Puts the user, email and groups on the auth response for auth_request_set (overview)
cookie-secret 32 random bytes The seed for the encrypted session cookie; the file variant “must be raw binary, exactly 16, 24, or 32 bytes” (overview)
cookie-secure false in the lab, true anywhere real The lab is plain HTTP
cookie-refresh 4m Refreshes the session with Keycloak after this long; the reference notes that not every provider supports it, so test it with yours (overview)
skip-provider-button true The reference says it “will skip sign-in-page to directly reach the next step: oauth/start”, so the browser goes straight to Keycloak (overview)

Generate the cookie secret once, and keep it out of the Compose file. The reference asks for “the seed string for secure cookies (optionally base64 encoded)” (overview); this is the documented command from that page: it produces 32 random bytes in URL-safe base64 (the last tr swaps + and / for - and _), and you paste the output into .env as OAUTH2_PROXY_COOKIE_SECRET:

dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d -- '\n' | tr -- '+/' '-_'

Somewhere to run this that stays patched

Everything above works the same on Skycloak, because it is real upstream Keycloak rather than a fork. What changes is who handles the upgrades, backups and security patches afterwards.

Step 3: the Nginx server blocks

The app.example.test block is the one from the oauth2-proxy documentation, trimmed to what the lab needs and with the upstream names of the Compose file. The structure is three locations: /oauth2/ goes to oauth2-proxy for the login start, the callback and sign-out; /oauth2/auth is the subrequest target, with the request body stripped because “nginx auth_request includes headers but not body”; and / is the protected app (oauth2-proxy docs, Nginx integration).

server {
  listen 80;
  server_name app.example.test;

  location /oauth2/ {
    proxy_pass       http://oauth2-proxy:4180;
    proxy_set_header Host                    $host;
    proxy_set_header X-Real-IP               $remote_addr;
    proxy_set_header X-Forwarded-Proto       $scheme;
    proxy_set_header X-Auth-Request-Redirect $request_uri;
  }

  location = /oauth2/auth {
    proxy_pass       http://oauth2-proxy:4180;
    proxy_set_header Host             $host;
    proxy_set_header X-Real-IP        $remote_addr;
    proxy_set_header X-Forwarded-Uri  $request_uri;
    # nginx auth_request includes headers but not body
    proxy_set_header Content-Length   "";
    proxy_pass_request_body           off;
  }

  location / {
    auth_request /oauth2/auth;
    error_page 401 = @oauth2_signin;

    # identity headers for the app, set by --set-xauthrequest
    auth_request_set $user   $upstream_http_x_auth_request_user;
    auth_request_set $email  $upstream_http_x_auth_request_email;
    auth_request_set $groups $upstream_http_x_auth_request_groups;
    proxy_set_header X-User   $user;
    proxy_set_header X-Email  $email;
    proxy_set_header X-Groups $groups;

    # needed for --cookie-refresh to work through auth_request
    auth_request_set $auth_cookie $upstream_http_set_cookie;
    add_header Set-Cookie $auth_cookie;

    proxy_pass http://app:80/;
  }

  location @oauth2_signin {
    return 302 /oauth2/start?rd=$scheme://$host$request_uri;
  }
}

Two details are worth a sentence each. The 401 handler is a named location that returns a real 302, because the older pattern error_page 401 =403 /oauth2/sign_in answers with a 403 and a Location header, and “Browsers do not automatically follow redirects on 403 responses”, which with skip-provider-button leaves users looking at a bare “Found.” link (oauth2-proxy docs, Nginx integration). And the redirect target here is /oauth2/start rather than /oauth2/sign_in: the endpoints reference describes /oauth2/sign_in as “the login page” and /oauth2/start as “a URL that will redirect to start the OAuth cycle”, so going to /oauth2/start does not show the page in between; both work (oauth2-proxy docs, Endpoints).

The sso.example.test block is a plain reverse proxy in front of Keycloak with the forwarding headers Keycloak reads. If you have never put Keycloak behind Nginx, see How to Run Keycloak Behind a Reverse Proxy?. The short version is that Keycloak needs to know the scheme and host the browser used, or every URL it generates will be wrong.

server {
  listen 80;
  server_name sso.example.test;

  location / {
    proxy_pass         http://keycloak:8080;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   X-Forwarded-Host  $host;
    proxy_set_header   X-Forwarded-Port  $server_port;
    proxy_buffer_size          128k;
    proxy_buffers              4 256k;
    proxy_busy_buffers_size    256k;
  }
}

Put both blocks in one file, nginx/default.conf, and the Compose file mounts it.

Step 4: the Docker Compose file

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env}
    volumes:
      - pgdata:/var/lib/postgresql/data

  keycloak:
    image: quay.io/keycloak/keycloak:26.8.1
    command: start
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:-admin}
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set it in .env}
      KC_HOSTNAME: http://sso.example.test
      KC_HTTP_ENABLED: "true"
      KC_PROXY_HEADERS: xforwarded
      KC_PROXY_TRUSTED_ADDRESSES: 172.28.0.0/16
    depends_on:
      - postgres

  oauth2-proxy:
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
    environment:
      OAUTH2_PROXY_PROVIDER: keycloak-oidc
      OAUTH2_PROXY_OIDC_ISSUER_URL: http://sso.example.test/realms/internal
      OAUTH2_PROXY_CLIENT_ID: internal-app
      OAUTH2_PROXY_CLIENT_SECRET: ${OAUTH2_PROXY_CLIENT_SECRET:?paste the client secret in .env}
      OAUTH2_PROXY_REDIRECT_URL: http://app.example.test/oauth2/callback
      OAUTH2_PROXY_CODE_CHALLENGE_METHOD: S256
      OAUTH2_PROXY_ALLOWED_GROUPS: /platform
      OAUTH2_PROXY_EMAIL_DOMAINS: "*"
      OAUTH2_PROXY_UPSTREAMS: static://202
      OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
      OAUTH2_PROXY_REVERSE_PROXY: "true"
      OAUTH2_PROXY_TRUSTED_PROXY_IPS: 172.28.0.0/16
      OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
      OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET:?run the dd command and paste it in .env}
      OAUTH2_PROXY_COOKIE_SECURE: "false"
      OAUTH2_PROXY_COOKIE_REFRESH: 4m
      OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
    extra_hosts:
      - "sso.example.test:host-gateway"
    depends_on:
      - keycloak

  app:
    image: traefik/whoami

  nginx:
    image: nginx:1.27
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - keycloak
      - oauth2-proxy
      - app

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/16

volumes:
  pgdata:

The extra_hosts line is the one non-obvious part. oauth2-proxy must reach sso.example.test at the same address the browser uses, because that is the issuer it discovers and validates tokens against, so the line sends that name to the Docker host, where Nginx listens on port 80 and proxies to Keycloak. The subnet is pinned so that both KC_PROXY_TRUSTED_ADDRESSES and OAUTH2_PROXY_TRUSTED_PROXY_IPS can name it. For a fuller Keycloak service definition, see Keycloak Docker Compose: From Development to Production; the Keycloak block itself can also come from the Docker Compose generator, which at the time of writing offers versions up to 26.6.3, so change its image tag to 26.8.1 after generating to match this lab.

The Compose file reads four values from .env. The first two you choose, the client secret comes from step 1, and the cookie secret from step 2:

POSTGRES_PASSWORD=choose-one
KC_ADMIN_PASSWORD=choose-one
OAUTH2_PROXY_CLIENT_SECRET=paste-the-secret-from-the-Credentials-tab
OAUTH2_PROXY_COOKIE_SECRET=paste-the-output-of-the-dd-command

The order matters. oauth2-proxy fetches the realm’s discovery document when it starts, and Keycloak is only reachable through Nginx, so start everything except oauth2-proxy, create the realm and client from step 1, then start oauth2-proxy:

docker compose up -d postgres keycloak app nginx
# now create the realm, client, mappers, group and user from step 1
docker compose up -d oauth2-proxy
docker compose logs -f oauth2-proxy

A healthy oauth2-proxy log ends with it listening on 0.0.0.0:4180 after a successful discovery of the issuer. Open http://app.example.test/ in a private window. You should land on the Keycloak login page for the internal realm, sign in as the user you put in the platform group, and come back to the whoami page. The response lists the headers the app received, and X-User, X-Email and X-Groups are in it. Sign in as a user outside the group and oauth2-proxy answers 403 after login, because only the platform group is allowed.

Where people get stuck

The issuer does not match, or Keycloak redirects to the wrong host

oauth2-proxy fetches the realm’s discovery document from the issuer URL and compares the iss claim in every token to it. Keycloak builds that claim from its hostname settings, so the lab sets KC_HOSTNAME to the full public URL, which gives the browser and oauth2-proxy the same issuer; the hostname guide covers the options (keycloak.org, Configuring the hostname (v2)).

The second half is the proxy headers. Keycloak only believes X-Forwarded-Proto and friends when proxy-headers is set, and the reverse proxy guide warns to restrict who may send them: “To ensure that proxy headers are used only from proxies you trust, set the proxy-trusted-addresses option”, because otherwise “rogue clients can inject false values” (keycloak.org, Using a reverse proxy). The same guide says that with TLS ended at the proxy, “enabling HTTP through the http-enabled setting is required”, which is why the Keycloak block has both KC_HTTP_ENABLED and KC_PROXY_HEADERS (keycloak.org, Using a reverse proxy). If you want to see what Keycloak thinks its own URLs are, start it with --hostname-debug=true and open /realms/internal/hostname-debug (keycloak.org, Configuring the hostname (v2)).

If oauth2-proxy must reach Keycloak over a private network under a different name than the browser uses, Keycloak has hostname-backchannel-dynamic for exactly that: “Set to true if your application accesses Keycloak via a private network” (keycloak.org, Configuring the hostname (v2)). The lab avoids the question by sending oauth2-proxy through the same Nginx as the browser.

oauth2-proxy stores the whole session, including the tokens Keycloak issued, in its cookie by default. The Nginx integration page explains the consequence: “Large OIDC tokens can make the session cookie exceed the 4KB limit, causing OAuth2 Proxy to split the cookie into multiple parts”, and “Nginx normally only copies the first Set-Cookie header from the auth_request to the response”, so the second part never reaches the browser and the user is sent to log in again on every request (oauth2-proxy docs, Nginx integration). The documented fix is to extract $upstream_cookie__oauth2_proxy_1 with a second auth_request_set and emit both cookies; the full snippet is on that page. For consistently large sessions the same page recommends --session-store-type=redis, “so only a small session ticket is stored in the browser cookie”.

The better fix is to shrink the token. Keycloak tokens grow with realm roles, client roles and group paths, and a client with “Full Scope Allowed” on puts the user’s realm and client roles in the token, as Fixing Keycloak’s 431 ‘Request Header Fields Too Large’ explains. Turn off full scope on internal-app, and keep only the mappers the proxy needs; that post has more on shrinking tokens.

Users are logged out at odd times, or never

Two clocks run at once. oauth2-proxy’s cookie has its own expiry and refresh interval, and Keycloak has the realm’s SSO session idle and max timeouts. With cookie-refresh set, oauth2-proxy refreshes the session with Keycloak after that interval (oauth2-proxy docs, configuration overview). Without it, the cookie alone decides, for as long as cookie-expire says. Pick the refresh interval below your access token lifespan and keep the realm’s idle timeout the way you would for any other client (for the realm side, see Keycloak Session Timeout Best Practices: Idle, Max, Refresh). Remember also that the add_header Set-Cookie $auth_cookie line in the Nginx block is what lets a refreshed cookie reach the browser through auth_request; without it, refreshes silently fail (oauth2-proxy docs, Nginx integration).

Before you put anything real behind it

The lab is plain HTTP so that it runs anywhere. For a real internal app:

  • Terminate TLS in Nginx for both host names, set OAUTH2_PROXY_COOKIE_SECURE to true, and switch the issuer, redirect URL and KC_HOSTNAME to https://.
  • Do not expose Keycloak’s admin paths through the public server block. The reverse proxy guide covers which paths to expose (keycloak.org, Using a reverse proxy).
  • Make the app trust the identity headers only from Nginx. Put the app on a network that only Nginx can reach, and have Nginx overwrite X-User, X-Email and X-Groups on every request as the block above does, so a client cannot supply them.
  • Give API routes a plain 401 instead of a redirect. The oauth2-proxy docs say the 302 to sign-in “should only be used for browser-facing routes” and show a second location /api/ with auth_request and no error_page (oauth2-proxy docs, Nginx integration).
  • Rotate the client secret through Keycloak and reload it with client-secret-file: the docs say you “can use the client-secret-file option to reload the secret when it is updated” (oauth2-proxy docs, Nginx integration).

If your edge is Traefik rather than Nginx, see Keycloak + Traefik: Reverse Proxy Authentication Setup, and for another pre-authentication option, see Keycloak as an Identity Provider for Cloudflare Access.

Frequently asked questions

Does the app really need no change? For a browser-facing app, none: Nginx only forwards requests that passed the subrequest, and the user’s identity arrives in headers the app may read or ignore. The app does need to stop trusting those headers from anywhere but Nginx, which is a network boundary, not a code change.

Why both a client secret and PKCE? The secret proves to Keycloak that the token request comes from oauth2-proxy. PKCE ties the authorization code to a random “code verifier” that the client made for that one login and presents at the token endpoint, so that the server “can perform the proof of possession” and a code captured in transit cannot be redeemed without it (RFC 7636). oauth2-proxy’s reference recommends S256, and Keycloak can require it per client through the setting in step 1, so turning both on costs nothing.

Can oauth2-proxy do the proxying itself, without Nginx? Yes: give it the app’s URL as --upstream instead of static://202; the reference says it can “pass requests on to HTTP(S) servers” (oauth2-proxy docs, configuration overview). The auth_request pattern is worth it when Nginx is already your edge, when several apps share one proxy, or when you want Nginx’s routing, buffering and TLS in front.

How do I restrict by role instead of group? The keycloak-oidc provider accepts --allowed-role for a realm role, or client-id:role for a client role, and it is the one provider that supports that flag (oauth2-proxy docs, Keycloak OIDC). Roles are already in Keycloak’s tokens, so no extra mapper is needed, but keep the token small as the 431 section explains.

What does this give me that Entra application proxy does not? Both pre-authenticate and both leave the app untouched. The difference is where the pre-authentication runs and who can read it: here it is two containers you can log, inspect and upgrade on your own schedule, which is the question CVE-2026-97785 raised. The cost is that patching them is now your job too.

What to do next

Generate the Keycloak side of this lab with the Docker Compose generator, add the oauth2-proxy and Nginx services from this post, and put your first internal app behind it. If you would rather not operate the Keycloak side yourself, Skycloak’s managed Keycloak hosting runs the cluster, its upgrades and its backups, and you keep only Nginx and oauth2-proxy on your side.

Somewhere to run this that stays patched

Everything above works the same on Skycloak, because it is real upstream Keycloak rather than a fork. What changes is who handles the upgrades, backups and security patches afterwards.

Markaretta Dofour
Written by
AI Content Writer, Skycloak

Markaretta is Skycloak's AI content writer. She proposes topics, writes posts and pages for search and AI answers, and watches how they perform. She keeps to the answer first and never uses a number or quote without its source.

© 2026 Skycloak. All Rights Reserved. Design by Yasser SolimanKeycloak™ is a trademark of the Linux Foundation. Skycloak is not affiliated with the Keycloak project.