Zero-Trust Security with Vault and Istio mTLS
Give pods short-lived Vault database credentials, enforce Istio mTLS and identity-based access, then prove it holds with chaos experiments.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a zero-trust setup on a local Kubernetes cluster with two tools: HashiCorp Vault hands each pod short-lived database credentials that expire on their own, and Istio encrypts and authenticates every service-to-service call with mutual TLS (mTLS), so only named workloads can talk to each other. You then run small chaos experiments to prove the controls hold when pods are killed and credentials are revoked.
The problem it solves is trust by location. A static database password in a Secret works for anyone who finds it, forever, and plain traffic inside the cluster trusts any pod that can reach the port. Here, every credential expires within minutes and every connection proves who is calling.
Key ideas
- Dynamic secrets: Vault creates a new database user on demand with a time limit and deletes it at expiry. A leaked credential stops working by itself.
- mTLS: both sides of a connection present certificates, so the server knows exactly which workload is calling, not just which IP.
- Authorization policy: after identity is proven, a policy decides which identities may call which service. Anything not allowed is denied.
Scope and prerequisites
This project uses its own fresh kind cluster, so you do not need the cluster from the Kubernetes hardening project. Infrastructure-as-code scanning and a findings dashboard are covered in the pipeline projects and are not repeated here.
- Docker,
kubectl, Helm and kind installed (a two-node kind cluster is enough) - About 8 GB of free memory for Istio, Vault and Postgres together
- Familiarity with pods, services and namespaces
SecurityVault runs in dev mode here. Dev mode stores everything in memory, uses a known root token and has no TLS. It is perfect for learning and unacceptable for real use. A production Vault needs persistent storage, high availability, auto-unseal and TLS.
TipThe versions used are Istio 1.31.1 and the Vault Helm chart 0.34.1. If you use newer ones, read the release notes first because Istio and Vault change defaults between releases.
Milestone 1: Create the Cluster and Install Istio and Vault
This milestone creates the cluster and installs the two platforms. Istio goes in with the minimal profile, which installs only the control plane (istiod), because you only need sidecar mTLS, not an ingress gateway.
Create the cluster and namespaces
kind create cluster --name zerotrustkubectl create namespace vaultkubectl create namespace datakubectl create namespace legacykubectl create namespace paymentskubectl label namespace payments istio-injection=enabledOnly payments is labelled for sidecar injection. vault, data and legacy stay outside the mesh on purpose: the first two keep Vault and the database simple, and legacy plays the part of a workload with no sidecar.
Install Istio
curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.31.1 TARGET_ARCH=x86_64 sh -export PATH="$PWD/istio-1.31.1/bin:$PATH"istioctl install --set profile=minimal -ykubectl -n istio-system rollout status deployment/istiod --timeout=180sInstall Vault in dev mode
helm repo add hashicorp https://helm.releases.hashicorp.comhelm repo updatehelm install vault hashicorp/vault --version 0.34.1 -n vault \ --set server.dev.enabled=true --set injector.enabled=truekubectl -n vault wait --for=condition=ready pod/vault-0 --timeout=240skubectl -n vault get podsExpected result: vault-0 and the vault-agent-injector pod are running. Dev mode runs Vault as a StatefulSet pod named vault-0, which is why you wait on the pod, not on a Deployment.
A helper for Vault commands
Vault commands run inside the pod. This function saves typing and sets the dev-mode root token.
vexec() { kubectl exec -n vault vault-0 -- env VAULT_TOKEN=root vault "$@"; }vexec statusExpected result: Sealed false and a version number.
Common MistakeRunning
kubectl rollout status deployment/vault. In dev mode there is no Deployment, so the command fails with "not found". Wait on the pod instead, as above.
Milestone 2: Deploy Postgres and Configure Dynamic Credentials
This milestone deploys a small Postgres database and teaches Vault how to create temporary users in it. Vault needs one admin login to do that; applications never see it.
Deploy Postgres in the data namespace
# postgres.yamlapiVersion: v1kind: Secretmetadata: name: postgres-admin namespace: datatype: OpaquestringData: password: change-me-lab-only---apiVersion: apps/v1kind: Deploymentmetadata: name: postgres namespace: dataspec: replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16-alpine env: - name: POSTGRES_DB value: payments - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-admin key: password ports: - containerPort: 5432---apiVersion: v1kind: Servicemetadata: name: postgres namespace: dataspec: selector: app: postgres ports: - port: 5432kubectl apply -f postgres.yamlkubectl -n data rollout status deployment/postgres --timeout=180skubectl -n data exec deploy/postgres -- psql -U postgres -d payments -c \ "CREATE TABLE payments (id serial PRIMARY KEY, amount int); INSERT INTO payments (amount) VALUES (10), (25);"Enable the database secrets engine
PGPASS="$(kubectl -n data get secret postgres-admin -o jsonpath='{.data.password}' | base64 -d)" vexec secrets enable database vexec write database/config/payments \ plugin_name=postgresql-database-plugin \ allowed_roles=app \ connection_url='postgresql://{{username}}:{{password}}@postgres.data.svc.cluster.local:5432/payments?sslmode=disable' \ username=postgres \ password="$PGPASS" vexec write database/roles/app \ db_name=payments \ creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \ default_ttl=2m \ max_ttl=10mThe role creates read-only users that live for 2 minutes and can be renewed up to 10 minutes. A real service would use longer lifetimes; the short ones let you watch expiry and renewal during this lab.
Test it by hand
vexec read database/creds/appExpected result: a lease ID, a lease duration of 2m, and a generated username that starts with v-. That user now exists in Postgres and will be deleted by Vault when the lease ends.
SecurityThe admin password above is a lab value. In a real setup, rotate it so only Vault knows it, with
vault write -f database/rotate-root/payments, and store nothing else that can log in as admin.
Common MistakeGiving the dynamic role broad rights such as
ALL PRIVILEGES. Each generated user gets exactly the statements increation_statements, so write them as the narrowest grant the service needs.
Milestone 3: Inject Credentials Into a Pod With Kubernetes Auth
This milestone lets a pod prove its identity to Vault using its Kubernetes service account, then receive database credentials in a file. The Vault Agent Injector adds a helper container to the pod that logs in, fetches credentials, renews them, and rewrites the file when they change. The application needs no Vault code.
Configure Kubernetes authentication
This command runs inside the Vault pod, which has its own service account and the cluster address in its environment, so no extra values are needed. The single quotes make the variable resolve inside the pod.
vexec auth enable kubernetes kubectl exec -n vault vault-0 -- sh -c \ 'VAULT_TOKEN=root vault write auth/kubernetes/config kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"' kubectl exec -i -n vault vault-0 -- env VAULT_TOKEN=root vault policy write db-reader - << 'EOF'path "database/creds/app" { capabilities = ["read"]}EOF vexec write auth/kubernetes/role/db-reader \ bound_service_account_names=db-reader \ bound_service_account_namespaces=payments \ policies=db-reader \ ttl=1hThe role says: only the service account db-reader in the namespace payments may log in, and it may only read database/creds/app.
Deploy a pod that uses the credentials
The container loops forever, reads the connection string from the injected file, and prints which database user it is. The Istio annotation excludes Vault's port from the mesh so the Vault agent can reach Vault before the Istio sidecar is ready.
# db-reader.yamlapiVersion: v1kind: ServiceAccountmetadata: name: db-reader namespace: payments---apiVersion: apps/v1kind: Deploymentmetadata: name: db-reader namespace: paymentsspec: replicas: 1 selector: matchLabels: app: db-reader template: metadata: labels: app: db-reader annotations: vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: "db-reader" vault.hashicorp.com/agent-inject-secret-db-url: "database/creds/app" vault.hashicorp.com/agent-inject-template-db-url: | {{- with secret "database/creds/app" -}} postgresql://{{ .Data.username }}:{{ .Data.password }}@postgres.data.svc.cluster.local:5432/payments?sslmode=disable {{- end }} traffic.sidecar.istio.io/excludeOutboundPorts: "8200" spec: serviceAccountName: db-reader containers: - name: reader image: postgres:16-alpine command: ["sh", "-c"] args: - | while true; do psql "$(cat /vault/secrets/db-url)" -Atc "select current_user, count(*) from payments" || echo "query failed" sleep 15 donekubectl apply -f db-reader.yamlkubectl -n payments rollout status deployment/db-reader --timeout=240skubectl -n payments logs deploy/db-reader -c reader --tail=5Expected result: lines such as v-...|2, a generated username and the row count. Wait three or four minutes and read the logs again: the username changes when the lease ends and the agent fetches a new one.
Confirm the secret is not in the environment
kubectl -n payments exec deploy/db-reader -c reader -- env | grep -i -E 'pass|secret|token' || echo "nothing in env"Expected result: nothing in env. The credential lives in a file under /vault/secrets, which is easier to rotate and keeps it out of process listings. Anyone who can exec into the pod can still read that file, which is why exec access needs to stay tightly controlled.
Common MistakeMounting the injected file but never reading it again. The agent rewrites the file when credentials rotate, so the application must reread it for each new connection. An app that reads it once at startup breaks when the first lease ends.
Milestone 4: Enforce mTLS and Identity-Based Access in Istio
This milestone turns on strict mTLS in the payments namespace and adds an authorization policy that lets only one named workload call the API. Identity comes from the service account of each pod, carried inside the mTLS certificate.
Deploy the API and two callers
# mesh-workloads.yamlapiVersion: v1kind: ServiceAccountmetadata: name: api namespace: payments---apiVersion: v1kind: ServiceAccountmetadata: name: client namespace: payments---apiVersion: v1kind: ServiceAccountmetadata: name: intruder namespace: payments---apiVersion: apps/v1kind: Deploymentmetadata: name: api namespace: paymentsspec: replicas: 1 selector: matchLabels: app: api template: metadata: labels: app: api spec: serviceAccountName: api containers: - name: api image: ghcr.io/stefanprodan/podinfo:6.15.0 ports: - containerPort: 9898---apiVersion: v1kind: Servicemetadata: name: api namespace: paymentsspec: selector: app: api ports: - port: 9898 targetPort: 9898---apiVersion: apps/v1kind: Deploymentmetadata: name: client namespace: paymentsspec: replicas: 1 selector: matchLabels: app: client template: metadata: labels: app: client spec: serviceAccountName: client containers: - name: curl image: curlimages/curl:8.11.1 command: ["sleep", "86400"]---apiVersion: apps/v1kind: Deploymentmetadata: name: intruder namespace: paymentsspec: replicas: 1 selector: matchLabels: app: intruder template: metadata: labels: app: intruder spec: serviceAccountName: intruder containers: - name: curl image: curlimages/curl:8.11.1 command: ["sleep", "86400"]kubectl apply -f mesh-workloads.yamlkubectl -n payments rollout status deployment/api deployment/client deployment/intruder --timeout=240skubectl -n payments get podsExpected result: the api, client and intruder pods show 2/2 containers ready, the app plus the Istio sidecar. The db-reader pod shows 3/3 because it also carries the Vault agent.
Require mTLS and allow only the client
# mesh-policy.yamlapiVersion: security.istio.io/v1kind: PeerAuthenticationmetadata: name: default namespace: paymentsspec: mtls: mode: STRICT---apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata: name: api-allow-client namespace: paymentsspec: selector: matchLabels: app: api action: ALLOW rules: - from: - source: principals: ["cluster.local/ns/payments/sa/client"]kubectl apply -f mesh-policy.yamlWith an ALLOW policy in place for the API, any request that does not match a rule is denied. You do not need a separate deny-all policy for this workload.
Test three callers
CALL='curl -s -o /dev/null -w "%{http_code}\n" http://api:9898/healthz'kubectl -n payments exec deploy/client -c curl -- sh -c "$CALL"kubectl -n payments exec deploy/intruder -c curl -- sh -c "$CALL"kubectl run probe -n legacy --rm -i --restart=Never --image=curlimages/curl:8.11.1 --command -- \ curl -sS -m 5 http://api.payments.svc.cluster.local:9898/healthzExpected result: 200 for the client, 403 for the intruder (authenticated but not allowed), and a connection error for the legacy pod (no sidecar, so no certificate, so refused under STRICT mode).
| Caller | Has a certificate? | Allowed by policy? | Result |
|---|---|---|---|
| client | Yes | Yes | 200 |
| intruder | Yes | No | 403 |
| legacy probe | No | Not evaluated | Connection refused |
Common MistakeTurning on STRICT mode before every pod in the namespace has a sidecar. A pod without one is cut off immediately. Start with
PERMISSIVE, confirm every pod shows its sidecar, then switch toSTRICT.
TipThis namespace deliberately has no Pod Security
restrictedlabel. Istio's init container needs extra network capabilities, which the restricted standard rejects. To combine them, use the Istio CNI plugin instead of the init container, which moves that privilege to a node component.
Milestone 5: Run Security Chaos Experiments
Security chaos engineering means breaking things on purpose to check that controls hold. Each experiment starts with a hypothesis, which turns random breakage into a test with a clear pass or fail.
The experiments
| Experiment | Hypothesis |
|---|---|
| Kill the db-reader pod | The replacement pod gets a new database user automatically and queries succeed |
| Revoke every lease | The agent notices and the pod continues with a new user within a few minutes |
| Delete the policy, then restore it | While the policy is missing the intruder gets through, and after restoring it gets 403 again |
Experiment 1: kill the pod
kubectl -n payments logs deploy/db-reader -c reader --tail=1kubectl -n payments delete pod -l app=db-readerkubectl -n payments rollout status deployment/db-reader --timeout=240skubectl -n payments logs deploy/db-reader -c reader --tail=3Expected result: the new pod prints a different v-... username than the old one, and the row count query succeeds.
Experiment 2: revoke all leases
vexec lease revoke -prefix database/creds/appsleep 200kubectl -n payments logs deploy/db-reader -c reader --tail=10Expected result: at least one query failed line may appear while the old user is gone, followed by successful lines with a new username once the agent fetches fresh credentials. The recovery time depends on the 2 minute lease, so this is a good moment to discuss why production leases and renewal behaviour need real testing.
Experiment 3: remove and restore the policy
kubectl -n payments delete authorizationpolicy api-allow-clientkubectl -n payments exec deploy/intruder -c curl -- sh -c 'curl -s -o /dev/null -w "%{http_code}\n" http://api:9898/healthz'kubectl apply -f mesh-policy.yamlsleep 5kubectl -n payments exec deploy/intruder -c curl -- sh -c 'curl -s -o /dev/null -w "%{http_code}\n" http://api:9898/healthz'Expected result: 200 while the policy is missing, then 403 after it is restored. The first answer is the lesson: without an ALLOW policy Istio allows authenticated traffic, so the policy file in version control is the control, and deleting it silently weakens security.
Common MistakeRunning chaos without a written hypothesis. You then cannot tell whether the result was a surprise or the expected behaviour. Write the sentence first, run the experiment, then record whether it held.
SecurityOnly run these on a cluster you own. Revoking leases and deleting policies on a shared environment can cause a real outage.
Validation & Testing
This script checks every control after the experiments and exits non-zero if any fails. Keep it with your project as evidence that the platform still behaves as designed.
#!/usr/bin/env bash# verify.shset -upass=0; fail=0ok() { echo "PASS: $1"; pass=$((pass+1)); }bad() { echo "FAIL: $1"; fail=$((fail+1)); }code() { kubectl -n payments exec "deploy/$1" -c curl -- sh -c 'curl -s -o /dev/null -w "%{http_code}" http://api:9898/healthz'; } [ "$(code client)" = "200" ] && ok "client can call api" || bad "client can call api"[ "$(code intruder)" = "403" ] && ok "intruder gets 403" || bad "intruder gets 403" mode="$(kubectl -n payments get peerauthentication default -o jsonpath='{.spec.mtls.mode}')"[ "${mode}" = "STRICT" ] && ok "mTLS is STRICT" || bad "mTLS is STRICT" user="$(kubectl -n payments logs deploy/db-reader -c reader --tail=1)"echo "${user}" | grep -q '^v-' && ok "db-reader uses a Vault-generated user" || bad "db-reader uses a Vault-generated user" kubectl -n payments exec deploy/db-reader -c reader -- env | grep -qi -E 'pass|secret|token' \ && bad "no secrets in environment" || ok "no secrets in environment" echo "passed=${pass} failed=${fail}"[ "${fail}" -eq 0 ]chmod +x verify.sh && ./verify.shExpected result: five PASS lines and failed=0.
Checklist
- Vault dev pod, Istio control plane and Postgres are running.
vault read database/creds/appreturns a short-lived user and the user disappears after the lease ends.- The db-reader pod logs show the database username changing over time.
- Client gets 200, intruder gets 403, and the legacy probe cannot connect.
- All three chaos experiments have a recorded hypothesis and result.
Reflection questions
- What can an attacker do with a leaked dynamic credential compared with a static one?
- What does mTLS prove that an IP allow-list cannot?
- Why does deleting an authorization policy open access, and how would you stop that happening by accident?
- What would you change before using Vault outside dev mode?
Portfolio deliverable
Your README should contain the architecture diagram, the three-caller test table with real output, the chaos hypotheses and results, and a short "what I would change for production" list covering Vault storage and auto-unseal, TLS to Vault, and rotating the root database credential.
TipStretch goals: add the Istio CNI plugin and Pod Security restricted, rotate the Postgres root credential through Vault, and run the verify script on a schedule.