SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Put any app behind SSO with oauth2-proxy

Forward auth puts an app that has no login behind your OIDC provider. Wire oauth2-proxy into nginx or Traefik, and avoid the cookie and redirect traps.

Forward auth: how an app with no login gets SSO

oauth2-proxy gives single sign-on to an application that has no login of its own. It works because the reverse proxy in front of that application stops every request, asks oauth2-proxy whether the request carries a valid session, and passes the request upstream only when the answer is yes. The application code never changes, because the application never sees the check.

The check is one extra HTTP request. The proxy sends a copy of the incoming request headers to /oauth2/auth and reads the status code. A 202 means the caller has a session, so the proxy forwards the original request to the app. A 401 means no session, so the proxy sends the browser to /oauth2/sign_in, which starts an OpenID Connect (OIDC) login at your identity provider. OIDC is the identity layer built on top of OAuth 2.0, and the provider is whatever you already run for logins.

This pattern has a name in every reverse proxy. Nginx calls the directive auth_request. Traefik calls the middleware forwardAuth. Caddy spells it forward_auth. The service answering the sub-request is interchangeable as well. oauth2-proxy is the common choice because it speaks plain OIDC and needs no database of its own.

Draw the trust boundary before you write any config

After a successful check, oauth2-proxy returns the identity as response headers, and the reverse proxy copies them onto the upstream request. With set_xauthrequest enabled you get X-Auth-Request-User and X-Auth-Request-Email. The application reads those headers and trusts them.

That is the whole security model, so say the consequence out loud. Anything that can open a TCP connection to the application port can set those headers itself and become any user. A single curl -H "X-Auth-Request-Email: admin@example.com" http://app-host:3000/ is a complete bypass if it reaches the app directly.

So the application must not be reachable except through the proxy. In Docker Compose, delete the ports: mapping from the app service and leave it on the internal network, so only the proxy container can dial it. On a bare host, bind the app to 127.0.0.1:3000 instead of 0.0.0.0:3000. Then check what you actually exposed:

sudo ss -tlnp | grep 3000

A line reading 0.0.0.0:3000 means the app answers on the public IP and your gate is decoration. 127.0.0.1:3000 is what you want. A firewall rule is a useful second layer, but the bind address is the one that survives another tool flushing your ruleset.

Install oauth2-proxy

As of August 2026 the current release is v7.15.3, published in June 2026. Install the binary and verify the download:

cd /tmp
curl -fsSLO https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.15.3/oauth2-proxy-v7.15.3.linux-amd64.tar.gz
curl -fsSLO https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.15.3/oauth2-proxy-v7.15.3.linux-amd64.tar.gz-sha256sum.txt
sha256sum -c oauth2-proxy-v7.15.3.linux-amd64.tar.gz-sha256sum.txt
tar -xzf oauth2-proxy-v7.15.3.linux-amd64.tar.gz
sudo install -m 755 oauth2-proxy-v7.15.3.linux-amd64/oauth2-proxy /usr/local/bin/oauth2-proxy
oauth2-proxy --version

sha256sum -c must print a line ending in OK. If it prints FAILED, stop and download again rather than running the binary.

In Docker the image is quay.io/oauth2-proxy/oauth2-proxy, and you should pin the tag: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3. Leaving it on latest turns a routine docker compose pull into an unplanned upgrade of the one process guarding every app on the box.

The session cookie is encrypted, and cookie_secret is the key. It must be exactly 16, 24 or 32 bytes, because it becomes an AES (advanced encryption standard) key. Any other length and oauth2-proxy refuses to start, with a startup error naming the cookie secret.

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

The tr is not cosmetic. It converts standard base64 into the URL-safe alphabet, so the value survives a shell, an env file and an HTTP header without quoting problems.

Two rules for this value. Use a different secret for every deployment. And when you run more than one oauth2-proxy instance behind the same domain, give them all the same secret, because a cookie encrypted by one instance has to be readable by the others.

