Locking an internal app behind GitHub org membership with nginx ingress + oauth2-proxy — and the traps I hit doing it in production
The pattern
You have an internal app on a Kubernetes cluster and you want only members of your GitHub org to reach it — without touching the app's code. The nginx ingress controller's auth_request mechanism delegates every request to oauth2-proxy, which handles the whole GitHub OAuth dance and sets a session cookie.
/oauth2/auth
auth-url annotation
X-Auth-Request-User and X-Auth-Request-Email on every request. That makes these headers your canonical identity: the backend can use them for per-user data scoping without implementing any auth itself.
Setup — three ingredients
OAuth App
Create it under the org's developer settings, not your personal account. Homepage URL is your app's URL; callback is https://app.example.com/oauth2/callback. Save the Client ID and generate a Client secret.
Cookie secret
oauth2-proxy requires a 16, 24, or 32-byte base64-encoded cookie secret. A random string of the right length is not enough — it must decode to exactly those byte counts.
Secrets sync
Store client-id, client-secret and cookie-secret in your secrets manager and let External Secrets (or similar) sync them into the Kubernetes Secret the oauth2-proxy chart expects.
Wiring the ingress
Three annotations on the app's ingress do all the work. oauth2-proxy itself runs as a normal Deployment + Service (the official Helm chart works fine as a subchart).
auth-url — every request triggers an internal subrequest here. 2xx lets the request through, 401 triggers the signin redirect.
auth-signin — where unauthenticated users get sent. The rd param preserves the originally requested URL.
auth-response-headers — copies the identity headers from oauth2-proxy's response onto the request forwarded to your app.
read:org scope. Non-members get a 403 from oauth2-proxy — they never reach the app.
The rollback trap
Rollback order matters more than it looks. The annotations and the Deployment are independent pieces of config, and one bad combination takes the whole site down.
Safe: remove the annotations first. Dropping auth-url, auth-signin and auth-response-headers from the app ingress restores unauthenticated access on its own. Optionally remove the oauth2-proxy Deployment in the same commit.
Outage: disable oauth2-proxy while the annotations remain. The auth-url now points at a deleted Service. nginx's auth_request fails on every request.
Verifying it works
Incognito test. Open the app in a private window — you should bounce to GitHub, authorize the OAuth app, and land back where you started. A non-member of the org should get a 403.
Pod health. The startup log should show the provider: OAuth2 Proxy v7.x ... configured with GitHub provider.
Headers reach the backend. Exec into an app pod or add a temporary debug endpoint that echoes X-Auth-Request-*. Your GitHub username and email should be there on every request.
Optional hardening. Add a NetworkPolicy so only the ingress controller namespace can reach oauth2-proxy's port (4180). Nothing else needs to talk to it.
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
| 403 Permission Denied after login | User isn't in the org, or their membership is private. Make membership public, or ensure the read:org scope is set and re-authenticate. |
| Invalid client credentials | Wrong client ID or secret in the secrets manager. Re-check the synced Kubernetes Secret values, not just the source. |
| "cookie secret must be 16, 24 or 32 bytes" | The secret isn't base64, or decodes to the wrong length. Regenerate with the Python one-liner above. |
/oauth2/callback returns 500 |
Callback URL mismatch between the GitHub OAuth app and oauth2-proxy's redirect URL. They must be byte-identical. |
| Redirect loop | The auth-signin annotation is missing or malformed. It must point to /oauth2/start?rd=$escaped_request_uri. |
| Every request 500s | The auth-url annotation points at a dead oauth2-proxy Service. See the rollback trap above. |