OIDC Auth with Keycloak and Envoy Gateway

6 min read
envoy keycloak oidc kubernetes security

What happens when a bug in your application’s auth middleware lets an unauthenticated request through?

If your auth lives entirely in your application code, the answer is: that request reaches your business logic, touches your data, and you find out about it later. Maybe much later. The application was your only line of defense and it failed.

I spent years building systems where authentication was a middleware function, a decorator, a filter chain — always inside the application. It works until it does not. A misconfigured route. A refactor that accidentally removes an auth check. A new endpoint that someone forgets to protect. These are not hypothetical scenarios. They are Tuesday.

Defense in depth means unauthenticated requests should never reach your application in the first place. The gateway should handle authentication. The application should handle authorization. Two layers, two concerns, two chances to get it right.

The architecture

The separation is clean. Envoy Gateway sits in front of every service and answers one question: is this a valid user? It validates JWTs, handles OIDC redirect flows, and rejects anything that does not present a valid token. If a request passes the gateway, it is attached to a real identity.

The Ktor application server answers a different question: can this user do this thing? It extracts the principal from the forwarded JWT, checks organization membership from the token claims, and enforces per-organization access control. The gateway does not know about organizations. The application does not handle login flows.

This separation matters because authentication and authorization are fundamentally different concerns with different failure modes. Authentication is generic — the same OIDC flow works regardless of your domain. Authorization is domain-specific — it depends on your data model, your tenancy model, your business rules. Mixing them in the same layer means your domain code is coupled to your identity provider, and your gateway configuration needs to understand your domain.

Route-level security policies

Envoy Gateway’s SecurityPolicy resource can target either a Gateway listener or individual HTTPRoutes. I target individual HTTPRoutes, and the reason is that not every client authenticates the same way.

Browser users hitting the webapp or API need the full OIDC redirect flow. They arrive without a token, get redirected to Keycloak, authenticate, and come back with a session. Mobile apps and MCP clients handle their own OAuth flow — they already have a Bearer token when they call the API. Well-known routes like OAuth discovery endpoints must be completely unauthenticated.

If I attached the SecurityPolicy to the Gateway listener, every route would get the same treatment. An MCP client sending a valid Bearer token would get redirected to a Keycloak login page. A mobile app would hit the OIDC flow instead of having its JWT validated directly. Targeting HTTPRoutes means each class of client gets the auth flow it needs.

OIDC for browser clients

The SecurityPolicy for browser-facing routes handles both JWT validation and the OIDC redirect flow:

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: oidc-policy
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: api-route
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: webapp-route
  jwt:
    providers:
      - name: keycloak-gtd
        issuer: "<keycloak-issuer>"
        remoteJWKS:
          uri: "<keycloak-issuer>/protocol/openid-connect/certs"
  oidc:
    provider:
      issuer: "<keycloak-issuer>"
    clientID: "<client-id>"
    clientSecret:
      name: ktor-server-oidc-client-secret
    redirectURL: "<redirect-url>"
    logoutPath: "/logout"
    forwardAccessToken: true
    passThroughAuthHeader: true
    scopes: ["openid", "email", "profile", "organization:*"]

This targets api-route and webapp-route — the two HTTPRoutes that serve browser traffic. When an unauthenticated browser request arrives, Envoy redirects to Keycloak. After the user authenticates, Keycloak redirects back with an authorization code, Envoy exchanges it for tokens, and the request proceeds with a validated session.

The scopes list includes organization:*, which is a custom Keycloak scope that populates the JWT with the user’s organization memberships. This is what the backend uses for authorization decisions.

The OIDC client secret lives in a standard Kubernetes Secret:

apiVersion: v1
kind: Secret
metadata:
  name: ktor-server-oidc-client-secret
type: Opaque
data:
  client-secret: <base64-encoded-secret>

JWT-only for native clients

Mobile apps and MCP clients perform their own OAuth flow. They authenticate with Keycloak directly, obtain a token, and send it as a Bearer token in the Authorization header. These clients do not need — and actively do not want — the OIDC redirect flow.

A separate SecurityPolicy targets the mobile API route with JWT validation only:

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: jwt-policy-mobile
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: mobile-api-route
  jwt:
    providers:
      - name: keycloak-gtd
        issuer: "<keycloak-issuer>"
        remoteJWKS:
          uri: "<keycloak-issuer>/protocol/openid-connect/certs"

No OIDC block. Envoy validates the JWT signature against Keycloak’s JWKS endpoint, checks the issuer and expiration, and either forwards the request or rejects it with a 401. The client is responsible for token acquisition and refresh.

This same pattern works for any client that manages its own tokens. MCP clients connecting to the API, service-to-service calls with client credentials, third-party integrations — they all go through JWT-only routes.

JWT forwarding to the backend

Two configuration fields make the handoff from gateway to application work:

  • forwardAccessToken: true — Envoy includes the validated access token in the request to the backend
  • passThroughAuthHeader: true — the original Authorization header reaches the Ktor server unchanged

The Ktor server receives every request with a JWT that Envoy has already validated. It does not need to verify the signature, check the issuer, or hit the JWKS endpoint. It trusts the gateway for that. Instead, it parses the JWT claims, extracts the user’s organization memberships, and checks whether the user is authorized for the specific organization they are trying to access.

Browser -> Envoy Gateway (authenticate: valid user?) -> Ktor (authorize: correct org?)
Mobile  -> Envoy Gateway (validate JWT: valid token?) -> Ktor (authorize: correct org?)

The gateway answers “who are you?” The application answers “are you allowed to do this?”

Routes are defined per service

HTTPRoutes are defined with Helm templating so that each service gets its own set of routes. This is what makes the per-route SecurityPolicy approach work at scale. When a new service is deployed, its Helm chart declares the routes it needs, and the appropriate SecurityPolicy is applied based on the client type each route serves.

A browser-facing route gets the full OIDC policy. An API route for native clients gets JWT-only. A health check or OAuth discovery route gets no policy at all. The security model is explicit in the infrastructure configuration, not buried in application code.

The payoff

With this setup, an unauthenticated request never reaches the application. It does not matter if someone introduces a bug in the Ktor auth middleware, misconfigures a route handler, or forgets to add an authorization check to a new endpoint. The gateway already rejected the request. The application code is the second check, not the only check.

And because the concerns are separated, each layer is simpler. The Envoy Gateway configuration does not know about organizations, tenancy, or business rules. The Ktor server does not handle OIDC redirects, token exchange, or JWKS fetching. Each layer does one thing and does it well.

This is not a novel architecture. It is the same pattern that API gateways and reverse proxies have provided for years. But Envoy Gateway’s SecurityPolicy resource makes it declarative and Kubernetes-native. You define your auth requirements in YAML, attach them to the routes that need them, and the gateway enforces them before your application code ever executes. That is defense in depth, and it is worth the twenty minutes it takes to configure.