Skip to content

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.

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 service
apiVersion: apps/v1
kind: Deployment
metadata:
name: cart
spec:
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.

DimensionMonolithMicroservices
Trust boundaries2–4, mostly yoursDozens, mostly not yours
Data flow complexityLinear, inspectableGraph, changes weekly
Blast radius of one compromiseWhole appOne service → lateral movement
Ownership modelOne teamN teams, fuzzy interfaces
Attack surface countFixed at deployGrows 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 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 context
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: shop
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-allow-cart
namespace: shop
spec:
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 downstream
func 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.

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 audit
span.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.

STRIDEMonolith exampleMicroservices exampleDetection signal
SpoofingStolen session cookieForged X-User-Id downstream401 spike on auth service
TamperingSQL injectionUnsigned Kafka messageSchema registry rejection
RepudiationMissing audit logNo trace context propagationTrace gap on sensitive path
Info DisclosureStack trace in 500Envoy admin on localhost:15000Unexpected /metrics scrape
DoSSlow queryNo circuit breaker on paymentsp99 latency cascade
EoPIDORToken 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:

Terminal window
# Enumerate every service, its ports, and its labels
kubectl 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:

Terminal window
# Extract real service-to-service edges from Istio telemetry
kubectl 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.

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 payments
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payments-ingress
namespace: shop
spec:
podSelector:
matchLabels:
app: payments
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
app: orders
ports:
- protocol: TCP
port: 8443

If 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 header
location /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 identity
location /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 topic
consumer = KafkaConsumer("orders.created", bootstrap_servers=BROKERS)
for msg in consumer:
order = json.loads(msg.value)
process_order(order) # no schema, no signature, no correlation ID

The hardened version validates against a schema registry and checks a message signature:

# Hardened: schema-validated, signed, correlated
from confluent_kafka.schema_registry.avro import AvroDeserializer
from 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 × BoundaryConcrete threatMitigationDetection
Spoofing @ ingressToken replayjti denylist, short TTL401 spike per sub
Spoofing @ inter-serviceForged X-User-IdContext token propagationMissing X-Context-Token
Tampering @ inter-serviceMalformed Kafka msgAvro + HMACSchema registry rejects
Info Disclosure @ egressPII in DLQField-level encryptionDLQ scan for PAN regex
Repudiation @ inter-serviceNo correlation IDOTel context propagationTrace gap alert
DoS @ egressPayment cascadeCircuit breaker (5xx > 20%)p99 latency cascade
EoP @ ingressOverbroad token scopeOPA scope checkPolicy 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:

.github/workflows/threat-model.yml
name: Threat Model
on: [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.json

The 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:

Terminal window
# New service-to-service edge not in the DFD
hubble observe --namespace shop --last 1h \
--output json | jq -r '
select(.flow.verdict == "FORWARDED") |
"\(.flow.source.workloads[0].name) -> \(.flow.destination.workloads[0].name)"' \
| sort -u

Feed that list into your CI check. Any edge not in threat-model/edges.yaml fails the build.

Tool categoryExamplesUpdate cadenceFailure modeWhen to use
Diagram-firstIriusRisk, Threat DragonManual, quarterlyStale within 1 quarterRegulated audits
Code-firstpytm, threagileEvery PRIgnored if not in CIMost teams
Runtime-derivedKiali, Hubble, IstioContinuousNeeds mesh maturityPlatform 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/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: shop
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-egress
namespace: shop
spec:
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: 9093

OPA Gatekeeper constraint blocking privileged pods:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sPSPPrivilegedContainer
metadata:
name: no-privileged
spec:
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 Spike
status: experimental
logsource:
service: auth-service
detection:
selection:
status_code: 401
timeframe: 5m
condition: selection | count() by source_ip > 100
level: high

Actionable 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.

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.

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.

CI job that diffs mesh topology against the model file:

ci/check-topology-drift.sh
#!/usr/bin/env bash
set -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 1
fi

Repo structure:

services/orders/
├── src/
├── deploy/
└── threat-model/
├── orders.yaml # boundary definition
├── edges.txt # expected call edges
└── controls.yaml # threat → control → owner mapping

Actionable 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.