Secure Istio with Pomerium
What this guide does
You'll run the Pomerium Ingress Controller inside a Kubernetes cluster that has the Istio service mesh installed, so Pomerium handles single sign-on and authorization for traffic entering the cluster (north-south traffic) and Istio enforces that authenticated identity on traffic between services inside the cluster (east-west traffic).
Pomerium forwards a signed identity JWT to the upstream. Istio's RequestAuthentication and AuthorizationPolicy resources verify that JWT at each service's sidecar, so a request only reaches a service if it arrived through an authorized Pomerium route carrying a valid token. This brings you closer to complete zero trust: every hop authenticates, not just the front door.
When to use this guide
Use it when you already run Istio and want Pomerium as the identity-aware front door, with the mesh re-verifying the user's identity on internal hops rather than trusting the network. If you only need to publish a single HTTP app from Kubernetes without a mesh, the Ingress Controller quickstart is simpler. For a non-mesh app that consumes the identity JWT directly, see the Grafana guide.
Prerequisites
- A Kubernetes cluster with Istio installed and sidecar injection available.
- The Pomerium Ingress Controller installed with our Helm chart. This guide covers only the values that change for Istio; see the controller reference for the full spec.
- A domain you control for the routes. This guide uses
*.localhost.pomerium.ioplaceholders; replace them with your domain.
How it works
A single service can offload authentication and authorization to a sidecar, as described in Mutual Authentication with a Sidecar. In a mesh, every service runs a sidecar and the control plane configures them to mutually authenticate. Pomerium sits at the edge, and each upstream sidecar independently verifies the Pomerium-issued JWT before letting traffic through:
This is a simplified model that omits the additional traffic for authentication and authorization. See the Mutual Authentication page for details.
Configure Pomerium for Istio
Follow Install Pomerium using Helm to set up the Ingress Controller, with the adjustments below.
-
Label the Pomerium namespace for Istio sidecar injection:
kubectl label namespace pomerium istio-injection=enabled -
Update
pomerium-values.yamlwith the Istio-specific changes:pomerium-values.yamlauthenticate:idp:provider: 'google'clientID: YOUR_CLIENT_IDclientSecret: YOUR_SECRETproxy:deployment:podAnnotations:# Let external connections terminate on the Pomerium proxy directly# rather than on the sidecar.traffic.sidecar.istio.io/excludeInboundPorts: '80,443'config:rootDomain: localhost.pomerium.iogenerateTLS: false # offload TLS to the meshinsecure: true # disable TLS on internal Pomerium servicesingress:enabled: false # we use the Ingress Controller, not a static ingressingressController:enabled: trueservice:authorize:headless: false # route through the Istio service, not individual podsdatabroker:headless: falsePrefer to self-host the identity provider?This example uses Google as the identity provider (IdP). To run your own instead, follow Keycloak + Pomerium and set the matching
idpvalues. -
Apply the values and confirm injection worked. When you deploy a test service, its pod now shows two containers (the app plus the injected sidecar):
$ kubectl get podsNAME READY STATUS RESTARTS AGE...nginx-6955473668-cxprp 2/2 Running 0 19s
Configure Istio authentication for a service
With Pomerium installed, define the Istio rules that validate traffic to an upstream as coming through an authorized Pomerium route with an authenticated user token. The example below protects a test nginx service published at hello.localhost.pomerium.io. This assumes you already created a Pomerium route to the service with an Ingress, as in the quickstart; you'll update that Ingress in step 3.
-
Create
nginx-istio-policy.yaml, adjusting the selectors and hosts for your environment:nginx-istio-policy.yamlapiVersion: security.istio.io/v1kind: RequestAuthenticationmetadata:name: nginx-require-pomerium-jwtspec:selector:matchLabels:app.kubernetes.io/name: nginx # matches the label on the test servicejwtRules:- issuer: 'hello.localhost.pomerium.io' # the route's From host; this is the JWT iss claim. See /docs/reference/routes/jwt-issuer-formataudiences:- hello.localhost.pomerium.io # matches the route's host (spec.rules[].host in its Ingress)fromHeaders:- name: 'X-Pomerium-Jwt-Assertion'# Preferred in production: fetch the signing key over the route's# well-known endpoint. See# https://istio.io/latest/docs/reference/config/security/jwt/#JWTRulejwksUri: https://hello.localhost.pomerium.io/.well-known/pomerium/jwks.json# If your route host is not a fully qualified domain name (FQDN) Istio# can resolve, inline the key served at that path instead of jwksUri:#jwks: |# {"keys":[{"use":"sig","kty":"EC","kid":"e1c5d20b9cf771de0bd6038ee5b5fe831f771d3715b72c2db921611ffca7242f","crv":"P-256","alg":"ES256","x":"j8I1I7eb0Imr2pvxRk13cK9ZjAA3VPrdUIHkAslX2e0","y":"jfWNKJkq3b5hrTz2JsrXCcvgJCPP7QSFgX1ZT9wapIQ"}]}---apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata:name: nginx-require-pomerium-jwtspec:selector:matchLabels:app.kubernetes.io/name: nginxaction: ALLOWrules:- when:- key: request.auth.claims[aud]values: ['hello.localhost.pomerium.io'] # matches the route's host (spec.rules[].host in its Ingress)The
RequestAuthenticationresource tells Istio that, for pods labeledapp.kubernetes.io/name: nginx, a request must carry anX-Pomerium-Jwt-Assertionheader whose JWT has anissclaim equal to the route's From host (hello.localhost.pomerium.io) and is signed by the key Pomerium publishes for that route. Theissclaim is the route host, not the Authenticate service URL; see JWT Issuer Format. A request with no JWT fails everyAuthorizationPolicy; a request with an invalid JWT failsRequestAuthentication.The
AuthorizationPolicythen allows the request only if the validated JWT'saudclaim matches the route host. This confirms the traffic came through the expected Pomerium route. That matters in Pomerium Enterprise, where a manager of a separate Namespace could otherwise create a second route to the same service. -
Apply the resources:
kubectl apply -f nginx-istio-policy.yaml -
Visit
https://hello.localhost.pomerium.io. After signing in you should seeRBAC: access denied, which confirms the policy is enforced. Pomerium isn't yet forwarding the identity JWT, so the sidecar rejects the request. Add thepass_identity_headersannotation to the route's Ingress:example-ingress.yamlapiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: helloannotations:kubernetes.io/ingress.class: pomeriumcert-manager.io/issuer: pomerium-issuer# Add this line to forward the signed identity JWT to the upstream.ingress.pomerium.io/pass_identity_headers: 'true'ingress.pomerium.io/policy: '[{"allow":{"and":[{"domain":{"is":"example.com"}}]}}]' # your users' email domainspec:rules:- host: hello.localhost.pomerium.io # the route host; must match audiences/issuer abovehttp:paths:- path: /pathType: Prefixbackend:service:name: nginxport:number: 80 -
Apply the update and access the service again:
kubectl apply -f example-ingress.yamlThe request now carries the JWT, the sidecar validates it, and the test service responds.
Pass identity through to the upstream
The nginx example proves the mesh enforces Pomerium's identity. To show an upstream consuming that same identity, the Grafana guide configures Grafana to sign users in from the forwarded JWT. The pieces that change for a mesh deployment:
-
Annotate the Grafana Ingress for the Pomerium Ingress Controller and
pass_identity_headers, the same way as the nginx route above. Pointhostsandtlsatgrafana.localhost.pomerium.io. -
Configure Grafana to read the assertion. Add these values to the Grafana
grafana.inisettings:grafana.ini.yamlgrafana.ini:auth:disable_login_form: trueauth.jwt:enabled: trueheader_name: X-Pomerium-Jwt-Assertionemail_claim: emailjwk_set_url: https://grafana.localhost.pomerium.io/.well-known/pomerium/jwks.jsonThis tells Grafana to trust the email claim in the
X-Pomerium-Jwt-AssertionJWT and disables Grafana's own login form. See Grafana's JWT authentication docs for more options. -
Add the matching Istio policy. Use the same
RequestAuthentication/AuthorizationPolicyshape as nginx, with Grafana's selector and host, and setforwardOriginalToken: true. The nginx example omits this because it only gates on the JWT; Grafana needs the token forwarded so it can read the email claim and sign the user in:grafana-istio-policy.yamlapiVersion: security.istio.io/v1kind: RequestAuthenticationmetadata:name: grafana-require-pomerium-jwtspec:selector:matchLabels:app.kubernetes.io/name: grafanajwtRules:- issuer: 'grafana.localhost.pomerium.io' # the route's From host; this is the JWT iss claimaudiences:- grafana.localhost.pomerium.iofromHeaders:- name: 'X-Pomerium-Jwt-Assertion'forwardOriginalToken: truejwksUri: https://grafana.localhost.pomerium.io/.well-known/pomerium/jwks.json---apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata:name: grafana-require-pomerium-jwtspec:selector:matchLabels:app.kubernetes.io/name: grafanaaction: ALLOWrules:- when:- key: request.auth.claims[aud]values: ['grafana.localhost.pomerium.io']Apply it with
kubectl apply -f grafana-istio-policy.yaml. Grafana now signs you in as the user identified by the Pomerium JWT.
Verify the setup
- The route requires authentication. Open
https://hello.localhost.pomerium.ioin a fresh browser. You should be redirected to sign in, not straight to the service. - The mesh rejects unauthenticated traffic. Before adding
pass_identity_headers, an authenticated browser request still returnsRBAC: access deniedbecause the sidecar sees no valid JWT. - Authorized traffic reaches the service. After adding
pass_identity_headersand applying the Istio policy, the same request succeeds. - The upstream consumes identity. With Grafana configured, opening
https://grafana.localhost.pomerium.iosigns you in automatically as the user from the Pomerium claim.
Common failure modes
RBAC: access deniedafter sign-in. The sidecar got no valid JWT. Confirmingress.pomerium.io/pass_identity_headers: 'true'is on the route's Ingress and thatRequestAuthenticationsucceeded.Jwt issuer is not configuredor signature errors. TheissuerinRequestAuthenticationmust match the JWT'sissclaim exactly, which is the route's From host (for examplehello.localhost.pomerium.io), not the authenticate service. See JWT Issuer Format. Also confirmjwksUriis reachable by Istio; for hosts that aren't a fully qualified domain name (FQDN), inline thejwksvalue instead.audmismatch. TheaudiencesandAuthorizationPolicyvaluesmust match the route host (spec.rules[].hostin the Ingress) precisely.- No sidecar injected. Confirm
istio-injection=enabledon the namespace and that the pod shows2/2containers.
Security considerations
- The whole model depends on upstreams being reachable only through the mesh. Istio enforces the JWT at the sidecar, so do not expose services on a
NodePortor externalLoadBalancerthat bypasses the mesh. - A forged header is only a risk if a service trusts it without verification. Here Istio verifies the JWT's signature and
audat every hop, so a fakeX-Pomerium-Jwt-Assertionis rejected. Keep thesigning_keyprivate. - Scope each route's Pomerium policy (group or domain) to who should reach that service. The
AuthorizationPolicyaudcheck prevents a second route in another namespace from reaching the same upstream.
Compatibility
The manifests use the security.istio.io/v1 Istio API and the ingress.pomerium.io annotations, which apply to Istio 1.18 and later and current Pomerium Ingress Controller releases. The JWT iss value follows the JWT Issuer Format (the route's From host), and the JSON Web Key Set (JWKS) endpoint and trust model match the JWT Authentication and Mutual Authentication references.
Rollback and cleanup
To revert the mesh integration:
-
Remove the Istio policies you applied. Once they're gone, the sidecar no longer requires the JWT:
kubectl delete -f nginx-istio-policy.yamlkubectl delete -f grafana-istio-policy.yaml -
Remove the
ingress.pomerium.io/pass_identity_headersannotation from each route's Ingress and re-apply the Ingress. -
To undo sidecar injection on the Pomerium namespace, remove the label and restart the workloads so they come back without a sidecar (removing the label does not strip the sidecar from already-running pods):
kubectl label namespace pomerium istio-injection-kubectl rollout restart deployment -n pomerium -
Roll back the Helm values change with
helm rollback pomerium(or re-apply your previouspomerium-values.yaml).