Threat Modeling Microservices: STRIDE in Practice
In 2023, a team I was consulting for shipped an “internal-only” gRPC service behind a Kubernetes ClusterIP. No NetworkPolicy, no auth on the RPC methods, shared service account. Three weeks later, a well-meaning frontend dev added a catch-all route on the ingress controller that proxied /internal/* to the gRPC-gateway. The service was on the public internet for eleven days. The threat model said “internal service, low risk.” The threat model was a PDF from 2021.
This guide is for platform engineers, SREs, and security engineers who already know STRIDE by heart but have watched it fail the moment their architecture split into forty services. By the end, you’ll be able to enumerate your actual trust boundaries from runtime telemetry, run STRIDE per boundary instead of per system, and commit the output as a CI-enforced artifact instead of a Confluence page.
We’re going to be blunt about what works and what’s theater. Most threat modeling programs I’ve audited produce exactly zero enforced controls. That’s the bar we’re clearing.
Prerequisites
Section titled “Prerequisites”You’ll want a working Kubernetes cluster (kind or k3s is fine for practice), kubectl with the krew plugin manager, jq, istioctl if you’re running Istio, and Python 3.10+ if you’re going to run pytm. For the detection snippets, Falco and a Sigma-compatible SIEM are assumed. If you’re on a different mesh — Linkerd, Cilium — the concepts translate; the exact commands don’t.
Why Traditional Threat Modeling Breaks at Microservice Scale
Section titled “Why Traditional Threat Modeling Breaks at Microservice Scale”The classic workflow — draw a diagram, run STRIDE, ship it — assumes a monolithic trust boundary. You have the internet, you have your DMZ, you have your app, you have your database. Four boxes, one diagram, done in an afternoon.
Microservices invert this. You now have dozens of trust boundaries, most of which you don’t own. The service mesh hop between cart and orders is a boundary. The Kafka topic between orders and notifications is a boundary. The webhook from payments to Stripe is a boundary. Every one of them is a place where identity, authorization, or data classification changes — and every one of them is a place where someone else’s team makes the decisions.
Here’s a representative manifest that shows the problem. Six services, no NetworkPolicy, one shared service account, implicit trust everywhere:
# Anti-pattern: implicit trust across every serviceapiVersion: apps/v1kind: Deploymentmetadata: name: cartspec: template: spec: serviceAccountName: default containers: - name: cart image: registry.internal/cart:1.14.2 env: - name: ORDERS_URL value: "http://orders.default.svc.cluster.local:8080" - name: CATALOG_URL value: "http://catalog.default.svc.cluster.local:8080"Every pod runs as default. Every service can reach every other service. Nothing in this manifest tells you where a trust boundary is — because there isn’t one. “We use mTLS everywhere” is the answer people give here, and it’s not a threat model. It’s a transport control that authenticates workloads, not users, and says nothing about whether cart should be allowed to call payments at all.
| Dimension | Monolith | Microservices |
|---|---|---|
| Trust boundaries | 2–4, mostly yours | Dozens, mostly not yours |
| Data flow complexity | Linear, inspectable | Graph, changes weekly |
| Blast radius of one compromise | Whole app | One service → lateral movement |
| Ownership model | One team | N teams, fuzzy interfaces |
| Attack surface count | Fixed at deploy | Grows with every new edge |
The takeaway: threat modeling is per-boundary, not per-system. If you can’t name who owns each boundary, you don’t have one.
STRIDE Reframed for Distributed Systems
Section titled “STRIDE Reframed for Distributed Systems”STRIDE is fine. What broke is the way we apply it. Here’s what each letter actually means when you have forty services.
Spoofing splits into two problems: identity at the mesh layer (workload identity, mTLS SPIFFE IDs) and identity at the application layer (user JWTs, session tokens). They are different problems with different controls. Confusing them is the single most common mistake I see.
Tampering now has three surfaces: in-transit (TLS covers this), at-rest (encryption + KMS), and in-queue (schema validation, message signing). Most teams cover the first and forget the third.
Repudiation is the forgotten letter. Distributed tracing is your evidence trail, and your logs are probably not admissible — stdout to a log aggregator with 48-hour retention is not forensics.
Information Disclosure has three new homes: sidecars (envoy admin on localhost), metrics endpoints (/metrics leaking cardinality), and debug endpoints (/debug/pprof in prod).
DoS includes cascading failure. One slow dependency without a circuit breaker is a DoS vector even without an attacker.
Elevation of Privilege is the confused deputy problem, SSRF, and token scope creep — a service token with orders:* when the service only needs orders:read.
Spoofing and Tampering Are Not the Same Problem in a Mesh
Section titled “Spoofing and Tampering Are Not the Same Problem in a Mesh”mTLS gives you workload identity. It does not give you user identity. When cart calls orders over mTLS, orders knows which workload called it. It does not know which user the request is for unless you propagate that context explicitly — and most teams don’t.
# Istio: workload identity enforced, but no user contextapiVersion: security.istio.io/v1kind: PeerAuthenticationmetadata: name: default namespace: shopspec: mtls: mode: STRICT---apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata: name: orders-allow-cart namespace: shopspec: selector: matchLabels: app: orders rules: - from: - source: principals: ["cluster.local/ns/shop/sa/cart"] to: - operation: methods: ["POST"] paths: ["/v1/orders"]This policy is correct as far as it goes: only the cart service account can POST to /v1/orders. But there’s no user context. If cart is compromised, the attacker can create orders for any user by simply omitting or forging the X-User-Id header — because orders trusts the caller to supply it. That’s the gap.
The fix is to verify the user JWT at the mesh boundary and propagate a signed, short-lived context token downstream. Here’s what that looks like in a Go middleware:
// Verify user JWT at ingress, mint a short-lived context token for downstreamfunc ContextPropagation(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authz := r.Header.Get("Authorization") claims, err := verifyJWT(authz, jwtAudience, jwtIssuer) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctxToken, err := mintContextToken(claims.Subject, claims.Scopes, 30*time.Second) if err != nil { http.Error(w, "internal", http.StatusInternalServerError) return } r.Header.Set("X-Context-Token", ctxToken) r.Header.Del("X-User-Id") // never trust caller-supplied identity next.ServeHTTP(w, r) })}The downstream service verifies X-Context-Token against the same signing key. Now orders knows who, not just which workload.
Repudiation Is the Forgotten Letter
Section titled “Repudiation Is the Forgotten Letter”OpenTelemetry spans are forensic artifacts if — and only if — you retain them long enough and capture the right attributes. The attributes that matter for incident response are enduser.id, http.request.header.x-request-id, service.name, k8s.pod.name, and peer.service. Without those, you have pretty latency graphs and no evidence.
# OTel span attributes that survive an auditspan.set_attribute("enduser.id", user_id)span.set_attribute("http.request.header.x-request-id", request_id)span.set_attribute("peer.service", "orders")span.set_attribute("k8s.pod.name", os.environ.get("POD_NAME", "unknown"))span.set_attribute("auth.subject", jwt_subject)If your retention policy is 48 hours and your incident is discovered on day three, you have nothing. Push for 30 days minimum on auth and payment paths.
| STRIDE | Monolith example | Microservices example | Detection signal |
|---|---|---|---|
| Spoofing | Stolen session cookie | Forged X-User-Id downstream | 401 spike on auth service |
| Tampering | SQL injection | Unsigned Kafka message | Schema registry rejection |
| Repudiation | Missing audit log | No trace context propagation | Trace gap on sensitive path |
| Info Disclosure | Stack trace in 500 | Envoy admin on localhost:15000 | Unexpected /metrics scrape |
| DoS | Slow query | No circuit breaker on payments | p99 latency cascade |
| EoP | IDOR | Token scope creep (orders:*) | OPA policy denial spike |
Q: Isn’t mTLS enough for STRIDE? No. mTLS addresses Spoofing at the workload layer only. It does nothing for Tampering in queues, Repudiation, Information Disclosure via sidecars, DoS via cascading failure, or EoP via confused deputies. Treat it as one control, not a threat model.
Mapping Your Actual Architecture Before You Model Threats
Section titled “Mapping Your Actual Architecture Before You Model Threats”You cannot threat model what you cannot draw, and most teams cannot draw their own architecture accurately. The Confluence page from 2021 is fiction. The service mesh knows the truth.
Pull the real service graph from Kubernetes and mesh telemetry:
# Enumerate every service, its ports, and its labelskubectl get svc -A -o json | jq -r ' .items[] | [.metadata.namespace, .metadata.name, (.spec.ports[]? | "\(.port)/\(.protocol)"), (.spec.selector | to_entries | map("\(.key)=\(.value)") | join(","))] | @tsv' | column -t -s $'\t'For actual call edges, query the mesh. In Istio, istioctl and Kiali expose this:
# Extract real service-to-service edges from Istio telemetrykubectl exec -n istio-system deploy/prometheus -- \ promtool query instant http://localhost:9090 \ 'sum by (source_workload, destination_workload) ( rate(istio_requests_total[24h]) > 0 )'Every pair with non-zero traffic is a real edge. If it’s not in your DFD, your DFD is wrong.
Trust Boundary Identification in Practice
Section titled “Trust Boundary Identification in Practice”A trust boundary is where identity, authorization, or data classification changes. Every microservice architecture has three: ingress (untrusted → trusted), inter-service (service → service), and egress (trusted → third party). Annotate them explicitly:
# Explicit trust boundary: only cart may egress to paymentsapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: payments-ingress namespace: shopspec: podSelector: matchLabels: app: payments policyTypes: [Ingress] ingress: - from: - podSelector: matchLabels: app: orders ports: - protocol: TCP port: 8443If your DFD doesn’t match your mesh telemetry, your threat model is theater.
STRIDE Walkthrough on a Real E-Commerce Architecture
Section titled “STRIDE Walkthrough on a Real E-Commerce Architecture”Concrete target: API gateway → auth → catalog → cart → orders → payments → notifications, plus Kafka for async events and Redis for cart state. We walk STRIDE per boundary, not per service.
Ingress Boundary — API Gateway and Auth Service
Section titled “Ingress Boundary — API Gateway and Auth Service”Spoofing: token replay (no jti check), missing audience validation (aud claim ignored so a token from another service works). DoS: rate limits per IP only — a botnet rotates IPs, and legitimate users get throttled. EoP: the gateway injects X-User-Id and downstream trusts it. Classic.
# Vulnerable: gateway forwards client-supplied identity headerlocation /api/ { proxy_set_header X-User-Id $http_x_user_id; proxy_pass http://orders.shop.svc.cluster.local:8080;}The fix: strip client-supplied identity headers, verify the JWT at the gateway, and inject identity from verified claims only.
# Fixed: strip untrusted header, inject verified identitylocation /api/ { proxy_set_header X-User-Id ""; auth_jwt "shop"; auth_jwt_key_file /etc/nginx/jwks.json; proxy_set_header X-User-Id $jwt_claim_sub; proxy_set_header X-Context-Token $jwt_claim_ctx; proxy_pass http://orders.shop.svc.cluster.local:8080;}Inter-Service Boundary — Catalog, Cart, Orders
Section titled “Inter-Service Boundary — Catalog, Cart, Orders”Tampering: Kafka messages arrive without schema validation. A producer bug ships \{"order_id": 123\} instead of \{"order_id": "123"\}, and the consumer silently writes garbage. Info Disclosure: verbose errors leak internal DNS names (orders.shop.svc.cluster.local:8080 in a 500 body). Repudiation: no correlation ID propagation, so you can’t reconstruct a request across services.
A vulnerable Kafka consumer:
# Vulnerable: trusts whatever arrives on the topicconsumer = KafkaConsumer("orders.created", bootstrap_servers=BROKERS)for msg in consumer: order = json.loads(msg.value) process_order(order) # no schema, no signature, no correlation IDThe hardened version validates against a schema registry and checks a message signature:
# Hardened: schema-validated, signed, correlatedfrom confluent_kafka.schema_registry.avro import AvroDeserializerfrom confluent_kafka.serialization import SerializationContext, MessageField
deserializer = AvroDeserializer(schema_registry_client, order_schema)
for msg in consumer: order = deserializer(msg.value(), SerializationContext(msg.topic(), MessageField.VALUE)) if not verify_hmac(order, msg.headers().get("x-signature")): audit_log.warn("unsigned message rejected", topic=msg.topic(), offset=msg.offset()) continue process_order(order, correlation_id=msg.headers().get("x-request-id"))Egress Boundary — Payments, Notifications
Section titled “Egress Boundary — Payments, Notifications”Info Disclosure: PII in logs, PII in traces, PII in dead-letter queues. The DLQ is the one people forget — failed payment messages with full card metadata sitting in a topic with default ACLs. Tampering: webhook signature verification skipped “because it’s internal.” It’s not internal; it’s the public internet. DoS: synchronous payment calls with no circuit breaker mean a Stripe slowdown takes down checkout.
| STRIDE × Boundary | Concrete threat | Mitigation | Detection |
|---|---|---|---|
| Spoofing @ ingress | Token replay | jti denylist, short TTL | 401 spike per sub |
| Spoofing @ inter-service | Forged X-User-Id | Context token propagation | Missing X-Context-Token |
| Tampering @ inter-service | Malformed Kafka msg | Avro + HMAC | Schema registry rejects |
| Info Disclosure @ egress | PII in DLQ | Field-level encryption | DLQ scan for PAN regex |
| Repudiation @ inter-service | No correlation ID | OTel context propagation | Trace gap alert |
| DoS @ egress | Payment cascade | Circuit breaker (5xx > 20%) | p99 latency cascade |
| EoP @ ingress | Overbroad token scope | OPA scope check | Policy denial spike |
Q: How do I prioritize which threats to fix first? Score each as severity × exploitability × blast radius. A high-severity, low-exploitability threat on a single service loses to a medium-severity, trivially exploitable threat that reaches payments. Heuristic: if the threat touches money, PII, or auth, it goes first regardless of score.
Tooling — What Actually Helps and What’s Security Theater
Section titled “Tooling — What Actually Helps and What’s Security Theater”Three buckets. Diagram-first (IriusRisk, OWASP Threat Dragon): beautiful, rots in a quarter because nobody updates the diagram. Code-first (pytm, threagile): works if it lives in the repo and runs in CI. Runtime-derived (mesh telemetry, Hubble, Kiali): the only one that stays accurate, but requires mesh maturity.
Opinion: for most teams, pytm in CI plus mesh telemetry beats any SaaS threat modeling platform nobody logs into.
A minimal pytm model for one boundary:
from pytm import TM, Actor, Server, Dataflow, Boundary, Classification
tm = TM("Orders Boundary")tm.isOrdered = True
internet = Actor("Internet")api_gw = Server("API Gateway")orders = Server("Orders Service")orders.OS = "Linux"
ingress = Boundary("Ingress Boundary")inter_service = Boundary("Inter-Service Boundary")
flow = Dataflow(internet, api_gw, "HTTPS")flow.protocol = "HTTPS"flow.data = "User JWT + order payload"flow.order = 1
flow2 = Dataflow(api_gw, orders, "mTLS + Context Token")flow2.protocol = "gRPC"flow2.data = "Order command"flow2.order = 2
tm.process()Run it on every PR:
name: Threat Modelon: [pull_request]jobs: model: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - run: pip install pytm - run: python threat-model/orders.py --json > /tmp/model.json - run: python ci/check-new-threats.py /tmp/model.json threat-model/baseline.jsonThe check-new-threats.py script diffs against a baseline and fails on new unmitigated threats. That’s the enforcement.
For runtime drift, Hubble surfaces edges that weren’t in the model:
# New service-to-service edge not in the DFDhubble observe --namespace shop --last 1h \ --output json | jq -r ' select(.flow.verdict == "FORWARDED") | "\(.flow.source.workloads[0].name) -> \(.flow.destination.workloads[0].name)"' \ | sort -uFeed that list into your CI check. Any edge not in threat-model/edges.yaml fails the build.
| Tool category | Examples | Update cadence | Failure mode | When to use |
|---|---|---|---|---|
| Diagram-first | IriusRisk, Threat Dragon | Manual, quarterly | Stale within 1 quarter | Regulated audits |
| Code-first | pytm, threagile | Every PR | Ignored if not in CI | Most teams |
| Runtime-derived | Kiali, Hubble, Istio | Continuous | Needs mesh maturity | Platform teams |
Turning the Model into Controls — Policy, Code, and Detection
Section titled “Turning the Model into Controls — Policy, Code, and Detection”A threat model that doesn’t produce tickets is a PDF nobody reads. Every STRIDE finding maps to exactly one of: policy (NetworkPolicy, OPA, Kyverno), code (input validation, authz), or detection (SIEM rule, mesh alert).
Default-deny baseline, then allow-list:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-all namespace: shopspec: podSelector: {} policyTypes: [Ingress, Egress]---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: orders-egress namespace: shopspec: podSelector: matchLabels: app: orders policyTypes: [Egress] egress: - to: - podSelector: matchLabels: app: payments ports: - protocol: TCP port: 8443 - to: - podSelector: matchLabels: app: kafka ports: - protocol: TCP port: 9093OPA Gatekeeper constraint blocking privileged pods:
apiVersion: constraints.gatekeeper.sh/v1beta1kind: K8sPSPPrivilegedContainermetadata: name: no-privilegedspec: match: kinds: - apiGroups: [""] kinds: ["Pod"]Falco rule for unexpected outbound connections from cart:
- rule: Unexpected Outbound from Cart desc: Cart service connected to a destination outside its allow-list condition: > outbound and container and k8s.ns.name = "shop" and k8s.pod.label.app = "cart" and not fd.sip in (allowed_cart_destinations) output: > Unexpected outbound connection (pod=%k8s.pod.name cmd=%proc.cmdline dest=%fd.sip:%fd.sport) priority: WARNING tags: [network, mitre_exfiltration]Sigma rule for auth failure spikes:
title: Auth Service 401 Spikestatus: experimentallogsource: service: auth-servicedetection: selection: status_code: 401 timeframe: 5m condition: selection | count() by source_ip > 100level: highActionable takeaway: Every threat model entry must map to a control with an owner and a test. If it doesn’t, delete it from the model — it’s noise.
Common Mistakes and How to Avoid Them
Section titled “Common Mistakes and How to Avoid Them”Modeling the diagram, not the system. The Confluence page hasn’t been touched since the monolith split. Fix: derive the DFD from mesh telemetry quarterly, and fail CI on drift.
Treating mTLS as a silver bullet. mTLS authenticates workloads, not users, not intent, not authorization. Fix: separate workload identity from user identity in your model, and propagate user context explicitly.
Ignoring the control plane. Your mesh control plane, your CI/CD, your secrets manager — all in scope. The 2021 Codecov and 2022 CircleCI supply-chain incidents are the canonical examples: attackers didn’t break the app, they broke the pipeline that shipped it.
No owner per boundary. “The platform team owns it” means nobody owns it. Fix: name a human for every boundary in the model file. If you can’t, escalate.
Modeling only the happy path. Threat models that ignore retries, DLQs, and webhook replays miss half the attack surface. Fix: enumerate failure modes explicitly — every async path needs a DLQ threat entry.
Q: Isn’t mTLS enough for STRIDE? No. Workload identity is one control against Spoofing. It does nothing for Tampering in queues, Repudiation, Information Disclosure via sidecars, DoS via cascades, or EoP via confused deputies. See the STRIDE reframe section.
Q: How do I prioritize threats? Severity × exploitability × blast radius, with a bias toward anything touching money, PII, or auth. The e-commerce walkthrough table gives you a worked example.
Q: How often should I re-run threat modeling? On architecture change, on new data class introduction, and after every incident. Not annually — annual reviews are how you end up with a 2021 PDF defending a 2023 architecture.
Q: Do I need a threat modeling tool?
Only if it lives in CI. pytm in a repo beats a SaaS platform nobody logs into. Diagram-first tools rot; runtime-derived tools stay accurate if you have mesh maturity.
Q: What about serverless and managed services? Same principles, different boundaries. The trust boundary is at the IAM role and the event source mapping. STRIDE still applies; the controls shift to IAM policies and event schema validation.
A Repeatable Workflow You Can Actually Ship
Section titled “A Repeatable Workflow You Can Actually Ship”The loop: enumerate boundaries → map STRIDE per boundary → assign controls → encode as policy/code/detection → re-derive from telemetry quarterly. Platform team owns boundaries; service teams own threats within their boundary. Time budget: two hours per boundary, not two days per system.
The 90-Minute Boundary Workshop
Section titled “The 90-Minute Boundary Workshop”15 minutes: generate the DFD from telemetry, on screen, live. 45 minutes: STRIDE walk — one letter at a time, no rabbit holes. 30 minutes: assign each finding to a control with an owner and a ticket. In the room: service owner, one SRE, one security engineer. Not the whole team.
Keeping the Model Alive
Section titled “Keeping the Model Alive”CI job that diffs mesh topology against the model file:
#!/usr/bin/env bashset -euo pipefail
hubble observe --namespace shop --last 24h --output json \ | jq -r 'select(.flow.verdict == "FORWARDED") | "\(.flow.source.workloads[0].name) -> \(.flow.destination.workloads[0].name)"' \ | sort -u > /tmp/observed-edges.txt
sort -u threat-model/edges.txt > /tmp/modeled-edges.txt
if ! diff -q /tmp/observed-edges.txt /tmp/modeled-edges.txt > /dev/null; then echo "Topology drift detected:" diff /tmp/modeled-edges.txt /tmp/observed-edges.txt exit 1fiRepo structure:
services/orders/├── src/├── deploy/└── threat-model/ ├── orders.yaml # boundary definition ├── edges.txt # expected call edges └── controls.yaml # threat → control → owner mappingActionable takeaway: If your threat model isn’t in the same repo as the service, it’s already stale.
STRIDE didn’t break. Your architecture outgrew the way you applied it. Pick one boundary this week — ingress on your highest-risk service — run the 90-minute workshop, and commit the artifact to the repo. The model you can’t enforce in CI is the model that becomes a 2021 PDF defending a 2023 breach.