| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | kubectl v1.37.0Envoy Gateway v1.9.2ingress-nginx controller v1.15.1oauth2-proxy v7.13.0Gateway API CRDs v1.6.1 (standard channel) |
| Applies to | Any host with kubectl configured; any Kubernetes cluster that already runs ingress-nginx and Envoy Gateway |
| Assumes | An existing ingress-nginx + oauth2-proxy deployment and an Envoy Gateway named eg |
| Privilege | Normal user |
| Scope | Why ingress-nginx auth annotations do not survive a move to HTTPRoute, how a SecurityPolicy replaces auth-url, which login paths must stay off that policy, and why /oauth2/auth returning 202 does not admit the request. Does not replace oauth2-proxy with Envoy Gateway's built-in OIDC policy. |
| Related guides | Migrate ingress-nginx to Envoy Gateway Gateway API with HTTPRoute Expose services with Ingress Kubernetes Services Kubernetes Secrets |
You cannot copy the ingress-nginx auth annotations onto an HTTPRoute and expect the login to keep working. auth-url, auth-signin, and auth-response-headers belong to ingress-nginx. The HTTPRoute replaces the routing rules, but ingress-nginx-specific authentication annotations do not carry over with it. Once that Ingress is gone, nothing asks oauth2-proxy whether the session is valid, and a signed-out browser can reach the app.
oauth2-proxy can stay. It remains the OIDC client, so the identity provider does not have to change. A SecurityPolicy on the application route replaces the auth-url check. A second HTTPRoute for /oauth2, with no SecurityPolicy, replaces the login Ingress.
The status code does not carry over. /oauth2/auth returns 202 for a valid session, and ingress-nginx accepts that 2xx. With the HTTP external authorization flow used here, that 202 is not the success response this integration requires. The working check uses oauth2-proxy's static 200 upstream.
How oauth2-proxy authentication changes during the migration
The application and the login are two routes on the same hostname. Only the application route calls oauth2-proxy for a decision. The login route goes straight to oauth2-proxy so the browser can finish the provider redirect.
app.example.com
|
Envoy Gateway
|
+--------------+--------------+
| |
/oauth2/* /*
| |
HTTPRoute oauth2 HTTPRoute web
no SecurityPolicy |
| SecurityPolicy
| |
oauth2-proxy <---- auth check ----+
| |
| HTTP 200 |
+------------------------> application
Browser -> /oauth2/start -> oauth2-proxy -> OIDC provider
<- /oauth2/callback <- oauth2-proxy <- providerThe old configuration is two Ingresses. The app Ingress carries the three auth annotations. The proxy Service is the in-cluster address in auth-url. The /oauth2 Ingress has no auth annotations, so /oauth2/start and /oauth2/callback reach oauth2-proxy directly.
auth-urlsends a subrequest to/oauth2/auth. Any HTTP 2xx, including 202, means the session is valid.auth-signinturns a failed check into a redirect to/oauth2/start?rd=plus the original path.auth-response-headerscopiesX-Auth-Request-UserandX-Auth-Request-Emailonto the request that reaches the app.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
nginx.ingress.kubernetes.io/auth-url: http://oauth2-proxy.default.svc.cluster.local:4180/oauth2/auth
nginx.ingress.kubernetes.io/auth-signin: http://app.example.com:18080/oauth2/start?rd=$escaped_request_uri
nginx.ingress.kubernetes.io/auth-response-headers: X-Auth-Request-User,X-Auth-Request-Email
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: oauth2
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: oauth2-proxy
port:
number: 4180The sign-in host in that Ingress is the address browsers used for this ingress-nginx setup. Use the external URL of your own site there.
ingress-nginx asks oauth2-proxy before it forwards the application request:
Request
|
ingress-nginx
|
+--> /oauth2/auth
|
+-- 202 -> allow the request
|
+-- 401 -> auth-signin -> /oauth2/startEnvoy Gateway has no equivalent for these annotations. Recreate the behavior with an HTTPRoute and a SecurityPolicy.
| ingress-nginx configuration | Envoy Gateway equivalent |
|---|---|
nginx.ingress.kubernetes.io/auth-url |
SecurityPolicy.spec.extAuth.http |
nginx.ingress.kubernetes.io/auth-signin |
oauth2-proxy returns the redirect from the authorization request |
auth-response-headers |
headersToBackend |
| Cookie included in the auth subrequest | headersToExtAuth: [Cookie] |
/oauth2 Ingress without auth |
Separate /oauth2 HTTPRoute without the app's SecurityPolicy |
One exception sits under that table. auth-url points at /oauth2/auth, and a 202 counts as success. Do not copy that URL into pathOverride. The next section is why.
Why /oauth2/auth does not work directly with Envoy Gateway
oauth2-proxy documents /oauth2/auth as an authentication endpoint. For the normal authentication check, a valid session returns 202 Accepted, while an unauthenticated request returns 401 Unauthorized. ingress-nginx auth-url treats any 2xx as authenticated, so 202 lets the request through:
/oauth2/auth
|
+-- 202 -> 2xx -> authenticated -> applicationA signed-in call to that endpoint returned 202, with the email header set:
curl --resolve app.example.com:18080:127.0.0.1 -b cookies.txt -D - \
http://app.example.com:18080/oauth2/authHTTP/1.1 202 Accepted
Date: Sat, 03 Oct 2026 18:53:37 GMT
Content-Length: 0
Connection: keep-alive
Gap-Auth: user@example.com
X-Auth-Request-Email: user@example.com
X-Auth-Request-User: CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2FsHTTP 202 means the session is valid. The user header is the issuer subject, not the email. The email header is user@example.com. ingress-nginx copied those headers and the app then returned email=user@example.com.
With Envoy Gateway's HTTP external authorization flow used here, that 202 is not accepted as the successful authorization response this integration requires. Pointing the check at /oauth2/auth returned the 202 to the client. The response had no application body:
curl --resolve app.example.com:19080:127.0.0.1 -b cookies.txt -D - \
http://app.example.com:19080/HTTP/1.1 202 Accepted
gap-auth: user@example.com
x-auth-request-email: user@example.com
x-auth-request-user: CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs
date: Sat, 03 Oct 2026 18:54:51 GMT
x-envoy-upstream-service-time: 4
content-length: 0There is no application path= line. The check answered, and the request stopped there:
/oauth2/auth
|
+-- 202 -> not treated as authorization success
X
applicationThe working configuration calls / on oauth2-proxy, and oauth2-proxy is started with --upstream=static://200. A valid session then returns 200, which this integration accepts:
/
|
oauth2-proxy --upstream=static://200
|
200
|
Envoy allows the requestConfigure oauth2-proxy for Envoy Gateway
oauth2-proxy stays the OIDC client. Keycloak, Dex, Microsoft Entra ID, Okta, or another provider oauth2-proxy already supports does not need to change because the ingress controller changed. The responses below were captured with Dex. Keep the client id, client secret, and cookie secret you already use. Store the secrets in a Kubernetes Secret.
Three existing oauth2-proxy settings are especially important to the authorization check:
--reverse-proxy=truetells oauth2-proxy it sits behind a proxy.--set-xauthrequest=trueaddsX-Auth-Request-UserandX-Auth-Request-Emailto an allowed response.--upstream=static://200answers/with HTTP 200 only after the session is valid. That is the status this HTTP external authorization flow accepts.
--redirect-url must be the callback URL the browser can reach, and the same URL registered with the identity provider. It does not follow whichever port or hostname happened to receive the request. On HTTPS, leave the session cookie Secure. An HTTP-only test has to set --cookie-secure=false, or the browser will not send the cookie.
args:
- --reverse-proxy=true
- --set-xauthrequest=true
- --upstream=static://200
- --redirect-url=https://app.example.com/oauth2/callbackReplace https://app.example.com/oauth2/callback with the callback you register. The rest of the oauth2-proxy arguments stay as they are.
Route oauth2-proxy login endpoints without the SecurityPolicy
/oauth2/start and /oauth2/callback have to reach oauth2-proxy with no authorization check in front of them. If the SecurityPolicy also matches those paths, the callback is sent back through the check, the check starts another login, and the browser loops.
/oauth2/start
/oauth2/callback
|
X must not call extAuthThe examples assume a Gateway named eg. Migrate ingress-nginx to Envoy Gateway is where that Gateway comes from. This HTTPRoute sends every /oauth2 path to the proxy Service:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: oauth2
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /oauth2
backendRefs:
- name: oauth2-proxy
port: 4180/oauth2 is a longer prefix than /, so the login paths use this route even when a second route matches /. Leave this route out of the SecurityPolicy in the later section.
Create the application HTTPRoute
The application route is the one the SecurityPolicy will target. It sends every other path on app.example.com to Service web:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web
port: 80This route does not check the session by itself. The policy in the next section does that before Envoy forwards the request.
The HTTPRoute backend remains the application Service. oauth2-proxy is only the external authorization backend referenced by the SecurityPolicy. Do not change the application backendRefs to oauth2-proxy. That sends normal application requests through the proxy instead of only checking their session.
Protect the HTTPRoute with SecurityPolicy
An HTTPRoute decides where a matched request should go. A SecurityPolicy adds the authentication check before Envoy forwards that request to the backend. Here, that policy does the job auth-url did on ingress-nginx.
Target the policy at HTTPRoute web, not at the Gateway. A policy on the Gateway would also check /oauth2/callback.
HTTP external authorization does not forward every client header. Unless you list more, the check includes only Host, Method, Path, Content-Length, and Authorization. The session cookie is not in that list. Without Cookie under headersToExtAuth, the browser can already hold a valid _oauth2_proxy cookie while oauth2-proxy never receives it. The user is treated as signed out, and the check starts a new login.
path and pathOverride are different. path is a prefix, and Envoy appends the original request path, so a check of /hello with path: /oauth2/auth becomes /oauth2/auth/hello. pathOverride replaces the path. Set it to /, which is the static upstream that returns 200 after a valid session.
headersToBackend is the replacement for auth-response-headers. failOpen: false means a dead proxy blocks the app instead of publishing it with no login.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: web-auth
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: web
extAuth:
failOpen: false
headersToExtAuth:
- Cookie
http:
backendRefs:
- name: oauth2-proxy
port: 4180
pathOverride: /
headersToBackend:
- X-Auth-Request-User
- X-Auth-Request-EmailRead the policy condition by type. Accepted=True means it is attached to HTTPRoute web. It does not mean a signed-in request has succeeded:
kubectl get securitypolicy web-auth \
-o jsonpath='{range .status.ancestors[*].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'Accepted=True AcceptedVerify oauth2-proxy authentication through Envoy Gateway
A signed-out request should redirect to the identity provider. The captured output below uses a local test address. In your environment, send the request to the hostname published by the Gateway.
A signed-out request returned 302. The Location is the provider login, and redirect_uri is the configured --redirect-url, not the address that received this request:
curl --resolve app.example.com:19080:127.0.0.1 -D - \
http://app.example.com:19080/HTTP/1.1 302 Found
location: http://login.example.com:5556/dex/auth?approval_prompt=auto&client_id=oauth2-proxy&redirect_uri=http%3A%2F%2Fapp.example.com%3A18080%2Foauth2%2Fcallback&response_type=code&scope=openid+email+profile&state=...
set-cookie: _oauth2_proxy_csrf=...; Path=/; Max-Age=900; HttpOnlyThe state value and the CSRF cookie value are omitted. The cookie name is _oauth2_proxy_csrf. This redirect goes to the provider. It does not stop at /oauth2/start the way auth-signin did.
Open the application in a browser, complete the OIDC login, and confirm you land back on the application. That round trip is what sets the _oauth2_proxy session cookie. An OAuth login is a browser flow. A curl of the callback with no authorization code only produces an error page.
cookies.txt is the valid _oauth2_proxy session cookie captured during testing. You do not need that file for a normal browser sign-in. The captured request below reuses it, and the body is the app with the identity headers:
curl --resolve app.example.com:19080:127.0.0.1 -b cookies.txt -D - \
http://app.example.com:19080/HTTP/1.1 200 OK
server: nginx/1.27.5
date: Sat, 03 Oct 2026 18:54:28 GMT
content-type: text/plain
content-length: 96
path=/
user=CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs
email=user@example.comserver: nginx/1.27.5 means the request reached the app. email=user@example.com means headersToBackend copied X-Auth-Request-Email. The user value is the same issuer subject from the 202 response.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
HTTP 202, empty body, no path= line |
The check calls /oauth2/auth, which returns 202 |
pathOverride: / and --upstream=static://200 |
| POST requests return 405, or application traffic reaches oauth2-proxy | The HTTPRoute points at oauth2-proxy instead of the application Service | Keep the application under backendRefs. Reference oauth2-proxy only from extAuth |
HTTP 302 to the provider even with a _oauth2_proxy cookie |
Cookie is not forwarded on the check, so oauth2-proxy treats the user as signed out |
headersToExtAuth: Cookie |
| The browser loops between the callback and the login | /oauth2 is covered by the SecurityPolicy |
A separate HTTPRoute, and a policy that targets only the app route |
| The app has no user or email | headersToBackend does not list the headers |
Copy X-Auth-Request-User and X-Auth-Request-Email. The user value is the issuer subject. |
| HTTP 500 and an empty body | oauth2-proxy has no ready endpoints | failOpen: false blocks the app when the proxy is down. Restore the proxy. |
| The callback host or port is wrong | --redirect-url is not the URL registered with the provider |
Set --redirect-url to the external callback, and register that same URL |
Should you keep oauth2-proxy or use Envoy Gateway OIDC?
Keep oauth2-proxy when the deployment, the session cookie, and the provider registration already work, and the only change is the ingress controller. The SecurityPolicy on this page calls that existing proxy.
Use Envoy Gateway's OIDC policy when you want the gateway itself to be the OIDC client and you are ready to retire oauth2-proxy. That policy talks to the provider directly. It is not a later step of this migration, and it does not preserve the oauth2-proxy cookie or the /oauth2/auth check.
Summary
The ingress-nginx annotations do not move onto an HTTPRoute. A SecurityPolicy on the application route replaces auth-url. A separate /oauth2 route, with no SecurityPolicy, is where /oauth2/start and /oauth2/callback have to land, or the login loops.
ingress-nginx accepts the 202 from /oauth2/auth. This HTTP external authorization flow does not. The working check calls / on oauth2-proxy with --upstream=static://200, forwards Cookie on the way in, and copies X-Auth-Request-User and X-Auth-Request-Email toward the app. A signed-out request redirects to the identity provider. --redirect-url has to be the callback URL registered with that provider.
With the session cookie, the app returned email=user@example.com. The user header was the issuer subject. If that header is empty, or a signed-in browser is sent to the provider again, check headersToBackend and headersToExtAuth before you change the identity provider.
References
- Envoy Gateway external authorization (v1.9)
- ingress-nginx external authentication
- oauth2-proxy endpoints
- oauth2-proxy
- Gateway API HTTPRoute
Frequently Asked Questions
Does Envoy Gateway replace oauth2-proxy?
No. This setup keeps oauth2-proxy and calls it from a SecurityPolicy. Envoy Gateway also has an OIDC policy that talks to the issuer itself. That is a different choice, and it does not preserve an existing oauth2-proxy deployment.
Why does a valid session return 202 and still fail?
/oauth2/auth returns HTTP 202 when the cookie is valid. ingress-nginx treats any 2xx as allowed. With the HTTP external authorization flow used here, that 202 is not the success response this integration requires, and the client receives the 202 instead of the app. Point the check at / and run oauth2-proxy with --upstream=static://200.
Why does the callback have to be a separate route?
/oauth2/start and /oauth2/callback have to reach oauth2-proxy without another authorization check. If the SecurityPolicy is attached to the Gateway, or to an HTTPRoute that also matches /oauth2, the callback starts another login and the browser loops.
Why is the user header not the email address?
X-Auth-Request-User was the issuer subject. X-Auth-Request-Email was user@example.com. Copy both with headersToBackend, and have the app read the email header when it needs the address.
What happens when oauth2-proxy is down?
With failOpen: false, the check returned HTTP 500 and an empty body. The app did not run. Bring the proxy back. Setting failOpen to true would skip the check, which publishes the app with no login.

