Skip to main content

Linkerd meets Istio | Runbook Live, Oct 15

Register
close

Kubernetes Workload Identity and Authorization with Linkerd

Linkerd Production Readiness Pre-Launch Checklist

Download Checklist

Oct 2026

TL;DR

  • Kubernetes workload identity lets a service authenticate a workload by its ServiceAccount identity.
  • Linkerd validates a projected ServiceAccount token and issues a short-lived certificate. Its proxies use that certificate to authenticate service-to-service connections.
  • Kubernetes RBAC governs requests to the Kubernetes API, and cloud IAM governs access to cloud resources. Linkerd authorization governs calls between services.
  • Linkerd’s cluster default permits unauthenticated inbound traffic. A Server defaults to deny, and AuthorizationPolicy resources grant access to named callers.
  • You can start in audit mode, check which callers reach each service, and add allow policies before switching Servers to deny.

Three kinds of identity Kubernetes gets confused with

Kubernetes API RBAC controls what a ServiceAccount can do through the Kubernetes API. When a pod requests a Secret, the API server authenticates its ServiceAccount token, and RBAC checks whether that account can read the Secret. RBAC doesn't inspect calls between pods.

Cloud workload identity gives pods credentials for resources outside the cluster. AWS IRSA and EKS Pod Identity connect Kubernetes ServiceAccounts to AWS IAM roles. Workload Identity Federation for GKE and Microsoft Entra Workload ID provide paths to Google Cloud and Azure credentials. Cloud permissions can govern access to an S3 bucket, for example, but they don't decide whether one pod can call another.

Service-to-service authorization governs those in-cluster calls. A service mesh can authenticate the calling workload and check an inbound policy before an HTTP or gRPC request reaches the application. You could allow a checkout service to call payments and reject the same request from a reporting service, regardless of either service's Kubernetes API permissions.

AWS, Azure, and Linkerd all start with a ServiceAccount token projected into the pod for a specified audience; GKE's metadata server requests it instead. A recipient must find its own identifier in the audience or reject the token, so sharing a ServiceAccount doesn't make a cloud credential valid for service-to-service calls.

How Linkerd derives workload identity from ServiceAccounts

Linkerd gives a meshed workload the identity of its Kubernetes ServiceAccount. The proxy reads the projected ServiceAccount token (audience identity.l5d.io) that the injector mounted and sends it to Linkerd's identity controller. The controller asks the Kubernetes API server to validate it through TokenReview, then issues the proxy a certificate valid for 24 hours.

The certificate names the ServiceAccount in the form <sa>.<ns>.serviceaccount.identity.<trust-domain>. A trust anchor signs Linkerd’s issuer certificate, and the issuer signs each workload certificate. Other meshed proxies use that chain to verify the identity presented during mutual TLS.

You can check what a proxy presents and what token its pod received.

linkerd identity <pod> -n <namespace>
kubectl get pod <pod> -n <namespace> -o yaml

In the pod spec, look for the linkerd-identity-token projected volume and its identity.l5d.io audience. The identity names the ServiceAccount rather than the pod or its IP address. Pods that share a ServiceAccount therefore present the same workload identity, even though their proxies receive separate certificates.

Buoyant’s zero-trust guide frames that verified identity as the basis for deciding which workloads may call a service. The certificate lets the receiving proxy check a caller’s claimed identity against Linkerd’s trust anchor before applying an authorization policy.

Server denies, AuthorizationPolicy allows

A Linkerd Server selects meshed pods and a port. Once the Server exists, its default accessPolicy denies inbound traffic on that port until an AuthorizationPolicy grants access.

The manifest below creates a meshed nginx workload in demo and permits calls from meshed pods using the client ServiceAccount. Run a meshed pod with serviceAccountName: client as the caller. Apply the whole manifest to test the allow rule, or apply through the Server document first to see the locked-down baseline, then add the two policy documents.

