Skip to content
Published on

Istio Security Model Analysis: mTLS, Authentication, Authorization

Authors

Introduction

Istio's security model implements "zero trust networking" at the service mesh level. All service-to-service communication is encrypted by default, and every request goes through authentication and authorization.

This post analyzes the internal implementation of Istio security's three core pillars: identity, authentication, and authorization.

Certificate Lifecycle

istiod CA Role

istiod acts as a built-in CA (Certificate Authority) managing certificates for all workloads in the mesh:

istiod CA
├── Root Certificate (self-signed or issued by external CA)
├── Intermediate CA Certificate (optional)
└── Workload Certificates (issued to each workload)
    ├── frontend → spiffe://cluster.local/ns/prod/sa/frontend
    ├── reviews → spiffe://cluster.local/ns/prod/sa/reviews
    └── ratings → spiffe://cluster.local/ns/prod/sa/ratings

Detailed Certificate Issuance Flow

[1] Pod starts → istio-agent initializes
[2] istio-agent generates RSA 2048 or ECDSA P-256 key pair
[3] Creates CSR including SPIFFE ID
    (spiffe://cluster.local/ns/NAMESPACE/sa/SA_NAME)
[4] Sends CSR to istiod along with Kubernetes ServiceAccount token
[5] istiod validation:
    ├── ServiceAccount token validity (TokenReview API)
    ├── Token namespace/SA matches CSR SPIFFE ID
    └── CSR format validity
[6] istiod CA signs X.509 certificate
    ├── Subject: SPIFFE ID
    ├── SAN (Subject Alternative Name): SPIFFE URI
    ├── Validity: 24 hours (default)
    └── Key Usage: Digital Signature, Key Encipherment
[7] Returns signed certificate + CA chain to istio-agent
[8] istio-agent delivers certificate to Envoy via SDS
[9] Automatic renewal before expiry (around 50% of validity period)

External CA Integration

Production environments can use external CAs:

External CA integration methods:
├── 1. Plug-in CA: Mount external CA intermediate cert in istiod
│   └── istiod signs workload certs as intermediate CA
├── 2. CSR API: Use Kubernetes CertificateSigningRequest API
│   └── External signer approves/signs Kubernetes CSRs
└── 3. Custom CA (istio-csr): cert-manager + Istio CSR Agent
    └── cert-manager issues certs from external CA (Vault, AWS ACM, etc.)

mTLS Handshake Flow

Sidecar-to-Sidecar mTLS

The mTLS connection establishment between two services:

Client Pod (frontend)              Server Pod (reviews)
[App][Envoy Proxy]     ←→     [Envoy Proxy][App]

1. App sends request to reviews:9080
   (plaintext HTTP, internal to localhost)
2. iptables redirects traffic to Envoy (15001)
3. Envoy checks TLS settings for destination cluster
   (DestinationRule or auto mTLS)
4. TLS handshake begins:
   ├── ClientHello (supported TLS versions, cipher suites)
   ├── ServerHello + Server Certificate
   │   └── reviews presents its SPIFFE certificate
   ├── Client Certificate
   │   └── frontend presents its SPIFFE certificate
   ├── Certificate Verification (bidirectional)
   │   ├── CA chain validation
   │   ├── SPIFFE ID verification
   │   └── Certificate expiry check
   └── Finished (session key exchange complete)
5. HTTP request sent over encrypted channel
6. Server Envoy terminates TLS
7. Forwards as plaintext to App (localhost)

Auto mTLS

Istio enables auto mTLS by default:

Does the destination have a sidecar?
    ├── Yes → automatically use mTLS
       (even without TLS settings in DestinationRule)
    └── No → use plaintext
        (forcing mTLS to sidecar-less services fails)

This maintains communication even when sidecars are being gradually injected.

PeerAuthentication Internals

Policy Scope and Priority

Priority (highest first):
1. Workload-level (selector specified)
2. Namespace-level (no selector, specific namespace)
3. Mesh-level (istio-system namespace, no selector)

Envoy Configuration by mTLS Mode

STRICT mode:

{
  "filter_chains": [
    {
      "filter_chain_match": {},
      "transport_socket": {
        "name": "envoy.transport_sockets.tls",
        "typed_config": {
          "require_client_certificate": true,
          "common_tls_context": {
            "validation_context": {
              "trusted_ca": "CA certificate"
            }
          }
        }
      }
    }
  ]
}

Connections without a valid client certificate are immediately rejected.

PERMISSIVE mode:

{
  "filter_chains": [
    {
      "filter_chain_match": {
        "transport_protocol": "tls"
      },
      "transport_socket": {
        "name": "envoy.transport_sockets.tls",
        "typed_config": {
          "require_client_certificate": true
        }
      }
    },
    {
      "filter_chain_match": {},
      "transport_socket": {
        "name": "envoy.transport_sockets.raw_buffer"
      }
    }
  ]
}

Two filter chains exist: one for TLS connections and one for plaintext. The TLS Inspector classifies connections.

Per-Port mTLS Configuration

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: reviews-mtls
  namespace: production
spec:
  selector:
    matchLabels:
      app: reviews
  mtls:
    mode: STRICT
  portLevelMtls:
    8080:
      mode: STRICT
    15021:
      mode: DISABLE # Disable mTLS for health check port

RequestAuthentication Internals

JWT Validation Flow

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: jwt-auth
spec:
  selector:
    matchLabels:
      app: reviews
  jwtRules:
    - issuer: 'https://auth.example.com'
      jwksUri: 'https://auth.example.com/.well-known/jwks.json'
      forwardOriginalToken: true
      outputPayloadToHeader: 'x-jwt-payload'

Processing in Envoy:

Request arrives (Authorization: Bearer TOKEN)
JWT Authn Filter (envoy.filters.http.jwt_authn)
    ├── Extract JWT token (Authorization header)
    ├── Check JWKS cache
    │   ├── Cache hit → verify signature with public key
    │   └── Cache miss → download keys from jwksUri
    ├── Token validation:
    │   ├── Signature validity
    │   ├── issuer (iss) match
    │   ├── Expiry (exp) check
    │   └── audience (aud) check (if configured)
    ├── Validation success:
    │   ├── Store payload in filter metadata
    │   ├── forwardOriginalToken: true → preserve original token
    │   └── outputPayloadToHeader → add payload to specified header
    └── Validation failure:
        ├── Invalid JWT401 Unauthorized
        └── No JWT → request passes through (unauthenticated)

Handling Requests Without JWT

Important characteristics of RequestAuthentication:

  • If JWT is present, it must be valid (invalid returns 401)
  • If JWT is absent, the request passes through (unauthenticated)
  • To also reject requests without JWT, use with AuthorizationPolicy
# Pattern to also reject requests without JWT
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: require-jwt
spec:
  selector:
    matchLabels:
      app: reviews
  action: DENY
  rules:
    - from:
        - source:
            notRequestPrincipals: ['*']

AuthorizationPolicy Internals

Evaluation Order

Request arrives
[1] CUSTOM policy evaluation
    ├── Matched and denied → 403 Forbidden
    ├── Matched and allowed → proceed to [2]
    └── Not matched → proceed to [2]
[2] DENY policy evaluation
    ├── Matched403 Forbidden
    └── Not matched → proceed to [3]
[3] Check for ALLOW policies
    ├── No ALLOW policies → allow (default allow)
    └── ALLOW policies exist:
        ├── Matched → allow
        └── Not matched → 403 Forbidden

Translation to Envoy RBAC Filter

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-frontend
  namespace: production
spec:
  selector:
    matchLabels:
      app: reviews
  action: ALLOW
  rules:
    - from:
        - source:
            principals: ['cluster.local/ns/production/sa/frontend']
      to:
        - operation:
            methods: ['GET']
            paths: ['/api/*']

Envoy RBAC filter configuration:

{
  "name": "envoy.filters.http.rbac",
  "typed_config": {
    "rules": {
      "action": "ALLOW",
      "policies": {
        "allow-frontend": {
          "permissions": [
            {
              "and_rules": {
                "rules": [
                  {
                    "header": {
                      "name": ":method",
                      "string_match": {
                        "exact": "GET"
                      }
                    }
                  },
                  {
                    "url_path": {
                      "path": {
                        "prefix": "/api/"
                      }
                    }
                  }
                ]
              }
            }
          ],
          "principals": [
            {
              "authenticated": {
                "principal_name": {
                  "exact": "spiffe://cluster.local/ns/production/sa/frontend"
                }
              }
            }
          ]
        }
      }
    }
  }
}

Source Field Mapping

AuthorizationPolicyEnvoy RBACDescription
source.principalsauthenticated.principal_nameSPIFFE ID matching
source.namespacesauthenticated.principal_name (prefix)Namespace matching
source.ipBlockssource_ipIP range matching
source.requestPrincipalsmetadata (JWT claims)JWT subject matching

Operation Field Mapping

AuthorizationPolicyEnvoy RBACDescription
operation.hostsheader (:authority)Host matching
operation.methodsheader (:method)HTTP method matching
operation.pathsurl_pathPath matching
operation.portsdestination_portPort matching

Trust Domain and Migration

Trust Domain Overview

Trust Domain: scope of trust for a CA that issues certificates

cluster-1: trust domain = "cluster-1.example.com"
  └── spiffe://cluster-1.example.com/ns/prod/sa/frontend

cluster-2: trust domain = "cluster-2.example.com"
  └── spiffe://cluster-2.example.com/ns/prod/sa/frontend

Trust Domain Migration

When changing CA or migrating trust domains:

# Configure trust domain aliases in MeshConfig
meshConfig:
  trustDomain: 'new-domain.example.com'
  trustDomainAliases:
    - 'old-domain.example.com'

This allows certificates issued under the old trust domain to continue being trusted.

External Authorization Integration (OPA)

CUSTOM AuthorizationPolicy

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: ext-authz
  namespace: production
spec:
  selector:
    matchLabels:
      app: reviews
  action: CUSTOM
  provider:
    name: 'opa-ext-authz'
  rules:
    - to:
        - operation:
            paths: ['/api/*']

Register External Authz Provider in MeshConfig

meshConfig:
  extensionProviders:
    - name: 'opa-ext-authz'
      envoyExtAuthzGrpc:
        service: 'opa.opa-system.svc.cluster.local'
        port: 9191
        timeout: 5s
        failOpen: false

Request Flow

Request arrives
Envoy ext_authz filter
    ├── Send authorization request to OPA service via gRPC
    │   ├── Request headers
    │   ├── Path, method
    │   ├── Source principal (SPIFFE ID)
    │   └── Custom attributes
    ├── OPA response:
    │   ├── ALLOWcontinue processing request
    │   ├── DENYreturn 403
    │   └── Timeout:
    │       ├── failOpen: true → allow request
    │       └── failOpen: false → deny request
    └── OPA can include additional headers in response
        (e.g., authorization context info)

Security Best Practices

1. Gradual mTLS Rollout

# Phase 1: Mesh-wide PERMISSIVE
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system
spec:
  mtls:
    mode: PERMISSIVE

# Phase 2: Per-namespace STRICT transition
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: production
spec:
  mtls:
    mode: STRICT

# Phase 3: Mesh-wide STRICT
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system
spec:
  mtls:
    mode: STRICT

2. Default Deny Policy

# Deny all requests by default, explicitly allow
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: deny-all
  namespace: production
spec: {} # Empty spec = ALLOW action, empty rules = deny all requests

3. Namespace Isolation

# Allow only traffic within the same namespace
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-same-namespace
  namespace: production
spec:
  action: ALLOW
  rules:
    - from:
        - source:
            namespaces: ['production']

Debugging Tools

# Check mTLS status
istioctl authn tls-check PODNAME.NAMESPACE

# AuthorizationPolicy application status
istioctl x authz check PODNAME.NAMESPACE

# Check certificate information
istioctl proxy-config secret PODNAME.NAMESPACE -o json

# Enable Envoy RBAC debug logging
kubectl exec PODNAME -c istio-proxy -- \
  curl -X POST "localhost:15000/logging?rbac=debug"

# Check RBAC denial statistics
kubectl exec PODNAME -c istio-proxy -- \
  curl -s localhost:15000/stats | grep rbac

Conclusion

Istio's security model consists of three layers:

  1. Identity: SPIFFE-based workload identity and automatic certificate management
  2. Authentication: mTLS (PeerAuthentication) and JWT (RequestAuthentication)
  3. Authorization: RBAC-based fine-grained access control (AuthorizationPolicy)

These three layers work organically within Envoy proxy's filter chain to implement zero trust security.

In the next post, we will explore the internal architecture of Istio Ambient Mesh.