Overview

In this hands-on lab, I implemented a Zero Trust networking model inside a Kubernetes cluster using Istio, an open-source service mesh. The goal was to prove that Istio can provide enterprise-grade security before migrating production workloads, specifically by encrypting all service-to-service traffic and enforcing identity-based access control.

The environment used Minikube on an AWS EC2 instance. While Minikube is not production-grade, it provided a self-contained Kubernetes cluster sufficient to demonstrate Istio’s Zero Trust capabilities end-to-end.


What is Zero Trust?

Zero Trust is a security model built on one core principle: never trust, always verify. Unlike traditional perimeter-based security (where everything inside the network is assumed safe), Zero Trust treats every request as potentially hostile, regardless of where it originates. Every service must authenticate itself, and access is only granted based on explicit, verified identity.

In Kubernetes, Istio achieves this through two mechanisms:

  • Mutual TLS (mTLS) - every service proves its identity with a certificate before a connection is established
  • Authorization Policies - fine-grained rules that explicitly declare which identities are allowed to talk to which services

Environment Setup

I SSH’d into the lab machine and ran a setup script that launched Minikube with 2 CPUs and 3 GB of memory using the Docker driver.

#!/bin/bash
echo 'Starting Minikube with sufficient resources for Istio...'
minikube start --driver=docker --cpus=2 --memory=3000 --force
if [ $? -eq 0 ]; then
  echo 'Minikube started successfully!'
  kubectl config use-context minikube
  echo 'Setup complete! You can now proceed with the lab.'
else
  echo 'Error: Minikube failed to start.'
  exit 1
fi

Output:

Starting Minikube with sufficient resources for Istio...
* minikube v1.38.1 on Amazon 2023.4.20240429
* Creating docker container (CPUs=2, Memory=3000MB) ...
* Preparing Kubernetes v1.35.1 on Docker 29.2.1 ...
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
Minikube started successfully!
Switched to context "minikube".
Setup complete! You can now proceed with the lab.

After setup, I confirmed the node was healthy:

kubectl get nodes
NAME       STATUS   ROLES           AGE   VERSION
minikube   Ready    control-plane   32s   v1.35.1

Part 1 - Deploying the Istio Service Mesh

Installing Istio

Using istioctl, Istio’s dedicated bootstrapping CLI that was pre-installed on the machine, I deployed Istio with the demo profile, which includes the control plane (istiod), an ingress gateway for inbound traffic, and an egress gateway for outbound traffic. This profile is well-suited for labs and proof-of-concept deployments; in production you would typically use the default profile or a custom-tuned configuration.

istioctl install --set profile=demo -y

Output:

✔ Istio core installed ⛵️
✔ Istiod installed 🧠
✔ Egress gateways installed 🛫
✔ Ingress gateways installed 🛬
✔ Installation complete

I verified all three Istio components were running in their dedicated namespace:

kubectl get pods -n istio-system
NAME                                    READY   STATUS    RESTARTS   AGE
istio-egressgateway-5bd755b557-mdmph    1/1     Running   0          27s
istio-ingressgateway-6497cf79bc-87l65   1/1     Running   0          27s
istiod-75659b6dd4-cpqkn                 1/1     Running   0          41s

^envoyinstancepods

All three pods show 1/1 in the READY column, each component is fully operational.

Note

The ingress and egress gateway pods are essentially standalone Envoy instances, running as dedicated border guards. Their job is to handle cluster boundary traffic.


Creating the Namespace and Enabling Sidecar Injection

I created a dedicated namespace called zerotrust to isolate my workloads. Then I applied the istio-injection=enabled label to it.

kubectl create namespace zerotrust
kubectl label namespace zerotrust istio-injection=enabled

Why this matters: This label tells Istio’s mutating webhook to automatically inject an Envoy sidecar proxy into every pod that gets deployed in this namespace. The sidecar is the data plane, it intercepts all inbound and outbound traffic for the pod, enabling mTLS, telemetry, and policy enforcement without any changes to the application code.

kubectl get namespace zerotrust --show-labels
NAME        STATUS   AGE   LABELS
zerotrust   Active   95s   istio-injection=enabled,kubernetes.io/metadata.name=zerotrust

Deploying Microservices

I deployed two microservices (frontend and backend) and a utility sleep pod for testing. Each service has its own Kubernetes Service Account, this is important because Istio uses service accounts as workload identities for access control.

kubectl apply -f frontend.yaml
kubectl apply -f backend.yaml
serviceaccount/frontend-sa created
deployment.apps/frontend created
service/frontend created

serviceaccount/backend-sa created
deployment.apps/backend created
service/backend created

After the pods started, I checked their status:

kubectl get pods -n zerotrust
NAME                        READY   STATUS    RESTARTS   AGE
backend-f6696465c-lkggr     2/2     Running   0          24s
frontend-5ccb44dbcc-44ncn   2/2     Running   0          48s

Notice 2/2 in the READY column, each pod is running two containers: the application container and the Envoy sidecar proxy. This confirms automatic sidecar injection worked correctly.

I verified the sidecar was present by inspecting the frontend pod:

kubectl describe pod -n zerotrust -l app=frontend | grep -E "Name:|Image:"
Name:             frontend-5ccb44dbcc-44ncn
    Image:         docker.io/istio/proxyv2:1.29.2
    Image:         docker.io/istio/proxyv2:1.29.2
    Image:         nginx:alpine

