Migrate oauth2-proxy from ingress-nginx to Envoy Gateway

Tested on RHEL 10.2 (Coughlan)
Package kubectl v1.37.0
Envoy Gateway v1.9.2
ingress-nginx controller v1.15.1
oauth2-proxy v7.13.0
Gateway 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
Short on time?Beta Get the gist of this 12-minute read in seconds, then ask follow-up questions

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.

NOTE
Envoy Gateway can also be the OIDC client itself. Use that when you want to retire oauth2-proxy. This page is the other migration: keep the oauth2-proxy deployment you already have, and replace only the ingress-nginx external-auth integration.

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 <- provider

The 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-url sends a subrequest to /oauth2/auth. Any HTTP 2xx, including 202, means the session is valid.
  • auth-signin turns a failed check into a redirect to /oauth2/start?rd= plus the original path.
  • auth-response-headers copies X-Auth-Request-User and X-Auth-Request-Email onto the request that reaches the app.
yaml
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: 4180

The 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/start

Envoy 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 -> application

A signed-in call to that endpoint returned 202, with the email header set:

bash
curl --resolve app.example.com:18080:127.0.0.1 -b cookies.txt -D - \
  http://app.example.com:18080/oauth2/auth
output
HTTP/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: CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs

HTTP 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:

bash
curl --resolve app.example.com:19080:127.0.0.1 -b cookies.txt -D - \
  http://app.example.com:19080/
output
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: 0

There is no application path= line. The check answered, and the request stopped there:

              /oauth2/auth
                   |
                   +-- 202 -> not treated as authorization success
                                 X
                           application

The 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 request

Configure 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=true tells oauth2-proxy it sits behind a proxy.
  • --set-xauthrequest=true adds X-Auth-Request-User and X-Auth-Request-Email to an allowed response.
  • --upstream=static://200 answers / 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.

yaml
args:
- --reverse-proxy=true
- --set-xauthrequest=true
- --upstream=static://200
- --redirect-url=https://app.example.com/oauth2/callback

Replace 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 extAuth

The 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:

yaml
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:

yaml
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: 80

This 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.

yaml
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-Email

Read the policy condition by type. Accepted=True means it is attached to HTTPRoute web. It does not mean a signed-in request has succeeded:

bash
kubectl get securitypolicy web-auth \
  -o jsonpath='{range .status.ancestors[*].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
output
Accepted=True Accepted

Verify 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:

bash
curl --resolve app.example.com:19080:127.0.0.1 -D - \
  http://app.example.com:19080/
output
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; HttpOnly

The 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:

bash
curl --resolve app.example.com:19080:127.0.0.1 -b cookies.txt -D - \
  http://app.example.com:19080/
output
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.com

server: 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)