Skip to content
Published on

Ingress-level Authentication — Applying SSO with oauth2-proxy and Forward Auth

Authors

Introduction

If you keep deploying internal tools onto a Kubernetes cluster, there is a moment of realization that eventually arrives. Grafana, Kibana, ArgoCD, an in-house dashboard, a build report page, and even a tiny side project with no authentication at all. All of these need to be visible only to "logged-in employees," yet bolting authentication onto each application separately is a nightmare.

Some apps support OIDC, some support only basic auth, and some have no authentication feature whatsoever. To layer consistent SSO (Single Sign-On) onto this motley crew, where should the authentication layer live? The answer is surprisingly simple. It belongs at the point where traffic first arrives, namely the Ingress.

This article does not re-explain the OIDC, Keycloak, and SSO concepts that this blog has already covered several times. Instead, it focuses strictly on the Ingress perspective, connecting oauth2-proxy as forward auth through the external auth pattern, and layering SSO onto multiple apps without touching a single line of application code. It also compares how ingress-nginx, Traefik, and Contour each approach it.

Let me state one premise up front. As of 2026, the Kubernetes Ingress API is effectively frozen. No new features are being added, and the successor standard is the Gateway API. ingress-nginx is close to maintenance mode and security issues are reported from time to time. Nonetheless, Ingress-based authentication is still overwhelmingly common in the field, so this article deals with the real-world Ingress while noting its relationship to the Gateway API in each section.

What is the external auth pattern

External auth (also called forward auth) is a pattern where, before a reverse proxy forwards a request to the backend, it first asks a separate authentication service: "may I let this request through?" If the auth service responds with 200, the proxy forwards the request to the backend; if it responds with 401 or 302, the proxy sends the user to the login page.

The core value of this pattern is that it pulls the authentication logic out of the application. The backend app does not need to care about who is allowed to see it. Only the Ingress and the auth service carry that responsibility.

                         (1) GET /dashboard
   ┌──────────┐  ─────────────────────────────►  ┌─────────────┐
   │ Browser   │                                  │   Ingress    │
   │          │  ◄─────────────────────────────  │  Controller  │
   └──────────┘   (6) 200 OK + backend response   └──────┬──────┘
        ▲                                                 │
        │                                  (2) subrequest │
        │                                   calls auth-url │
        │                                                 ▼
        │                                          ┌─────────────┐
        │  (4) 302 login redirect                  │ oauth2-proxy │
        └──────────────────────────────────────── │ (auth svc)   │
                                                   └──────┬──────┘
                                  (3) no cookie -> 401    │
                                  (5) cookie -> 202       │
                                                   ┌─────────────┐
                                                   │  backend app │
                                                   └─────────────┘

Unpacking the flow step by step looks like this.

  1. The browser accesses a protected path (/dashboard).
  2. The Ingress controller does not forward to the backend directly; first it sends a subrequest to the auth service (auth-url). It carries along the original request's cookies and headers.
  3. oauth2-proxy checks whether the request has a valid session cookie. If not, it responds with 401.
  4. When the Ingress receives a 401, it 302-redirects the user to the login URL specified in auth-signin.
  5. After the user completes OIDC login and accesses again, this time there is a valid cookie, so oauth2-proxy responds with 202 (or 200) and at the same time returns user information in headers.
  6. The Ingress forwards those headers to the backend and sends the original request to the backend.

There is an important distinction to highlight here. In forward auth, what the auth service receives is a "subrequest," not the actual request body. The auth service decides whether to allow based only on the method, path, and headers (especially the cookies). That is why the auth service must be able to respond lightly and quickly.

Why oauth2-proxy

Anything can sit in the auth service slot of forward auth. It can be a tiny HTTP server you wrote yourself, or an integrated solution like Authelia or Authentik. But the most widely used is oauth2-proxy. The reasons are simple.

  • It supports OIDC and OAuth2 in a standard way, so it integrates with almost any IdP including Keycloak, Google, GitHub, and Azure AD.
  • It can store sessions either in the cookie or in Redis, giving you broad scalability options.
  • It natively supports forward auth mode (omitting --upstream and using only the /oauth2/auth endpoint).
  • After authentication, it can pass user information (email, groups, and so on) to the backend as response headers.