Write the oauth2-proxy config

Keep the settings in a file rather than a long command line, so the client secret never shows up in ps output.

# /etc/oauth2-proxy/oauth2-proxy.cfg
http_address = "127.0.0.1:4180"
reverse_proxy = true

provider = "oidc"
oidc_issuer_url = "https://id.example.com/application/o/myapp/"
client_id = "REPLACE_ME"
client_secret = "REPLACE_ME"

redirect_url = "https://app.example.com/oauth2/callback"
cookie_secret = "REPLACE_ME"
cookie_secure = true
cookie_domains = [".example.com"]
whitelist_domains = [".example.com"]

email_domains = ["*"]
set_xauthrequest = true
upstreams = ["static://202"]

reverse_proxy = true tells oauth2-proxy to trust the X-Forwarded-* headers from the proxy in front of it. Without it, oauth2-proxy treats the proxy's own address as the client address and can misjudge whether the request arrived over HTTPS.

upstreams = ["static://202"] makes oauth2-proxy answer 202 to an authenticated request and proxy nothing, which is exactly what forward auth needs, since the reverse proxy does the proxying. The other deployment shape puts oauth2-proxy directly in the request path with upstreams = ["http://127.0.0.1:3000"] and no auth_request at all. That is simpler for one app and does not scale to ten.

email_domains = ["*"] admits every address your provider will authenticate. Narrow it to your own domain, or better, restrict access with a group binding in the provider, because the provider is where you already manage people.

Run it under systemd as its own user:

# /etc/systemd/system/oauth2-proxy.service
[Unit]
Description=oauth2-proxy
After=network-online.target
Wants=network-online.target

[Service]
User=oauth2-proxy
Group=oauth2-proxy
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/oauth2-proxy.cfg
Restart=on-failure
ProtectSystem=strict
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
sudo useradd --system --no-create-home --shell /usr/sbin/nologin oauth2-proxy
sudo install -d -m 750 /etc/oauth2-proxy
sudo chown -R oauth2-proxy:oauth2-proxy /etc/oauth2-proxy
sudo chmod 600 /etc/oauth2-proxy/oauth2-proxy.cfg
sudo systemctl daemon-reload
sudo systemctl enable --now oauth2-proxy
curl -s http://127.0.0.1:4180/ping

/ping printing OK means the process started and loaded its config. It is oauth2-proxy's own health endpoint and it never asks for a session. If nothing answers, read journalctl -u oauth2-proxy -n 50, because a bad issuer URL and a wrong-length cookie secret both fail at startup and both say so.

Register the redirect URI with your provider

Create an OIDC application in your provider and set its redirect URI to exactly the redirect_url from the config: https://app.example.com/oauth2/callback. Exactly means the scheme, host, port and path all match character for character. A trailing slash makes it a different URI.

This is the most common failure in the whole setup, and it fails before oauth2-proxy is involved at all. The provider rejects the authorisation request and renders its own error page, so nothing appears in the oauth2-proxy log. The tell is the address bar: the browser is still on your provider's domain, and the query string carries error=invalid_request or the page names redirect_uri directly. When you see that, fix the application record in the provider, not the proxy config.

Copy the issuer URL from the provider rather than typing it. oauth2-proxy appends /.well-known/openid-configuration to oidc_issuer_url and fetches that discovery document at startup. Check it yourself first:

curl -s https://id.example.com/application/o/myapp/.well-known/openid-configuration | head -c 400

JSON containing an authorization_endpoint key means the issuer URL is right. A 404 or an HTML error page means it is wrong, and oauth2-proxy will fail to start on that same 404. If you have not picked a provider yet, the comparison of Keycloak, Authentik and Zitadel covers the trade-offs, and running Authentik as your own SSO server walks through the provider half of this exact setup.

Nginx: auth_request

Nginx does forward auth with auth_request, which fires an internal sub-request and branches on its status code.