apiVersion: v1
kind: Namespace
metadata:
  name: demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: client
  namespace: demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
      annotations:
        linkerd.io/inject: enabled
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
              name: http
---
apiVersion: policy.linkerd.io/v1beta3
kind: Server
metadata:
  name: web-port
  namespace: demo
spec:
  podSelector:
    matchLabels:
      app: web
  port: http
  proxyProtocol: HTTP/1
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: client-identity
  namespace: demo
spec:
  identityRefs:
    - kind: ServiceAccount
      name: client
      namespace: demo
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
  name: allow-client
  namespace: demo
spec:
  targetRef:
    group: policy.linkerd.io
    kind: Server
    name: web-port
  requiredAuthenticationRefs:
    - group: policy.linkerd.io
      kind: MeshTLSAuthentication
      name: client-identity

Before you add the AuthorizationPolicy, the Server blocks calls to nginx on port 80. A denied HTTP caller receives 403, a denied gRPC caller receives PermissionDenied, and a denied caller on an opaque port gets its connection reset (a meshed caller sees a 5xx from its own proxy). Linkerd keeps GET requests to a pod’s configured probe path open to any client (policyController.probeNetworks narrows it) until you bind a route to the Server. The Linkerd authorization documentation describes the policy resources and their scope.

Each AuthorizationPolicy grants access. Linkerd accepts a caller when any policy grants it, but the authentication references within one policy must all match. An empty requiredAuthenticationRefs list permits any caller, so don’t use one for an identity-restricted port.

Two configuration mistakes can leave a deny rule stuck in place. The config.linkerd.io/default-inbound-policy value is fixed at proxy start, so restart running pods after changing the annotation. And a policy targeting a Namespace grants access through matching Server resources. If you set a default-deny inbound policy without creating a Server, that namespace-targeted policy can’t open the port.

Scoping access by HTTP method and gRPC method

Linkerd can authorize individual HTTP methods and gRPC methods by attaching Gateway API routes to a Server. Install the Gateway API CRDs first. Linkerd 2.20 supports versions 1.2.1 through 1.5.1, but doesn’t ship the CRDs. If you install them after Linkerd starts, restart linkerd-destination so its policy controller discovers them.

The following routes attach to an existing Server named api. A frontend ServiceAccount can GET /items, while a writer ServiceAccount can POST there. A POST from frontend receives HTTP 403.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: items-get
spec:
  parentRefs: [{group: policy.linkerd.io, kind: Server, name: api}]
  rules:
  - matches: [{method: GET, path: {type: Exact, value: /items}}]
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
  name: allow-items-get
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: items-get}
  requiredAuthenticationRefs: [{kind: ServiceAccount, name: frontend}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: items-post
spec:
  parentRefs: [{group: policy.linkerd.io, kind: Server, name: api}]
  rules:
  - matches: [{method: POST, path: {type: Exact, value: /items}}]
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
  name: allow-items-post
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: items-post}
  requiredAuthenticationRefs: [{kind: ServiceAccount, name: writer}]

A policy can name callers through MeshTLSAuthentication, NetworkAuthentication, or a direct ServiceAccount reference, as these examples do. Choose the reference that matches how the caller connects.

Keep probes reachable. Binding the first route removes Linkerd’s automatic probe authorization. If your kubelet requests /healthz and no route matches, it gets 404. A failed liveness probe can restart the pod, and failed readiness leaves it NotReady. Add an HTTPRoute matching GET /healthz on the same Server, then target it with an AuthorizationPolicy whose requiredAuthenticationRefs is [].

Rolling out authorization policy without an outage

Start in audit mode so you can see which requests a deny policy would block. In Linkerd 2.16 and later, set config.linkerd.io/default-inbound-policy: audit on the namespace before injecting new pods. Existing pods keep the inbound policy they received at injection, so restart them if you change the annotation.

Create a Server for each port you plan to protect and set accessPolicy: audit while you map callers. Use linkerd viz authz and inbound authorization metrics to inspect traffic against your policies. linkerd viz tap -o wide shows the matched authorization as dst_authz_name, but never shows denied requests.

