Skip to content

OIDC at the Gateway

The previous lesson gave us a shopmicro realm and a shopmicro-gateway OIDC client, but nothing enforces authentication yet. This lesson closes that gap.

We deploy oauth2-proxy into the platform layer, wire it to the Keycloak client, and put it directly in ShopMicro’s request path: oauth2-proxy’s own ingress takes over the ShopMicro hostname and proxies authenticated traffic through to the gateway Service behind it. The result: an unauthenticated request to the gateway gets a 302 to the Keycloak login page; only after a successful login does the request reach ShopMicro.

ShopMicro’s own services shouldn’t each re-implement OIDC. Authentication is a cross-cutting concern, so we enforce it once, at the edge, in front of the gateway — the app stays oblivious to how login works and just receives requests that have already been authenticated.

There are two ways to wire oauth2-proxy to an ingress: an external-auth annotation, where the ingress controller makes a per-request subrequest to oauth2-proxy and only forwards what passes, or putting oauth2-proxy directly in the request path as the ingress’s backend, with oauth2-proxy itself proxying through to the real service. External-auth is the lighter touch, but it’s an nginx-ingress-controller feature (nginx.ingress.kubernetes.io/auth-url) with no equivalent on the AWS Load Balancer Controller, GKE’s ingress, or AGIC — the three controllers this course actually installs (see Ingress and per-cloud config). CloudDeploy doesn’t add ingress-nginx as a fourth controller just to get that one annotation: it’s retiring upstream in March 2026, and running it alongside three cloud-native controllers would undo the honest, per-cloud-native story the ingress lesson just told. So CloudDeploy puts oauth2-proxy directly in the path instead — one pattern that behaves identically behind ALB, GKE’s ingress, and AGIC. oauth2-proxy speaks OIDC to Keycloak, manages the session cookie, handles the redirect dance, and forwards anything authenticated straight through to the ShopMicro gateway — none of which belongs in application code.

oauth2-proxy as a reverse proxy in the request path vs. an ingress controller’s external-auth annotation

  • Pros: Works identically behind any ingress controller — ALB, GKE’s ingress, AGIC, or anything else — because “the ingress backend is a Service that proxies onward” needs no controller-specific feature. One pattern, no per-cloud variation, and no fourth ingress controller to install.
  • Cons: Every request, not just the auth check, now makes an extra hop through oauth2-proxy before reaching the gateway. The external-auth annotation is lighter on the hot path and keeps ShopMicro’s own ingress in charge of routing — but it only exists on nginx-class ingress controllers, which this course doesn’t run (see the ingress lesson).

oauth2-proxy at the edge vs. per-service token validation in ShopMicro

  • Pros: One place to configure and audit; services never handle tokens or redirects; you can protect the whole app before touching a line of Go.
  • Cons: The edge only proves the user is authenticated, not what they’re allowed to do — fine-grained authorization still has to live in the services. Edge auth is a gate, not a policy engine.

Deploy oauth2-proxy via its Helm chart, configured with the keycloak-oidc provider pointed at the realm’s issuer URL. It reads the client id/secret produced by the Keycloak lesson.

resource "kubernetes_secret" "oauth2_proxy" {
metadata {
name = "oauth2-proxy"
namespace = var.platform_namespace
}
data = {
"client-id" = keycloak_openid_client.gateway.client_id
"client-secret" = keycloak_openid_client.gateway.client_secret
# 32 random bytes, base64url-encoded
"cookie-secret" = var.oauth2_proxy_cookie_secret
}
}
resource "helm_release" "oauth2_proxy" {
name = "oauth2-proxy"
namespace = var.platform_namespace
repository = "https://oauth2-proxy.github.io/manifests"
chart = "oauth2-proxy"
version = var.oauth2_proxy_chart_version
values = [yamlencode({
config = {
existingSecret = kubernetes_secret.oauth2_proxy.metadata[0].name
configFile = <<-CFG
provider = "keycloak-oidc"
oidc_issuer_url = "https://${var.keycloak_hostname}/realms/shopmicro"
redirect_url = "https://${var.shopmicro_hostname}/oauth2/callback"
upstreams = ["http://shopmicro-gateway.shopmicro.svc.cluster.local:80"]
email_domains = ["*"]
cookie_secure = true
reverse_proxy = true
CFG
}
ingress = {
enabled = true
className = var.ingress_class
path = "/"
hosts = [var.shopmicro_hostname]
}
})]
depends_on = [keycloak_openid_client.gateway]
}

