Zero Trust Architecture in Microservices: Identity, mTLS, and Authorization Policies

The traditional security model for microservices relies on a perimeter: hard outer shell, soft trusted interior. Once a request passes the API gateway, it’s assumed safe. Services communicate freely with each other over plaintext HTTP. The network is the trust boundary. This model breaks down the moment an attacker breaches the perimeter — which, in cloud-native environments with dozens of ingress points, is not a matter of if but when.

Zero Trust Architecture rejects the idea of a trusted interior entirely. Every service, every request, every connection must prove its identity and authorization — regardless of whether it originates from outside or inside the network. The network is no longer a security boundary; identity and context are.

In a microservices architecture, Zero Trust means mutual TLS between every service, identity-based authorization policies, encrypted service-to-service communication, and per-request authentication. Let’s walk through how to implement this practically, using Istio as a service mesh and SPIFFE identities for workload attestation.

The Three Pillars of Zero Trust in Microservices

Implementing Zero Trust across a microservices deployment requires three coordinated mechanisms:

  • Workload Identity — every service gets a cryptographic identity that can be verified by any other service, independent of IP addresses or network topology.
  • Encrypted Transport — all inter-service communication uses mutual TLS, so both parties verify each other’s identity before any data is exchanged.
  • Authorization Policies — every request is evaluated against fine-grained access rules: which service can call which endpoint, under what conditions.

Workload Identity With SPIFFE

The foundation of Zero Trust is workload identity. The SPIFFE (Secure Production Identity Framework for Everyone) specification defines a standard for cryptographic workload identity in heterogeneous environments. A SPIFFE ID — typically formatted as a URI like spiffe://cluster.local/ns/default/sa/payments-svc — uniquely identifies a workload.

SPIFFE IDs are carried in X.509 SVIDs (SPIFFE Verifiable Identity Documents) — short-lived certificates that a workload presents to prove its identity. The SPIRE (SPIFFE Runtime Environment) agent runs on each node, attests workloads, and issues SVIDs automatically. No manual certificate management, no long-lived secrets, no static credentials to steal.

Mutual TLS With Istio

Istio’s service mesh implements SPIFFE-based workload identity transparently through Envoy sidecar proxies. Each pod gets an Envoy proxy that handles TLS termination, certificate rotation, and identity verification — without any changes to application code. Services communicate as if they’re on a flat network; the mesh handles the cryptography.

Enforcing strict mTLS across the mesh is a single policy:

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: production
spec:
  mtls:
    mode: STRICT

Setting mode: STRICT means the mesh rejects any plaintext connection between services. Every inter-service call must present a valid client certificate. This is the single most impactful Zero Trust control — it eliminates the attack surface of unencrypted traffic on the internal network.

Authorization Policies: Beyond Network Segmentation

mTLS proves who is calling. Authorization policies determine what they can do. Traditional network segmentation works at L3/L4 — IP ranges and ports. Istio’s AuthorizationPolicy works at L7, evaluating service identity, HTTP method, path, and even request headers.

Here’s a policy that restricts the orders service so only the API gateway can call it, and only on specific paths:

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: orders-service-access
  namespace: production
spec:
  selector:
    matchLabels:
      app: orders-service
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/production/sa/api-gateway"]
    to:
    - operation:
        methods: ["GET", "POST"]
        paths: ["/orders/*", "/payments/webhook"]

This policy allows only the API gateway’s service account to call the orders service, and only on GET and POST methods matching specific paths. Any other caller — including compromised services on the same cluster — gets a 403 before reaching the application code.

Layering Application-Level Identity Checks

The service mesh handles transport-level identity. But Zero Trust also requires application-level authorization — validating the JWT claims, checking user roles, and enforcing business rules. In a Go microservice, this means extracting identity from the incoming request and making per-request decisions.

package middleware

import (
    "context"
    "net/http"
    "strings"

    "github.com/golang-jwt/jwt/v5"
)

type contextKey string

const UserClaimsKey contextKey = "userClaims"

func AuthMiddleware(secret []byte, allowedRoles ...string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, "missing token", http.StatusUnauthorized)
                return
            }

            tokenStr := strings.TrimPrefix(authHeader, "Bearer ")

            claims := jwt.MapClaims{}
            _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
                return secret, nil
            })
            if err != nil {
                http.Error(w, "invalid token", http.StatusUnauthorized)
                return
            }

            role, _ := claims["role"].(string)
            if !roleAllowed(role, allowedRoles) {
                http.Error(w, "insufficient permissions", http.StatusForbidden)
                return
            }

            ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func roleAllowed(role string, allowed []string) bool {
    for _, r := range allowed {
        if role == r {
            return true
        }
    }
    return false
}