Add AuthorizationPolicies for the callers you've identified, then exercise the service with representative traffic. The proxy logs requests still covered by audit as authz.name=audit, and authorization metrics label them authz_name="audit". Keep checking those signals as you add policies. A quiet audit signal only tells you something useful if the expected callers actually sent requests during the test.

Once the expected traffic stops hitting audit, change that Server's accessPolicy to deny and test again. Don't make the change while a caller still lacks an allow rule. If you later change the namespace default to deny, restart affected pods and check ports that have no Server.

Check probes before binding an HTTPRoute to a Server. The first bound route removes Linkerd's automatic probe authorization, so add an explicit probe route or kubelet requests can fail and restart the pod. Linkerd's authorization documentation covers the policy resources used in this rollout.

Testing and troubleshooting authorization policy

Start with linkerd diagnostics policy -n <ns> po/<pod> <port>. It shows the inbound policy the pod’s proxy currently holds, so you can check whether the expected Server, route, and authorization reached it.

Then check inbound_http_authz_deny_total, inbound_http_route_not_found_total, and inbound_tcp_authz_deny_total. Use srv_name, route_name, authz_name, and client_id labels where present to narrow the failure. An HTTP 403 points to a denied request. An HTTP 404 points to a request that matched no route, so inspect the route before changing an allow rule.

Test a policy in a scratch namespace before you use it on production traffic. Deploy a test service named echo on port 8080, with app: echo on its pods. Apply this Server, which records authorization decisions in audit mode.

apiVersion: policy.linkerd.io/v1beta3
kind: Server
metadata:
  name: echo
  namespace: policy-test
spec:
  podSelector:
    matchLabels:
      app: echo
  port: 8080
  accessPolicy: audit

Create one meshed caller and one unmeshed caller. Send requests from both for a minute (viz authz shows rates, not counts), then inspect linkerd viz authz and the audit metric before changing the Server to deny.

kubectl run mesh-curl -n policy-test --image=curlimages/curl:8.11.1 \
  --restart=Never --annotations=linkerd.io/inject=enabled \
  --command -- sleep 3600
kubectl run plain-curl -n policy-test --image=curlimages/curl:8.11.1 \
  --restart=Never --annotations=linkerd.io/inject=disabled \
  --command -- sleep 3600

for caller in mesh-curl plain-curl; do
  kubectl exec -n policy-test "$caller" -c "$caller" -- \
    curl -si http://echo.policy-test.svc.cluster.local:8080/
done
linkerd viz authz -n policy-test deploy/echo

kubectl patch server echo -n policy-test --type=merge \
  -p '{"spec":{"accessPolicy":"deny"}}'

Repeat the requests after the patch. Check client_id if a meshed caller gets denied, and confirm its ServiceAccount identity matches your allow rule. If a route never appears in diagnostics, check whether you installed Gateway API CRDs after Linkerd and restart linkerd-destination if you did. An inbound-policy annotation added after pod injection needs a pod restart. If the target turns NotReady after you bind a route, check probe access. An opaque Server can block probes at TCP before an HTTP probe rule can help.

Linkerd AuthorizationPolicy vs Istio AuthorizationPolicy vs CiliumNetworkPolicy

Linkerd, Istio, and Cilium enforce access at different points. Linkerd and Istio can decide whether an individual service request is allowed. CiliumNetworkPolicy starts with network endpoints and ports, then can add HTTP rules.