# in the http context, next to your other maps
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 443 ssl;
  server_name app.example.com;

  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-Auth-Request-Redirect $request_uri;
  }

  location = /oauth2/auth {
    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-Forwarded-Uri  $request_uri;
    proxy_set_header Content-Length   "";
    proxy_pass_request_body           off;
  }

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

    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;

    auth_request_set $auth_cookie $upstream_http_set_cookie;
    add_header Set-Cookie $auth_cookie;

    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host       $host;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
  }

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

Three details in there earn their place. proxy_pass_request_body off with an empty Content-Length stops nginx copying the body of every POST into the sub-request, which matters because oauth2-proxy reads none of it. On a file upload, the default sends the file twice.

The auth_request_set $auth_cookie and add_header Set-Cookie pair passes a refreshed session cookie back to the browser. Leave it out and cookie_refresh quietly does nothing, because nginx discards the sub-request's Set-Cookie and the browser keeps the old value until the session expires.

error_page 401 = @oauth2_signin is what turns a failed check into a login. Without it, an unauthenticated visitor gets a bare 401 Authorization Required page and no way forward.

Test before reloading, always:

sudo nginx -t && sudo systemctl reload nginx

If the surrounding directives are new to you, the anatomy of an nginx reverse proxy config covers the layer underneath this one.

Traefik: forwardAuth middleware

Traefik needs two middlewares for the same job. One runs the check. The other turns the 401 into a browser redirect.

# dynamic configuration
http:
  middlewares:
    oauth-auth:
      forwardAuth:
        address: https://oauth.example.com/oauth2/auth
        trustForwardHeader: true
    oauth-errors:
      errors:
        status:
          - "401-403"
        service: oauth-backend
        query: "/oauth2/sign_in?rd={url}"
        statusRewrites:
          "401": 302

Attach both to the router that fronts your app, and publish oauth2-proxy on its own router at oauth.example.com, because the browser has to reach /oauth2/sign_in and /oauth2/callback without passing the check.

statusRewrites mapping 401 to 302 is the part people miss. Without it Traefik returns the sign-in redirect with a 401 status, the browser does not follow it, and the visitor sees a page containing the single word Found.

trustForwardHeader: true passes the original host and URI through to oauth2-proxy, which needs them to build the rd value that returns the user to the page they asked for. Set whitelist_domains to cover that host as well, or oauth2-proxy drops the rd parameter as an open-redirect risk and everyone lands on / after login. A Traefik box usually fronts several apps at once, and routing several Docker Compose apps through one Traefik instance shows the router layout this plugs into.

Caddy: forward_auth