oauth2-proxy can operate in two modes. One is where it becomes the proxy directly in front of the backend and receives all traffic; the other is forward auth mode where it sits behind the Ingress and is responsible only for the auth verdict. For Ingress-level SSO we use the latter. The Ingress takes care of traffic routing, and oauth2-proxy responds only with the pass/deny verdict from the single /oauth2/auth endpoint.

Deploying oauth2-proxy

First we deploy oauth2-proxy onto the cluster. Let us assume we use Keycloak as the IdP. Using the Helm chart is the most convenient, but to see the behavior clearly we will look only at the core settings directly.

# oauth2-proxy-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy
  namespace: auth
spec:
  replicas: 2
  selector:
    matchLabels:
      app: oauth2-proxy
  template:
    metadata:
      labels:
        app: oauth2-proxy
    spec:
      containers:
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
          args:
            - --provider=oidc
            - --oidc-issuer-url=https://keycloak.example.com/realms/internal
            - --email-domain=*
            - --upstream=static://200
            - --http-address=0.0.0.0:4180
            - --reverse-proxy=true
            - --cookie-secure=true
            - --cookie-domain=.example.com
            - --whitelist-domain=.example.com
            - --set-xauthrequest=true
            - --pass-access-token=true
          env:
            - name: OAUTH2_PROXY_CLIENT_ID
              valueFrom:
                secretKeyRef:
                  name: oauth2-proxy-secret
                  key: client-id
            - name: OAUTH2_PROXY_CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: oauth2-proxy-secret
                  key: client-secret
            - name: OAUTH2_PROXY_COOKIE_SECRET
              valueFrom:
                secretKeyRef:
                  name: oauth2-proxy-secret
                  key: cookie-secret
          ports:
            - containerPort: 4180

Let me call out the arguments worth noting here.

  • --upstream=static://200: in forward auth mode, oauth2-proxy does not proxy to the actual backend. Since it only renders the auth verdict, we set a static response as the upstream.
  • --reverse-proxy=true: since it sits behind the Ingress, we turn this on so it trusts the X-Forwarded headers.
  • --cookie-domain=.example.com: setting the cookie domain to a parent domain lets multiple subdomain apps share the same session cookie. This is the crux of multi-app SSO.
  • --set-xauthrequest=true: it puts headers such as X-Auth-Request-User and X-Auth-Request-Email into the auth response. This lets the backend receive user information.

The cookie secret must be generated at exactly 32 bytes. You can create it like this.

# generate cookie secret (16, 24, or 32 bytes; 32 bytes recommended)
openssl rand -base64 32 | head -c 32 | base64

We create the service as well.

# oauth2-proxy-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: oauth2-proxy
  namespace: auth
spec:
  selector:
    app: oauth2-proxy
  ports:
    - name: http
      port: 4180
      targetPort: 4180

Integrating an OIDC IdP (Keycloak / Google)

For oauth2-proxy to integrate properly with the IdP, you must register a client (application) on the IdP side and specify the redirect URL accurately. oauth2-proxy's callback path is /oauth2/callback by default.

In Keycloak you configure it as follows.

  • Select one realm or create a new one (for example, internal).
  • Create a client and set the client type to OpenID Connect.
  • Register oauth2-proxy's callback address under Valid redirect URIs. For example: https://auth.example.com/oauth2/callback
  • Turn on Client authentication to make it a confidential client, and put the issued secret into the oauth2-proxy secret.

If you want group-based access control, add a mapper in Keycloak so the groups claim is included in the token, and give oauth2-proxy the --allowed-group argument.

# allow only a specific group
- --allowed-group=/platform-team
- --oidc-groups-claim=groups

When you use Google as the IdP, the issuer URL is fixed.

- --provider=google
- --oidc-issuer-url=https://accounts.google.com
- --email-domain=mycompany.com

Google does not include group information in the OIDC token by default, so you must use the --email-domain approach to allow only emails from a specific domain, or query Google Groups through a separate service account. You must take this difference into account when choosing your IdP.

Wiring forward auth in ingress-nginx

Now for the core. In ingress-nginx you enable forward auth with two annotations.