This middleware validates the JWT, checks the user’s role against an allowlist, and injects claims into the request context for downstream handlers. Combined with mesh-level mTLS, you have defense in depth: the mesh verifies service identity, the application verifies user identity and authorization.

Request-Level Rate Limiting and Context Checks

Zero Trust also means treating every request as potentially hostile. Rate limiting, input validation, and anomaly detection should be per-service, not just at the gateway. A Go-based token bucket limiter scoped to authenticated identity:

package middleware

import (
    "net/http"
    "sync"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type visitor struct {
    tokens   float64
    lastSeen time.Time
}

type RateLimiter struct {
    mu         sync.Mutex
    visitors   map[string]*visitor
    rate       float64 // tokens per second
    burst      float64
    window     time.Duration
}

func NewRateLimiter(rps float64, burst float64) *RateLimiter {
    rl := &RateLimiter{
        visitors: make(map[string]*visitor),
        rate:     rps,
        burst:    burst,
        window:   5 * time.Minute,
    }
    go rl.cleanup()
    return rl
}

func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        claims, ok := r.Context().Value(UserClaimsKey).(jwt.MapClaims)
        if !ok {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        subject, _ := claims["sub"].(string)

        if !rl.allow(subject) {
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

func (rl *RateLimiter) allow(key string) bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    v, exists := rl.visitors[key]
    now := time.Now()
    if !exists {
        rl.visitors[key] = &visitor{tokens: rl.burst - 1, lastSeen: now}
        return true
    }

    elapsed := now.Sub(v.lastSeen).Seconds()
    v.tokens += elapsed * rl.rate
    if v.tokens > rl.burst {
        v.tokens = rl.burst
    }
    v.lastSeen = now

    if v.tokens < 1 {
        return false
    }
    v.tokens--
    return true
}

func (rl *RateLimiter) cleanup() {
    ticker := time.NewTicker(rl.window)
    defer ticker.Stop()
    for range ticker.C {
        rl.mu.Lock()
        for key, v := range rl.visitors {
            if time.Since(v.lastSeen) > rl.window {
                delete(rl.visitors, key)
            }
        }
        rl.mu.Unlock()
    }
}

The cleanup goroutine runs in NewRateLimiter (called once at initialization), not in the per-request Middleware method — a critical distinction that prevents spawning unbounded goroutines on every request.

Extending Zero Trust to the Data Layer

The same identity that the service mesh issues can extend to database connections. Tools like Cilium use eBPF to enforce identity-based network policies at the kernel level, and service meshes can forward SPIFFE identities to databases that support certificate-based authentication (PostgreSQL, CockroachDB). This means a compromised service can’t connect to a database it wasn’t explicitly authorized to use — even if it’s on the same network.

Observability: You Can’t Trust What You Can’t See

Zero Trust generates significantly more telemetry than traditional architectures — every inter-service call produces mTLS handshake data, policy evaluation results, and access decisions. Istio’s access logs capture which SPIFFE identity called which service, whether the call was permitted, and what policy matched. This audit trail is essential for incident response and compliance.

The key metrics to monitor in a Zero Trust deployment are denied connection counts (spikes indicate either a misconfigured policy or an active attack), mTLS certificate rotation failures, and policy evaluation latency. A well-configured mesh should add less than 1ms of overhead per request for identity verification.

Common Pitfalls

  • Permissive mTLS mode: Istio’s PERMISSIVE mode allows both encrypted and plaintext connections during migration. Forgetting to switch to STRICT leaves a permanent downgrade attack vector. Always set a deadline for the migration window.
  • IP-based authorization: Some teams fall back to IP allowlists because identity-based policies feel unfamiliar. This defeats the purpose of Zero Trust — IPs are spoofable, ephemeral, and provide no attestation.
  • Missing egress controls: Securing inbound traffic while ignoring outbound calls leaves a data exfiltration path. Apply AuthorizationPolicy rules to egress gateways as well.
  • Certificate rotation gaps: If your SPIRE or cert-manager configuration has rotation issues, services can lose identity silently. Monitor certificate expiry and rotation success rates as critical alerts.

Getting Started

The path to Zero Trust in microservices doesn’t have to be all-or-nothing. A practical adoption sequence: start by deploying a service mesh with permissive mTLS to observe traffic patterns, then progressively enforce strict mTLS namespace by namespace, add authorization policies starting with the most sensitive services, and layer application-level JWT validation on top of mesh identity. Each step reduces the attack surface without requiring a flag-day migration.

The tools are mature, the patterns are proven, and the threat model is clear. In a world where lateral movement is the primary attack vector for data breaches, trusting the internal network is no longer a viable default.

Leave a Reply

Your email address will not be published. Required fields are marked *