Two images are present: nginx:alpine (the app) and istio/proxyv2 (the Envoy sidecar). Sidecar injection confirmed.

kubectl apply -f sleep.yaml
serviceaccount/sleep-sa created
deployment.apps/sleep created

Finally, I ran a quick connectivity test to confirm the mesh was passing traffic:

kubectl exec -n zerotrust deploy/sleep -- curl -s http://backend
`
<html><body><h1>Backend Service</h1><p>This is the backend microservice. Access is controlled by authorization policies.</p></body></html>

✅ The service mesh is up and routing traffic successfully.

It succeeds because at this point in the lab, no restriction policies exist yet. We haven’t applied PeerAuthentication or any AuthorizationPolicy yet.


Part 2 - Enforcing Mutual TLS

What is mTLS?

Mutual TLS (two-way)

In a service mesh, Istio issues certificates automatically to every sidecar, making mTLS seamless to configure.

PeerAuthentication Policy

I applied a PeerAuthentication resource set to STRICT mode. This tells Istio that every service in the zerotrust namespace must use mTLS, plaintext connections are rejected outright. PeerAuthentication handles the server side (what connections to accept).

kubectl apply -f peer-authentication.yaml
peerauthentication.security.istio.io/default created
kubectl get peerauthentication -n zerotrust
NAME      MODE     AGE
default   STRICT   29s

DestinationRule

I also applied a DestinationRule to configure the client side, telling Envoy sidecars to use ISTIO_MUTUAL TLS when sending traffic to any service in the namespace. DestinationRule handles the client side (how to initiate connections).

kubectl apply -f destination-rule.yaml
kubectl get destinationrule -n zerotrust
NAME      HOST                            AGE
default   *.zerotrust.svc.cluster.local   52s

Verifying mTLS Blocks Plaintext Traffic

To prove that STRICT mTLS actually rejects non-mesh clients, I created a separate namespace without sidecar injection and tried to reach the backend from there:

kubectl create namespace no-mesh
kubectl run curl-test --image=curlimages/curl:latest -n no-mesh -- sleep infinity
kubectl wait --for=condition=ready pod/curl-test -n no-mesh --timeout=60s
 
kubectl exec -n no-mesh curl-test -- curl -s -m 5 http://backend.zerotrust
command terminated with exit code 56

Exit code 56 means the connection was forcibly reset, the Envoy sidecar on the backend rejected the plaintext request because the client had no mTLS certificate. This is exactly the behavior we want: no certificate, no access.


Part 3 - Identity-Based Authorization Policies

The Zero Trust Authorization Model

With mTLS in place, all traffic is encrypted and authenticated. Now I needed to add authorization, controlling which authenticated services are allowed to talk to each other.

Istio uses an important pattern here: when you create an ALLOW policy for a workload, Istio automatically applies an implicit deny-all for any traffic that doesn’t match. You never need a separate “deny everything else” rule, Istio’s default behavior handles it.

Applying the Policy

I applied an AuthorizationPolicy that explicitly allows only the frontend-sa service account to access the backend service:

kubectl apply -f authz-allow-frontend.yaml
kubectl get authorizationpolicy -n zerotrust
NAME             ACTION   AGE
allow-frontend   ALLOW    54s

Testing Access Control

Frontend → Backend (should succeed):

kubectl exec -n zerotrust deploy/frontend -- curl -s http://backend
<html><body><h1>Backend Service</h1><p>This is the backend microservice. Access is controlled by authorization policies.</p></body></html>

✅ Frontend has frontend-sa - matches the policy, access granted.

Sleep → Backend (should be denied):

kubectl exec -n zerotrust deploy/sleep -- curl -s -o /dev/null -w "%{http_code}" http://backend
403

❌ Sleep uses sleep-sa - not in the ALLOW policy, implicitly denied. No separate deny rule needed.

Confirming Identity Mapping

kubectl get pods -n zerotrust -o custom-columns=NAME:.metadata.name,SERVICE_ACCOUNT:.spec.serviceAccountName
NAME                        SERVICE_ACCOUNT
backend-f6696465c-lkggr     backend-sa
frontend-5ccb44dbcc-44ncn   frontend-sa
sleep-86d948cc48-b8h4z      sleep-sa

This table makes the identity model clear, each workload has a distinct service account, and Istio uses these identities (not IP addresses or network segments) as the basis for access decisions.


Summary

Security LayerMechanismResult
EncryptionmTLS via PeerAuthentication (STRICT)All traffic encrypted; plaintext rejected
AuthenticationIstio-issued certificates per sidecarEvery service proves its identity
AuthorizationAuthorizationPolicy (ALLOW + implicit deny)Only explicitly allowed identities can connect

Key Takeaways

  • Sidecar injection is the foundation of the data plane, it transparently intercepts all pod traffic without application changes.
  • STRICT mTLS means a pod without an Istio certificate (i.e., outside the mesh) simply cannot connect, regardless of network access.
  • Authorization Policies use identity, not IP, this is the core of Zero Trust. A service’s location in the network is irrelevant; what matters is who it is.
  • Implicit deny-all removes the risk of misconfigured permissive defaults. Once an ALLOW policy exists, unlisted traffic is automatically blocked.

This pattern, mesh enrollment, mTLS enforcement, identity-based authorization, is the production-ready Zero Trust blueprint for Kubernetes workloads.