# protected-app-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: grafana
  namespace: monitoring
  annotations:
    nginx.ingress.kubernetes.io/auth-url: 'https://auth.example.com/oauth2/auth'
    nginx.ingress.kubernetes.io/auth-signin: 'https://auth.example.com/oauth2/start?rd=$escaped_request_uri'
    nginx.ingress.kubernetes.io/auth-response-headers: 'X-Auth-Request-User, X-Auth-Request-Email, X-Auth-Request-Groups'
spec:
  ingressClassName: nginx
  rules:
    - host: grafana.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: grafana
                port:
                  number: 3000

Let me unpack what the annotations mean.

  • auth-url: the address to which the auth subrequest is sent. It is oauth2-proxy's /oauth2/auth endpoint. This endpoint responds with 202 if the cookie is valid and 401 otherwise.
  • auth-signin: the login URL to which the user is sent when a 401 is received. It carries the originally intended address in the rd parameter so the user returns to that spot after login.
  • auth-response-headers: among the headers returned by the auth service, this specifies which ones to pass to the backend. The backend app can identify the user from these headers.

Note that oauth2-proxy itself must also be accessible from the outside, because the callback and login-start endpoints have to be reachable from the browser. So we put a separate Ingress in place for oauth2-proxy.

# oauth2-proxy-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: oauth2-proxy
  namespace: auth
spec:
  ingressClassName: nginx
  rules:
    - host: auth.example.com
      http:
        paths:
          - path: /oauth2
            pathType: Prefix
            backend:
              service:
                name: oauth2-proxy
                port:
                  number: 4180

In this structure, the Ingress of every protected app points at the same auth.example.com/oauth2/auth. Because the cookie domain is .example.com, once you log in the session is kept across all subdomain apps. This is the point at which multi-app SSO comes together.

Traefik's forwardAuth middleware

Traefik expresses forward auth not through annotations but through the Middleware CRD. The philosophy is a little different. In Traefik you define authentication as a reusable component called a "middleware" and attach it to a router.

# traefik-forwardauth-middleware.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oauth2-forwardauth
  namespace: auth
spec:
  forwardAuth:
    address: 'http://oauth2-proxy.auth.svc.cluster.local:4180/oauth2/auth'
    trustForwardHeader: true
    authResponseHeaders:
      - X-Auth-Request-User
      - X-Auth-Request-Email
      - X-Auth-Request-Groups

We attach this middleware to an IngressRoute.

# traefik-ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: grafana
  namespace: monitoring
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`grafana.example.com`)
      kind: Rule
      services:
        - name: grafana
          port: 3000
      middlewares:
        - name: oauth2-forwardauth
          namespace: auth

Traefik's forwardAuth and ingress-nginx's auth-url are conceptually identical but differ in a few ways.

  • ingress-nginx handles the 302 redirect directly through the auth-signin annotation on auth failure. Traefik's forwardAuth passes the response returned by the auth service (for example, a 401, or a 302 sent directly by the auth service) straight to the client. So you must configure oauth2-proxy to produce the appropriate redirect.
  • A Traefik middleware, once defined, is very clean to reuse across multiple IngressRoutes. In a multi-app environment this reusability is a big advantage.

The Contour case

Contour is an Envoy-based controller, and it implements external authorization through Envoy's ext_authz filter. In Contour you combine an ExtensionService and an HTTPProxy. Using gRPC-based ext_authz is the canonical approach, but when attaching oauth2-proxy people sometimes use the pattern of integrating over HTTP.

# contour-httpproxy.yaml
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
  name: grafana
  namespace: monitoring
spec:
  virtualhost:
    fqdn: grafana.example.com
    authorization:
      extensionRef:
        name: oauth2-proxy-authz
        namespace: auth
  routes:
    - conditions:
        - prefix: /
      services:
        - name: grafana
          port: 3000

Because Contour handles ext_authz at the Envoy level, the performance characteristics differ, and using a gRPC auth server is more efficient. However, oauth2-proxy provides HTTP authentication by default, so in a Contour environment you may need a separate component acting as an adapter. Comparing the three controllers in a single table looks like this.

Itemingress-nginxTraefikContour
Auth wiring methodauth-url annotationforwardAuth middleware CRDHTTPProxy authorization + ext_authz
Redirect handlingauth-signin does 302 directlypasses auth service responseEnvoy ext_authz response handling
Reusabilityannotations repeated per Ingressdefine middleware once, reusereuse ExtensionService
Data planenginxTraefik (Go)Envoy
Preferred auth protocolHTTP subrequestHTTP forward authgRPC ext_authz preferred

