Skip to main content

Secretless Microsoft Entra ID Authentication for AKS with Istio and oauth2-proxy

· 15 min read
Diego Casati
Principal Cloud Architect, Azure Global Black Belt
Dominique St-Amand
Principal Cloud Architect, Azure Global Black Belt

Sometimes, you need to add authentication to an application without modifying its source code. It might be off-the-shelf software or an older in-house application built before modern authentication protocols such as OpenID Connect or SAML 2.0 became commonplace. So how can you protect it with modern authentication without changing the application itself?

This is where a reverse proxy can handle authentication on the application's behalf.

In this post we'll show how to protect the AKS Store Demo with an Istio Gateway (using the Gateway API), oauth2-proxy, and Microsoft Entra ID. Istio service mesh delegates every protected request to oauth2-proxy through Envoy external authorization. Envoy's external authorization filter delegates authorization decisions to an external HTTP or gRPC service, allowing flexible and centralized access control. Oauth2-proxy uses the authorization code flow with PKCE (Proof Key for Code Exchange) to authenticate the user. It uses AKS Workload Identity to authenticate the app registration when redeeming the authorization code.

There is no Entra application password to create, store in Kubernetes, or rotate.

Architecture

                +----------------------------+
| Microsoft Entra ID |
| authorize + token endpoints|
+-------------^--------------+
|
code + PKCE + federated
client assertion
|
+---------+ HTTPS +-------------------+-------------------+
| Browser |------------>| Azure Load Balancer and Istio Gateway |
+---------+ | Envoy proxy: TLS + ext_authz filter |
+----------+-------------------+---------+
| |
authorization|check |allowed request
v v
+-------------------+ +---------------------+
| oauth2-proxy:4180 | | AKS Store Front |
| ClusterIP service | | ClusterIP service |
+---------^---------+ +---------------------+
|
projected service account token
|
+---------+---------+
| AKS OIDC issuer |
+-------------------+

Microsoft Entra ID trusts the token issuer and subject through the federated credential.

The gateway is the key security boundary in this architecture because it ensures every request passes through Istio and the authentication flow before reaching the application. To prevent users from bypassing that boundary, the store services are changed from LoadBalancer to ClusterIP. This removes their direct public endpoints and makes the Istio gateway the only external path to the application.

The authentication path has two distinct identities:

  1. The browser user authenticates with Microsoft Entra ID through the OpenID Connect authorization code flow.
  2. oauth2-proxy authenticates itself to Entra through Workload Identity. It uses the projected Kubernetes service account token as a federated client assertion when exchanging the authorization code for tokens.

Workload Identity replaces the long-lived OAuth client secret with a short-lived, federated Kubernetes service account token. This reduces the risk associated with storing persistent credentials in the cluster while easing the operational burden on platform teams, which no longer need to distribute, synchronize, and rotate an Entra client secret through mechanisms such as the Secrets Store CSI Driver.

Authentication Sequence

Prerequisites

  • An Azure subscription and Microsoft Entra tenant
  • Azure CLI authenticated with az login
  • Permissions to create AKS clusters, Entra applications, service principals, and federated credentials
  • kubectl, Helm 3, dig, openssl, sed, and base64
  • A public DNS hostname, or sslip.io for a quick test

The example uses these versions:

ComponentVersion
Kubernetes1.36.1
AKS managed Istioasm-1-29
oauth2-proxyv7.15.2
cert-managerv1.21.1
TLS for the demo

This setup uses cert-manager and Let's Encrypt to add TLS (commonly referred to as SSL) to the public demo endpoint. They are included for demonstration purposes and are not required by the Entra authentication flow itself.

For the Istio asm / AKS compatibility, please refer to the documentation.

Create the AKS Cluster

The cluster needs the OIDC issuer, Workload Identity, and Gateway API enabled (to work with the Istio ingress). The OIDC issuer publishes the keys Entra uses to validate projected service account tokens.

export CLUSTER_NAME="aks-oauth2-proxy-POC-01"
export RESOURCE_GROUP="rg-oauth2-proxy-POC"
export LOCATION="westus3"
export KUBERNETES_VERSION="1.36.1"
export KUBECONFIG="${PWD}/cluster.config"

az group create \
--name "${RESOURCE_GROUP}" \
--location "${LOCATION}"