upstreams is what makes this the request-path pattern: oauth2-proxy’s path = "/" ingress now owns the whole ShopMicro hostname, and oauth2-proxy itself proxies anything with a valid session straight through to the shopmicro-gateway Service — not just the /oauth2/* callback routes. reverse_proxy = true tells oauth2-proxy to trust the X-Forwarded-* headers the ingress sets.

Because oauth2-proxy’s ingress now claims var.shopmicro_hostname at path = "/", ShopMicro’s own ingress for that same host has to step aside — one hostname, one ingress object. Turn it off in each per-cloud values file:

# charts/shopmicro/values-aws.yaml (amendment)
ingress:
enabled: false # oauth2-proxy's ingress fronts this host instead

Make the same one-line change in values-gcp.yaml and values-azure.yaml. ShopMicro’s Service is untouched and still reachable in-cluster — only the public ingress for its hostname moves to oauth2-proxy.

Add the new inputs alongside the Keycloak ones from the last lesson:

inputs = {
# ...existing keycloak inputs...
oauth2_proxy_cookie_secret = get_env("OAUTH2_PROXY_COOKIE_SECRET")
oauth2_proxy_chart_version = "10.7.0"
}

Apply, confirm oauth2-proxy is running, then prove the redirect works:

Terminal window
cd live/aws/platform
terragrunt apply
kubectl -n platform get pods -l app.kubernetes.io/name=oauth2-proxy
# NAME READY STATUS RESTARTS AGE
# oauth2-proxy-6c8d...-abcde 1/1 Running 0 90s

Now hit the gateway with no session cookie. An unauthenticated request must be redirected to login, not served the app:

Terminal window
curl -sI https://shop.aws.clouddeploy.example.com/
# HTTP/2 302
# location: https://shop.aws.clouddeploy.example.com/oauth2/start?rd=%2F

Follow the sign-in path and confirm it hands off to Keycloak:

Terminal window
curl -sI "https://shop.aws.clouddeploy.example.com/oauth2/start?rd=%2F"
# HTTP/2 302
# location: https://id.aws.clouddeploy.example.com/realms/shopmicro/protocol/openid-connect/auth?client_id=shopmicro-gateway&...

The final location pointing at the realm’s openid-connect/auth endpoint is the proof: the gateway now sends anonymous traffic to Keycloak instead of into ShopMicro. Open the URL in a browser, log in, and you land back on ShopMicro authenticated.

Check your understanding:

  1. Why does putting oauth2-proxy directly in the request path behave identically behind ALB, GKE’s ingress, and AGIC, when the external-auth annotation pattern would not?
  2. Why can’t oauth2-proxy’s ingress and ShopMicro’s own ingress both claim var.shopmicro_hostname, and what one-line change hands the hostname to oauth2-proxy?
  3. Edge auth proves the user logged in. What does it deliberately not do, and where does that responsibility land?
  4. upstreams points oauth2-proxy at shopmicro-gateway.shopmicro.svc.cluster.local, an in-cluster Service address, not the public ingress hostname. Why is that the right choice?

You deployed oauth2-proxy into the platform layer, wired it to the Keycloak OIDC client, and put it directly in ShopMicro’s request path — its ingress now owns the hostname and proxies authenticated traffic straight through to the gateway Service — then verified that an unauthenticated request gets a 302 to the Keycloak login page. ShopMicro is authenticated at the edge, the same way behind ALB, GKE’s ingress, and AGIC, with the app itself unchanged.

That completes identity. Next comes the other half of runtime control — deciding what the app does without redeploying it: Feature Flags (GrowthBook) →.