Comparison with basic auth and mTLS

Forward auth is not the only way to restrict access at the Ingress level. The simplest is basic auth, and the strongest is mTLS. Understanding where each belongs makes it clear when you should use forward auth.

Basic auth comes down to one or two annotations and a single secret.

# basic-auth-ingress.yaml
metadata:
  annotations:
    nginx.ingress.kubernetes.io/auth-type: basic
    nginx.ingress.kubernetes.io/auth-secret: basic-auth
    nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required'

It is very simple but its limits are clear. Per-user identification is hard, password rotation is cumbersome, SSO is impossible, and there is no group-based authorization. It is fine as temporary protection to block something off for a moment, but it is no replacement for production SSO.

mTLS is the opposite, being very strong. A client can pass through only by presenting a valid certificate. It suits machine-to-machine (service-to-service) communication rather than people, or internal systems with a very high security grade. However, the cost of distributing and managing a certificate on each user device is large, and the browser UX is not friendly.

MethodUser identificationSSOGroup authorizationAdoption difficultySuitable situation
basic authweakimpossiblenonevery lowtemporary protection, internal demos
forward auth (oauth2-proxy)strongpossiblepossiblemediummany employee-facing web apps
mTLScertificate-basednot applicablecertificate policyhighmachine-to-machine, high-security zones

In summary, if you want to layer consistent login onto many internal web applications that people access through a browser, forward auth is optimal. basic auth is too weak, and mTLS is overkill for human users.

Handling sessions and cookies

The stability of forward auth SSO essentially comes down to cookie management. Let me organize a few key points.

Cookie domain. This is the crux of multi-app SSO. You must set it to a parent domain, as in --cookie-domain=.example.com, so that grafana.example.com and kibana.example.com share the same session cookie. If the domains differ (for example, example.com and example.net), SSO will not work even with the same oauth2-proxy.

Cookie store. oauth2-proxy can put the session in the cookie itself (cookie session) or store it in Redis. If the token is large and exceeds the cookie size limit (usually 4KB), the cookie gets truncated and authentication breaks. Many group claims or carrying an access token easily exceed the limit, so in that case switching to a Redis session store is safer.

# use Redis session store
- --session-store-type=redis
- --redis-connection-url=redis://redis.auth.svc.cluster.local:6379

Cookie security attributes. In production we recommend --cookie-secure=true (HTTPS only) and, where possible, --cookie-samesite=lax. The OIDC redirect flow goes out to an external IdP and comes back, so if you set SameSite to strict the cookie may not ride along on the callback and you can fall into a login loop.

Session expiry. Control the session lifetime and refresh interval with --cookie-expire and --cookie-refresh. With refresh set appropriately, even when the access token expires it is silently renewed with the refresh token, so the user experience is not interrupted.

Completing multi-app SSO

Gathering all the pieces so far completes the multi-app SSO picture. There are three core principles.

  1. Share one oauth2-proxy. Do not put a separate oauth2-proxy per app. Have just one, and have every app's Ingress point at the same auth-url.
  2. Set the cookie domain to a parent domain. All apps must be subdomains under the same parent domain to share cookies.
  3. Handle authorization per app separately. Whether you "have logged in" is shared via SSO, but whether you "have permission to view this app" can differ per app. Set oauth2-proxy's group check differently per app, or let the backend decide using the passed group header.
                       ┌──────────────────────┐
                       │  oauth2-proxy (single) │
                       │  auth.example.com      │
                       │  cookie domain=.example.com │
                       └──────────┬───────────┘
                                  │ /oauth2/auth
        ┌─────────────┬───────────┼───────────┬─────────────┐
        ▼             ▼           ▼           ▼             ▼
  grafana.ex..   kibana.ex..  argo.ex..   wiki.ex..   reports.ex..
   (Ingress)      (Ingress)   (Ingress)   (Ingress)    (Ingress)
   auth-url ─────┴─── all point at the same auth-url ──┴──────┘

Once you log in, the session is kept across all five apps. When the user logs into grafana and then moves to kibana, there is no need to log in again. This is the essence of the SSO user experience.