az aks create \
--name "${CLUSTER_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--location "${LOCATION}" \
--kubernetes-version "${KUBERNETES_VERSION}" \
--node-count 2 \
--node-vm-size Standard_D4s_v4 \
--network-plugin azure \
--network-plugin-mode overlay \
--load-balancer-sku standard \
--generate-ssh-keys \
--enable-oidc-issuer \
--enable-workload-identity \
--enable-gateway-api \
--enable-azure-service-mesh \
--revision asm-1-29

az aks get-credentials \
--name "${CLUSTER_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--file "${KUBECONFIG}"

Verify the two identity features before continuing:

az aks show \
--name "${CLUSTER_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--query '{oidc:oidcIssuerProfile.enabled,workloadIdentity:securityProfile.workloadIdentity.enabled,issuer:oidcIssuerProfile.issuerUrl}'

Both boolean values should be true, and issuer should contain the AKS OIDC issuer URL.

Deploy the Store Without a Public Bypass

The upstream store manifest exposes store-front and store-admin as public LoadBalancer services. That would create a second path around the authentication policy, so I convert both to ClusterIP.

kubectl create namespace aks-store-demo

kubectl apply \
--namespace aks-store-demo \
--filename https://raw.githubusercontent.com/Azure-Samples/aks-store-demo/refs/heads/main/aks-store-all-in-one.yaml

for service in store-front store-admin; do
kubectl patch service "${service}" \
--namespace aks-store-demo \
--type json \
--patch='[
{"op":"remove","path":"/spec/ports/0/nodePort"},
{"op":"replace","path":"/spec/type","value":"ClusterIP"}
]'
done

Verify there is no direct public endpoint:

kubectl get service store-front store-admin --namespace aks-store-demo

Both services should show ClusterIP under TYPE.

Create the Entra Application

First, get the AKS issuer and tenant ID:

export OIDC_ISSUER=$(az aks show \
--name "${CLUSTER_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--query oidcIssuerProfile.issuerUrl \
--output tsv)

export TENANT_ID=$(az account show \
--query tenantId \
--output tsv)

The Entra redirect URI must be known before the app registration is created. The manual sequence below assumes that you already have a public hostname and will point its DNS record to the Gateway address after creating the Gateway. Set that hostname now:

export STORE_URL="https://store.example.com"
export REDIRECT_URL="${STORE_URL}/oauth2/callback"
export APP_NAME="aks-store-demo-oauth2-proxy"

For a quick test with sslip.io, the order is different: create the Gateway first, wait for its public IP address, derive STORE_URL from that address, and then return to this section to create the Entra application. The deployment script described later automates this bootstrap sequence.

Create a single-tenant web application and its service principal:

export CLIENT_ID=$(az ad app create \
--display-name "${APP_NAME}" \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "${REDIRECT_URL}" \
--query appId \
--output tsv)

az ad sp create --id "${CLIENT_ID}"

Do not run az ad app credential reset. That command creates the application password we are intentionally removing from this design.

Federate the App Registration with Kubernetes

oauth2-proxy uses the app registration's client ID and presents the projected service account token as its client assertion.

az ad app federated-credential create \
--id "${CLIENT_ID}" \
--parameters "{
\"name\": \"oauth2-proxy\",
\"issuer\": \"${OIDC_ISSUER}\",
\"subject\": \"system:serviceaccount:aks-store-demo:oauth2-proxy\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"

The issuer, subject, and audience must match exactly. A mismatch produces an Entra token exchange failure even when the pod otherwise looks healthy.

Configure the oauth2-proxy Workload Identity

The Kubernetes service account points to the Entra app registration client ID:

apiVersion: v1
kind: ServiceAccount
metadata:
name: oauth2-proxy
namespace: aks-store-demo
annotations:
azure.workload.identity/client-id: "<entra-application-client-id>"

The pod must also carry the Workload Identity label:

spec:
template:
metadata:
labels:
app: oauth2-proxy
azure.workload.identity/use: "true"
spec:
serviceAccountName: oauth2-proxy

The AKS Workload Identity webhook looks for this label when it creates the pod. When the label is present, the webhook gives the pod access to a short-lived service account token and tells oauth2-proxy where to find it.

Restart pods after changing the service account

Workload Identity is configured only when a pod starts. If you update the service account annotation or change the client ID, restart the deployment so AKS can recreate the pods with the updated configuration.

Configure oauth2-proxy Without a Client Secret

The key oauth2-proxy options are:

args:
- --provider=entra-id
- --entra-id-federated-token-auth=true
- --scope=openid
- --code-challenge-method=S256
- --upstream=static://200
- --reverse-proxy=true
- --cookie-secure=true
- --cookie-samesite=lax
- --set-xauthrequest=true
- --pass-access-token=true

--entra-id-federated-token-auth=true tells oauth2-proxy to use the projected Workload Identity token instead of OAUTH2_PROXY_CLIENT_SECRET when calling the Entra token endpoint.

The deployment still loads a Kubernetes Secret:

envFrom:
- secretRef:
name: oauth2-proxy

That Secret does not contain an Entra client secret. It contains the client ID, OIDC issuer, callback URL, and the cookie encryption key:

kubectl create secret generic oauth2-proxy \
--namespace aks-store-demo \
--from-literal=OAUTH2_PROXY_CLIENT_ID="${CLIENT_ID}" \
--from-literal=OAUTH2_PROXY_COOKIE_SECRET="$(openssl rand -base64 32 | tr '+/' '-_')" \
--from-literal=OAUTH2_PROXY_OIDC_ISSUER_URL="https://login.microsoftonline.com/${TENANT_ID}/v2.0" \
--from-literal=OAUTH2_PROXY_REDIRECT_URL="${REDIRECT_URL}"

The cookie secret is still sensitive because oauth2-proxy uses it to protect browser sessions. Secretless here means there is no long-lived Entra application credential. It does not mean the workload has no sensitive runtime state.

Register oauth2-proxy as an Istio External Authorizer

Istio needs an extension provider that points Envoy to the oauth2-proxy service:

apiVersion: v1
kind: ConfigMap
metadata:
name: istio-shared-configmap-asm-1-29
namespace: aks-istio-system
data:
mesh: |-
extensionProviders:
- name: oauth2-proxy
envoyExtAuthzHttp:
service: oauth2-proxy.aks-store-demo.svc.cluster.local
port: 4180
includeRequestHeadersInCheck:
- cookie
- authorization
headersToUpstreamOnAllow:
- x-auth-request-user
- x-auth-request-email
- x-auth-request-access-token
headersToDownstreamOnAllow:
- set-cookie
headersToDownstreamOnDeny:
- set-cookie
- content-type
- location
Forward the ID token to the application

The configuration above does not add an Authorization header to the request sent to the application. If the application needs the ID token as Authorization: Bearer <ID-token>, add --set-authorization-header=true to the oauth2-proxy container arguments and add authorization to headersToUpstreamOnAllow. Both settings are required: oauth2-proxy produces the response header, and Envoy copies it into the request sent to the application.

The location and set-cookie response headers matter. Without them, Envoy can deny the unauthenticated request but the browser will not receive the complete sign-in redirect and CSRF cookie from oauth2-proxy.

Preserve existing mesh configuration

The shared ConfigMap name includes the installed Istio revision, following the format istio-shared-configmap-<asm-revision>. Confirm your revision and update the name accordingly.

Before applying this manifest, check whether the ConfigMap already exists:

kubectl get configmap "istio-shared-configmap-asm-1-29" \
--namespace aks-istio-system \
--output yaml

If it exists, merge the extensionProviders configuration with the existing content under data.mesh. Applying the example unchanged replaces the entire data.mesh value and can remove other mesh settings.

See Configure Istio-based service mesh add-on for AKS for naming, configuration, and upgrade guidance.

Azure support boundaries

Any issues associated with extension tools are outside the support boundary of the Istio add-on.

Apply the Authorization Policy

The Gateway routes /oauth2 callbacks to oauth2-proxy (as described in the documentation) and sends application traffic to store-front:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: store
namespace: aks-store-demo
spec:
parentRefs:
- name: store-external
sectionName: https
hostnames:
- "store.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /oauth2
backendRefs:
- name: oauth2-proxy
port: 4180
- backendRefs:
- name: store-front
port: 80

The CUSTOM policy selects the generated Gateway workload and delegates checks to the extension provider:

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: store-oauth2
namespace: aks-store-demo
spec:
selector:
matchLabels:
gateway.networking.k8s.io/gateway-name: store-external
action: CUSTOM
provider:
name: oauth2-proxy
rules:
- to:
- operation:
notPaths:
- /oauth2/*
- /.well-known/acme-challenge/*

The two exclusions are intentional:

  • /oauth2/* must reach oauth2-proxy so it can start sign-in and process the callback.
  • /.well-known/acme-challenge/* must remain reachable so cert-manager can issue and renew the certificate with HTTP-01 when you use Let's Encrypt for certificate management.

Keep this list narrow. Every excluded path bypasses the external authorization check.

Deploy the Complete Sample

The repository includes an idempotent deployment script that creates or updates the Entra application, direct federated credential, TLS certificate, Gateway, routes, oauth2-proxy, and authorization policy.

export KUBECONFIG="${PWD}/cluster.config"
export RESOURCE_GROUP="rg-oauth2-proxy-POC"
export CLUSTER_NAME="aks-oauth2-proxy-POC-01"

# Omit STORE_URL to derive an sslip.io hostname from the gateway IP.
bash deploy-store.sh

For a custom hostname, create its public A record first and rerun:

export STORE_URL="https://store.example.com"
bash deploy-store.sh

The hostname must resolve to the Gateway address before cert-manager starts the HTTP-01 challenge.

Verify the Authentication Path

Check the Kubernetes resources:

kubectl get gateway,httproute \
--namespace aks-store-demo

kubectl get pods \
--namespace aks-store-demo \
--selector app=oauth2-proxy

kubectl get authorizationpolicy store-oauth2 \
--namespace aks-store-demo

kubectl get certificate store-tls \
--namespace aks-store-demo

An unauthenticated request should return a redirect to Microsoft Entra ID:

curl --head "${STORE_URL}"

Expected result:

HTTP/2 302
location: https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize?...
set-cookie: _oauth2_proxy_csrf=...

Open the URL in a private browser window. After sign-in, Entra redirects to /oauth2/callback, oauth2-proxy redeems the code with PKCE and its federated client assertion, sets the session cookie, and redirects back to /.

Confirm no application password remains:

az ad app credential list \
--id "${CLIENT_ID}" \
--output table

The list should be empty unless the application has another intentionally managed credential.

Troubleshooting

The callback still asks for a client secret

Confirm all three pieces are present:

kubectl get deployment oauth2-proxy \
--namespace aks-store-demo \
--output jsonpath='{.spec.template.spec.containers[0].args}'

kubectl get serviceaccount oauth2-proxy \
--namespace aks-store-demo \
--output yaml

az ad app federated-credential list \
--id "${CLIENT_ID}" \
--output table

You need --entra-id-federated-token-auth=true, the service account client ID annotation, and a federated credential on the same Entra app registration.

The pod does not have a projected token

Check the pod label and injected environment:

POD=$(kubectl get pod \
--namespace aks-store-demo \
--selector app=oauth2-proxy \
--output jsonpath='{.items[0].metadata.name}')

kubectl get pod "${POD}" \
--namespace aks-store-demo \
--output jsonpath='{.metadata.labels.azure\.workload\.identity/use}'

kubectl exec "${POD}" \
--namespace aks-store-demo \
-- printenv AZURE_FEDERATED_TOKEN_FILE

If the label or token path is missing, fix the pod template and restart the deployment.

Certificate issuance redirects to sign-in

The ACME solver path is reaching the authorization policy. Confirm /.well-known/acme-challenge/* is excluded and inspect the challenge:

kubectl get challenge,order,certificaterequest \
--namespace aks-store-demo

Users can reach the store without signing in

Check for another public service or ingress path:

kubectl get service,ingress,gateway,httproute \
--all-namespaces

The store front and admin services should remain ClusterIP. Authentication at one ingress path does not protect a second public path.

Cleanup

kubectl delete namespace aks-store-demo

az ad app delete --id "${CLIENT_ID}"

# Remove cert-manager only if this demo installed it exclusively.
helm uninstall cert-manager --namespace cert-manager
kubectl delete namespace cert-manager

# Delete the cluster only if it was created for this exercise.
az group delete \
--name "${RESOURCE_GROUP}" \
--yes \
--no-wait

Conclusion

The useful part of this design is not just putting oauth2-proxy in front of an application. It is keeping the authentication boundary at the Gateway while removing the long-lived Entra credential from the workload.

The pieces that make it work are:

  • Istio external authorization pauses protected requests at Envoy and sends them to oauth2-proxy before the application sees them.
  • AKS Workload Identity projects a signed Kubernetes service account token into the oauth2-proxy pod.
  • Direct app registration federation lets oauth2-proxy use that token as its client assertion when it calls Microsoft Entra ID.
  • Narrow callback and ACME exclusions keep sign-in and certificate renewal working without opening the rest of the application.

The result is a conventional OpenID Connect experience for users and one less credential for operators to rotate.

References