Axis Linkerd Istio Cilium
Identity source Kubernetes ServiceAccount identity in a mesh certificate ServiceAccount identity in a mesh certificate Endpoint identity derived from Kubernetes labels
Enforcement point Destination pod’s inbound proxy Sidecar proxy; in ambient, ztunnel for L4, waypoint for L7 Cilium endpoint policy, with an HTTP proxy for Layer 7 rules
Policy resources Server, AuthorizationPolicy, MeshTLSAuthentication, NetworkAuthentication, plus optional HTTPRoute or GRPCRoute AuthorizationPolicy CiliumNetworkPolicy
No matching policy Cluster default allows unauthenticated traffic Allows traffic Allows traffic
Once selected A Server denies unless an authorization rule grants access An ALLOW policy denies requests it doesn’t match. Istio evaluates CUSTOM, then DENY, then ALLOW. Selecting an endpoint denies unmatched traffic in the policy’s direction. Ingress and egress are independent.
gRPC method rules Native GRPCRoute service and method matches Match the HTTP path carrying the RPC Match HTTP POST and the RPC path, including path regular expressions
mTLS Required for MeshTLSAuthentication, but NetworkAuthentication can admit non-mesh callers Principal matches require an authenticated identity. In PERMISSIVE mode, plaintext traffic skips authentication and authorization checks by default. Label-based policy doesn’t require mTLS. Mutual authentication is beta and uses SPIRE.
Denied request HTTP 403 or gRPC PermissionDenied for an authorization denial Typically HTTP 403 for an authorization denial L3/L4 denials silently drop packets. The L7 proxy returns 403.

For Linkerd, the authorization documentation is the starting point for the Server and AuthorizationPolicy rules. An unmatched Linkerd route is a separate case from a denied authorization request and can return HTTP 404 or gRPC NotFound. CiliumNetworkPolicy can allow specific HTTP requests, but its deny rules don’t express Layer 7 matches.

Best for network access controls: Choose CiliumNetworkPolicy when you need label- and port-based ingress or egress rules without putting a mesh proxy on each service request.

Best for a broader policy surface: Choose Istio when you need CUSTOM authorization or its explicit Layer 7 DENY rules. In ambient mode, plan for a waypoint wherever you need Layer 7 enforcement.

Best for gRPC-heavy services: Choose Linkerd when ServiceAccount-based access and native GRPCRoute method rules fit your policy. That combination suits the gRPC and AI inference workloads Buoyant targets with Linkerd, where you may want to admit a caller to one RPC without opening every method on the server.

Where to go from default-deny

Zero trust in Kubernetes starts with a workload identity you can verify. A default-deny policy can then use that identity to decide which callers get access.

If you run gRPC-heavy or regulated workloads, check Buoyant’s zero-trust guidance for the trust model and Linkerd’s authorization documentation for the policy resources you’ll operate.

FAQ

Does Linkerd authorization require mTLS?

No. Linkerd authorization can admit unmeshed callers through NetworkAuthentication. MeshTLSAuthentication requires a caller whose meshed proxy presents a verified identity.

Can I default-deny without a Server? Can I allow traffic without one?

Set the config.linkerd.io/default-inbound-policy annotation to deny before injecting the pod to deny inbound traffic without a Server. To grant access with AuthorizationPolicy, you need a Server or a route attached to one.

Can AuthorizationPolicy admit non-mesh traffic?

Yes. NetworkAuthentication can match an unmeshed caller by network, and an empty requiredAuthenticationRefs list admits any caller. MeshTLSAuthentication requires a meshed caller.

How does Linkerd authorization differ from Kubernetes NetworkPolicy?

NetworkPolicy controls traffic using pod and network selectors. Linkerd can authorize a request using the caller’s authenticated ServiceAccount identity and the HTTP or gRPC route it requests.

Why did my pod go NotReady after I added an HTTPRoute?

Binding a route removes the automatic allowance for the pod’s probe path. Add a matching probe route with an empty requiredAuthenticationRefs list so the kubelet can reach it.

Which Linkerd version should I check?

Tested on Linkerd 2.20 (edge-26.6.3), Gateway API 1.3.0. Needs 2.18 or later: earlier releases installed their own Gateway API CRDs by default, with older route versions, and releases before 2.16 had no accessPolicy or audit. The open-source project hasn’t published a stable release since February 2024, so verify your distribution’s policy and Gateway API support before applying examples.

‍