Troubleshooting

When you attach forward auth SSO for the first time, you will almost certainly run into one or two problems. Let me organize the ones people frequently encounter.

Infinite redirect loop. This is the most common problem. You log in, but you keep coming back to the login page. The cause is usually a cookie domain mismatch or a SameSite setting issue. Check whether the auth-signin domain matches the cookie domain, and whether SameSite is too tight by being set to strict.

401 reaches the backend. If the auth-url subrequest responds with 401 but auth-signin does not work, the user sees the 401 directly. Recheck the spelling and URL of the auth-signin annotation.

Headers not delivered to the backend. If the backend does not receive X-Auth-Request-Email, check two things. Whether --set-xauthrequest=true is turned on in oauth2-proxy, and whether that header name is explicitly listed in the Ingress's auth-response-headers. Both must be present for the header to flow.

Authentication breaks because the cookie is too large. Login works, but refreshing the page asks you to log in again. There is a high chance the token exceeded 4KB and the cookie was truncated. Switch to a Redis session store.

Auth timeout on large request bodies. On large file upload paths, subrequest handling can be delayed. In ingress-nginx, inspect the buffer and timeout settings related to auth-request.

# check the denial reason in oauth2-proxy logs
kubectl logs -n auth deploy/oauth2-proxy -f | grep -i "denied\|401\|error"
# inspect the nginx config to see where the subrequest actually goes
kubectl exec -n ingress-nginx deploy/ingress-nginx-controller -- cat /etc/nginx/nginx.conf | grep -A5 auth_request

Operations and tuning

Forward auth generates one extra subrequest for every protected request. In other words, oauth2-proxy sits on the critical path of your traffic. In operations you must take care of the following.

  • Availability. If oauth2-proxy dies, all protected apps are blocked along with it. Set replicas to 2 or more and configure a PodDisruptionBudget.
  • Session store availability. If you use a Redis session, Redis is also a single point of failure. Consider an HA configuration.
  • Latency. The subrequest creates additional delay. Place oauth2-proxy in the same cluster, and if possible in the same availability zone, to reduce network round trips.
  • Caching. ingress-nginx can briefly cache the auth response for the same session. However, you must understand and apply the trade-off between security and freshness.
# cache auth response (ingress-nginx) - use with care
metadata:
  annotations:
    nginx.ingress.kubernetes.io/auth-cache-key: '$cookie_oauth2_proxy'
    nginx.ingress.kubernetes.io/auth-cache-duration: '200 202 5m, 401 30s'

Relationship to the Gateway API

As mentioned earlier, the Ingress API is frozen and the Gateway API is the successor standard. So what happens to forward auth?

Currently the Gateway API core spec does not standardize external auth. Authentication and authorization are still provided as extensions per controller implementation, or they rely on a data plane mechanism like ext_authz. The Gateway API has a policy attachment pattern under discussion for expressing auth policy, and some implementations provide a BackendTLSPolicy or a separate auth policy CRD.

In practical terms, the forward auth you build now with ingress-nginx annotations does not move over as-is when you migrate to the Gateway API. There is a high chance you will have to reconstruct it with a per-controller policy CRD or with Envoy ext_authz. So if you are designing a new cluster, it is wise to loosely couple the auth service and the routing configuration so you can keep the oauth2-proxy auth service itself and change only how it is wired to the Gateway API. oauth2-proxy is a reusable asset either way.

Closing

The essence of Ingress-level authentication is "moving the authentication responsibility from the application to the infrastructure." By combining forward auth and oauth2-proxy, you can layer enterprise-grade SSO even onto an app with no authentication feature at all, without touching a single line of code.

Let me restate the core. The external auth pattern is a structure where the Ingress throws a subrequest to the auth service in front of the backend, and oauth2-proxy is the most widely used as that auth service. ingress-nginx expresses the same pattern with annotations, Traefik with middleware, and Contour with ext_authz. Set the cookie domain to a parent domain and share a single oauth2-proxy, and multi-app SSO comes together.

Finally, do not forget the reality of 2026. The Ingress API is frozen and ingress-nginx is in maintenance mode. Build forward auth now, but loosely couple the auth service and routing so you prepare in advance for the migration into the Gateway API era. That is the wise choice.

References