Authenticate at the Edge: Put Any Website Behind OIDC Without Changing Its Code

How nginx, oauth2-proxy, and an OpenID Connect provider turn login into an infrastructure concern — and why your application should never see a password.

9-15 minutes(1934 words)complex

Quick Navigation

Difficulty: Intermediate Estimated Time: 20-30 minutes
Prerequisites: nginx reverse proxy basics, Docker, an OIDC identity provider, HTTPS/TLS fundamentals

Authentication is the feature engineers rewrite most often and ship broken most often. Broken access control and identification failures have sat near the top of the OWASP Top Ten for years, and the reason is structural: every team reimplements sessions, token validation, and redirect handling inside the app, and every team finds a new way to get it subtly wrong.

There is a cleaner model. Move authentication out of the application entirely and enforce it at the edge — in front of the code, before a single request reaches your business logic. The application stops knowing about passwords, tokens, or OIDC flows. It receives requests that have already been proven to belong to a logged-in user, and nothing else.

This article walks through a concrete, production-shaped version of that pattern: an internal or public website fronted by nginx, protected by oauth2-proxy, with WebADM (RCDevs' OpenID & SAML Provider) acting as the identity provider. The same blueprint works with any compliant OIDC issuer — Keycloak, Entra ID, Google, Okta — because the contract between the pieces is the standard, not the vendor.

The case against in-app login

The instinct to put login inside the application is understandable and almost always wrong for the kind of app that sits behind a corporate or team boundary. When auth lives in the code, every service re-derives the same risky machinery: cookie signing, token expiry, refresh logic, callback validation. Multiply that across five internal tools and you have five separate auth surfaces to patch, audit, and keep consistent.

Edge authentication collapses all of that into one enforcement point. The proxy owns the session. The proxy talks to the identity provider. The proxy decides who passes. Your application, whether it is a Flask dashboard, a legacy PHP admin panel, or a static site, simply trusts what arrives.

"The clearest way to secure an application is to make sure it never has to think about security at all."

This is also the only realistic way to protect software you cannot modify. A vendor appliance, an internal Grafana instance, a tool whose source you do not own — none of these can grow an OIDC client overnight. An auth proxy gives all of them single sign-on without a single code change.

The architecture, in one diagram

Three components, one standard protocol holding them together:

(1) request
User ───────────────────────────────────────► nginx
▲ │
│ │ (2) sub-request: auth_request
│ ▼
│ oauth2-proxy
│ │
│ (3) redirect to login │ (4) OIDC discovery + token exchange
│ ◄─────────────────────────────────────────────┤
│ ▼
└──────────────► WebADM (OpenID & SAML Provider, the OIDC issuer)
After login: nginx ──► your website (upstream :8080)

nginx is the reverse proxy and the only thing exposed to the internet. oauth2-proxy is a small, stateless service that speaks OIDC to the provider and issues a signed session cookie. WebADM is the identity provider that actually verifies the user — credentials, MFA, group membership, whatever your policy demands.

The elegance is in the division of labor. nginx knows nothing about OIDC. oauth2-proxy knows nothing about your application. WebADM knows nothing about nginx. Each speaks one well-defined protocol to its neighbor, and you can replace any one of them without rewriting the others.

Key insight: The application and the identity provider never talk directly. oauth2-proxy is the only component that holds an OIDC client secret, which means your blast radius for a leaked secret is one container, not your whole estate.

What actually happens on a request

The flow is worth understanding line by line, because most debugging sessions are really debugging this sequence.

A request arrives at nginx. Before passing it upstream, nginx fires an internal sub-request to oauth2-proxy's /oauth2/auth endpoint using the auth_request directive. If the request already carries a valid session cookie, oauth2-proxy answers 202 and nginx lets the original request through to your website. Done — no redirects, no provider round-trip.

If there is no valid session, oauth2-proxy answers 401. nginx catches that with an error_page rule and redirects the user to /oauth2/sign_in, which bounces them to WebADM. The user authenticates there — password plus MFA, or whatever WebADM's policy enforces. WebADM redirects back to /oauth2/callback with an authorization code. oauth2-proxy exchanges that code for tokens, validates them against the provider's signing keys, sets a signed session cookie, and sends the user to where they were originally headed.

"Every subsequent request is a single cookie check. The expensive part of authentication happens exactly once per session, at the edge, and never inside your app."

The first visit costs a full handshake. Everything after that is cheap.

Step one: the identity provider (WebADM)

The provider side is where most misconfigurations originate, so get it precise. In the WebADM Admin Portal, open Applications Single Sign-On OpenID & SAML Provider and register a new OIDC client. Three settings matter most:

  • Redirect / callback URI: must be exactly https://app.example.com/oauth2/callback. Not a trailing-slash variant. Not http. Exactly this.
  • Scopes: openid, profile, email.
  • Client ID and Client Secret: generated by WebADM — copy both.

Then confirm the issuer URL, which is the single most common source of "discovery failed" errors. OIDC clients build the discovery URL by appending /.well-known/openid-configuration to the issuer, so the value has to match what the provider publishes character for character. Verify it in a browser before touching any config:

https://webadm.example.com/ws/openid/.well-known/openid-configuration

Whatever string the "issuer" field reports in that JSON document is the value you feed to oauth2-proxy. Copy it verbatim.

Note: If WebADM sits behind its own reverse proxy, make sure that proxy allows large query strings. OIDC and SAML requests carry long URI parameters, and an over-eager size limit will truncate them and produce baffling, intermittent login failures.

Step two: oauth2-proxy, the OIDC client

oauth2-proxy is the brain of the setup, and it is deliberately small. Generate a cookie secret first — this is what signs the session cookie, so it must be random and kept private:

openssl rand -base64 32 | tr -- '+/' '-_'

Then run the proxy. Every flag below earns its place:

docker run -d --name oauth2-proxy -p 4180:4180 \
quay.io/oauth2-proxy/oauth2-proxy:latest \
--provider=oidc \
--oidc-issuer-url="https://webadm.example.com/openid/" \
--client-id="YOUR_CLIENT_ID" \
--client-secret="YOUR_CLIENT_SECRET" \
--redirect-url="https://app.example.com/oauth2/callback" \
--cookie-secret="YOUR_GENERATED_SECRET" \
--cookie-secure=true \
--email-domain="*" \
--http-address="0.0.0.0:4180" \
--reverse-proxy=true \
--set-xauthrequest=true \
--upstream="static://200"

Reading the important flags: --provider=oidc tells it to use generic OIDC discovery rather than a vendor-specific shortcut. --oidc-issuer-url is the issuer you confirmed in step one — replace the placeholder with the exact value. --redirect-url must mirror the callback you registered in WebADM. --cookie-secure=true refuses to set the session cookie over plain HTTP, which is correct and non-negotiable in production. --reverse-proxy=true tells oauth2-proxy to trust the X-Forwarded-* headers nginx sets, so it builds correct redirect URLs. --set-xauthrequest=true exposes the authenticated user's identity back to nginx as response headers, which is how your app learns who is logged in. And --upstream="static://200" is the trick that lets oauth2-proxy run purely as an auth checker — it never proxies traffic itself; nginx does the routing.

To restrict access, swap --email-domain="*" for your real domain, or move to group-based rules enforced in WebADM's client policy. The wildcard means anyone WebADM can authenticate gets in, which is rarely what you want past a first test.

Step three: nginx and the auth_request trick

nginx ties it together with one directive doing the heavy lifting: auth_request. It lets a location delegate an allow/deny decision to a sub-request before serving content.

server {
listen 443 ssl;
server_name app.example.com;
# ... your ssl_certificate directives ...
# oauth2-proxy's own endpoints (sign-in, callback, etc.)
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Auth-Request-Redirect $request_uri;
}
# The internal auth check — never reached directly by users
location = /oauth2/auth {
internal;
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
# Your actual website — protected
location / {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
# Forward the authenticated identity to your app
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-User $user;
proxy_set_header X-Email $email;
proxy_pass http://127.0.0.1:8080; # your website's upstream
}
}

Three blocks, three jobs. The /oauth2/ block exposes oauth2-proxy's sign-in and callback routes to the browser. The location = /oauth2/auth block is marked internal, so users can never hit it directly — it exists only to answer nginx's sub-request, and it deliberately strips the request body for speed. The root block is your real site: auth_request /oauth2/auth runs the check on every request, error_page 401 = /oauth2/sign_in turns a failed check into a login redirect, and the auth_request_set lines pull the authenticated username and email out of oauth2-proxy's response and forward them to your application as X-User and X-Email. Your app can read those headers to know who is on the other end — without ever having implemented login.

The failure modes that bite in production

This stack is robust once running, but the same handful of mistakes account for nearly every broken first deployment.

A redirect URI mismatch is the most common. The callback registered in WebADM must equal --redirect-url exactly — protocol, host, path, and trailing slash. A single mismatched character produces a generic OIDC error that says nothing useful.

Cookies that never stick almost always trace to --cookie-secure=true running over plain HTTP somewhere in the chain. The secure flag means the browser refuses to store the cookie on an insecure hop, so the user logs in, gets bounced back, and loops forever. The fix is real HTTPS end to end, including between nginx and oauth2-proxy if they are on separate hosts.

An issuer that does not match breaks discovery before login even starts. If oauth2-proxy logs a "could not fetch openid configuration" error, the issuer URL you passed does not line up with what the provider's discovery document declares.

Note: When debugging, watch all three logs at once — nginx access log, oauth2-proxy log, and WebADM's auth log. The failure almost always announces itself clearly in exactly one of them, and the trick is knowing which boundary you are looking at.

When the proxy becomes the platform

The deeper payoff of this pattern is not the single site you just protected. It is the seam you created. Once an auth proxy fronts one application, adding the next is a new nginx server block and a second WebADM client — minutes of work, not a sprint. Single sign-on, MFA policy, and access revocation become properties of the infrastructure rather than features each team rebuilds and re-audits in isolation.

That is the real argument for authenticating at the edge: it turns identity from a problem every application owns into a service the platform provides. The application gets smaller and safer. The security boundary gets sharper and easier to reason about. And the next time someone ships an internal tool, the right answer to "how do we add login?" is no longer a library and a migration — it is a config file.

The open question worth carrying forward is how far you push this. Edge authentication answers who are you. It does not, by itself, answer what are you allowed to do. The moment your apps need fine-grained, per-resource authorization, the proxy stops being enough and the conversation moves to policy engines and identity-aware access. That is the next boundary — and it is a far more interesting design problem than login ever was.


Tags: #OIDC #Nginx #Authentication #DevOps #WebSecurity