app.example.com {
  handle /oauth2/* {
    reverse_proxy oauth2-proxy.internal:4180 {
      header_up X-Real-IP {remote_host}
      header_up X-Forwarded-Uri {uri}
    }
  }
  handle {
    forward_auth oauth2-proxy.internal:4180 {
      uri /oauth2/auth
      header_up X-Real-IP {remote_host}
      copy_headers X-Auth-Request-User X-Auth-Request-Email
      @error status 401
      handle_response @error {
        redir * /oauth2/sign_in?rd={scheme}://{host}{uri}
      }
    }
    reverse_proxy upstream.internal:3000
  }
}

Order matters here. The /oauth2/* block comes first and carries no forward_auth, because a visitor who is not logged in has to reach the sign-in and callback paths. Put the check in front of those paths and the login redirects to itself until the browser gives up.

copy_headers is what moves the identity onto the upstream request, and it only produces values when oauth2-proxy runs with set_xauthrequest = true. Picking between the proxies is its own question, and the comparison of nginx, Caddy and Traefik works through it.

Why does the login loop back to the login page?

You sign in at the provider, it sends you back, and oauth2-proxy sends you straight to the provider again. The loop means the callback request arrived without the cookie oauth2-proxy set on the way out. Its log names the case:

No cookies were found in OAuth callback.

or, when some other cookie made it but the right one did not:

Cookies were found in OAuth callback, but none was a CSRF cookie.

CSRF is cross-site request forgery, and this cookie exists so a callback can be tied to the login that started it. The browser sees the same failure as:

Login Failed: Unable to find a valid CSRF token. Please try again.

Check these four causes in order.

  1. cookie_secure = true while the browser reached the site over plain HTTP. A browser will not store a cookie marked Secure on an http:// origin, so it is never sent back. Terminate TLS (transport layer security) properly, or set cookie_secure = false only while testing on localhost.
  2. A cookie_domains value that does not cover the hostname in the address bar. .example.com covers app.example.com and does nothing at all for app.example.net.
  3. The browser is dropping the cookie. A strict privacy extension or third-party cookie blocking can remove _oauth2_proxy_csrf between the outbound redirect and the callback.
  4. Clock drift. If the server clock sits far from the provider's, the ID token's iat and exp fall outside the accepted window and the session is rejected on arrival. timedatectl should report System clock synchronized: yes.

Watch it happen from the server side rather than guessing:

sudo journalctl -u oauth2-proxy -f

Load the app in a private window. Every request is logged with its status, so a callback followed immediately by another redirect to the provider is the loop, on the record.

Paths that must skip the login: APIs, webhooks and websockets

Forward auth assumes a browser holding a cookie. Callers without a browser break.

An API client sending Authorization: Bearer <token> has no cookie, so it receives a 302 to your provider's login page and then tries to parse HTML as JSON. There are two clean answers. Setting skip_jwt_bearer_tokens = true makes oauth2-proxy accept a valid JWT (JSON web token) bearer token from the same issuer, which is right when your API clients already get tokens from the provider. Otherwise exempt the path:

skip_auth_routes = [
  "^/api/",
  "POST=^/webhook/",
  "GET=^/healthz$"
]

Each value is a regular expression matched against the normalised path, optionally prefixed with an HTTP method and =. POST=^/webhook/ leaves the webhook receiver open to POST while a human browsing to the same path still hits the login. Every entry is a hole in your gate, so anchor the expressions with ^ and keep them as narrow as the caller allows.

Websockets are the case people get wrong. The upgrade request is an ordinary HTTP GET carrying the same cookies as any other request, so it passes the check normally and needs no exemption. What breaks is the proxying around it. Without the Upgrade and Connection headers on the protected location, the upgrade never completes and the app's client retries forever with a WebSocket connection ... failed message in the browser console. Exempting the path fixes nothing there, because the request was already authorised.

One real limit does apply. The check runs once, at the upgrade. A websocket that stays open for hours is never re-checked, so removing a user in your provider does not close the socket they already hold. Restart the app to cut live connections.

By default the whole session lives inside the cookie, encrypted with your cookie_secret. That keeps oauth2-proxy stateless and needs no extra service. It also has a ceiling, because browsers cap a cookie near 4 KB. When the ID token carries a long list of group claims, oauth2-proxy splits the session across _oauth2_proxy_0, _oauth2_proxy_1 and onwards, and past a few parts the request headers grow big enough that nginx answers 400 Request Header Or Cookie Too Large before the app ever sees the request.

Move the session server-side when that happens:

session_store_type = "redis"
redis_connection_url = "redis://127.0.0.1:6379"

The browser then holds a short ticket, and the encrypted session sits in Redis. The cost is a service that has to stay up: if Redis goes down, every session becomes invalid and everyone is logged out at once. The cookie store has its own cost, which is that two requests refreshing the same session at the same moment can conflict and force a re-login.

What forward auth does not give you

This is a gate at the door. It is not authorisation inside the application, and that difference decides whether the approach fits your case.

Once a user is through, the application sees whatever it always saw. If the app has its own roles, forward auth does not fill them in, unless the app supports header-based authentication and maps a header to an account. Grafana does, through its auth.proxy settings. Most self-hosted apps do not, so everyone who gets through the gate is the same single identity to the application, and that identity is often an admin.

It also does not protect the application's own API tokens. A personal access token issued by the app authenticates to the app, not to oauth2-proxy, so the token stops working the moment the gate stands in front of it. Exempt the API path to bring it back and that token becomes the only thing guarding the path. You now run two authentication systems on one service, and SSO covers only one of them.

Revocation is the third gap. Deleting a user in your provider stops new logins and stops the token refresh that cookie_refresh performs, but an existing session cookie stays valid until it expires. cookie_expire defaults to 168 hours, which is a week of access for someone you just removed. Set cookie_refresh to something short, such as an hour, so revocation lands inside that window instead.

The audit trail stops at the gate too. oauth2-proxy logs who came through and when. The application logs an unnamed session. If you have to answer who changed a setting, header identity in the app is the minimum, and real per-user accounts are the honest answer.

When paying the SSO tax is the better answer

Forward auth is the right tool when an app has no login at all, or one shared password, and you want a single place to add and remove people. It costs an afternoon and one extra process, and it works with any app that speaks HTTP.

It is the wrong tool when different people need different permissions inside the same app. A gate cannot express "Ana may edit the dashboards and Bo may only read them". If the vendor sells an SSO tier, the group-to-role mapping is usually the thing you are actually buying, and rebuilding that with headers and proxy rules is more fragile than paying for it. The pricing pattern behind those SSO tiers is worth reading before you decide either way.

Two other situations point the same direction. Compliance work that needs per-user audit records inside the application will not accept a proxy access log as evidence. And any app with a mobile or desktop client that does not carry browser cookies will fight the gate on every request.

FAQ

What is forward auth?

Forward auth is a pattern where the reverse proxy asks a separate authentication service about every incoming request before passing it upstream. The proxy sends the request headers to an endpoint such as /oauth2/auth and reads the status code. 202 means allowed, so the original request goes through to the application. 401 means no session, so the proxy redirects the browser to a login. Nginx implements it with the auth_request directive, Traefik with the forwardAuth middleware, and Caddy with forward_auth.

Why does oauth2-proxy redirect me back to the login page in a loop?

The callback request reached oauth2-proxy without its CSRF cookie, so oauth2-proxy starts the flow over. The server log reads No cookies were found in OAuth callback. and the browser shows Login Failed: Unable to find a valid CSRF token. Please try again. The usual cause is cookie_secure = true on a site the browser reached over plain HTTP, because a browser will not store a Secure cookie on an http:// origin. The next most common is a cookie_domains value that does not cover the hostname in the address bar.

How do I let an API client or a webhook through oauth2-proxy?

Use skip_auth_routes with an anchored regular expression, optionally scoped to one HTTP method, for example POST=^/webhook/. If your API clients already hold JWTs issued by the same provider, skip_jwt_bearer_tokens = true accepts those tokens in place of a cookie and keeps the path protected. Anything listed in skip_auth_routes is unauthenticated for everyone, so keep each expression as narrow as the caller allows.

Does oauth2-proxy give the application per-user permissions?

No. It is a gate, not an authorisation system. It decides who reaches the application, and everyone who reaches it looks identical to the application unless that application reads identity headers and maps them to accounts. Grafana can do this through its auth.proxy settings. Most self-hosted apps cannot, so every person past the gate shares whatever single identity the app is running with.

Can someone bypass oauth2-proxy by setting the identity header themselves?

Yes, if they can reach the application directly. Identity arrives as a plain header such as X-Auth-Request-Email, and the application trusts whatever it receives. Anyone who can open a connection to the application port can send that header and become any user. Bind the app to 127.0.0.1, or keep it on an internal Docker network with no published port, and confirm it with sudo ss -tlnp.

#oauth2-proxy#sso#oidc#reverse-proxy#forward-auth