Skip to content
Categoria: OPSEC10 min read

STRIDE Threat Modeling in Sprints: A Full Microservice Walkthrough

Por Lucas Andrade ·

How to apply STRIDE to a real payments microservice inside a two-week sprint, with a clean DFD, prioritized threats, and actionable mitigations.

STRIDE Threat Modeling in Sprints: A Full Microservice Walkthrough

Threat modeling dies in a drawer when it turns into a four-hour meeting with no owner and a 40-page PDF nobody opens again. At Basilisk OffSec we wire STRIDE into two-week sprints using a payments microservice as the guinea pig: one backend dev, one SRE, one offensive researcher, 90 minutes at kickoff and 30 minutes of review mid-sprint. The output is not a document, it is 12 Jira issues with verifiable mitigations. This post shows exactly how we ran it on the payments-api service, which receives PSP webhooks and talks to Postgres, Redis and a KMS, and how every STRIDE letter became a real patch in code within the same sprint rather than a finding that ages in a wiki.

Why threat modeling dies in a drawer

The failure mode is predictable: a big-bang workshop produces an exhaustive document, the document is never turned into work, and six weeks later the architecture has drifted past it anyway. The fix is to make threat modeling small, recurring and output-bound. We cap the kickoff at 90 minutes, restrict scope to one service and its immediate trust boundaries, and require that every threat leaves the room as a tracked issue with an acceptance criterion, not as a paragraph. The offensive researcher keeps the session honest by asking "how would I actually exploit this" rather than "is this theoretically bad". Time-boxing forces prioritization: you model the flows that cross a trust boundary first, because that is where real attackers operate, and you accept that a shorter session run every sprint beats a heroic one that happens once and rots.

The DFD and the trust boundaries

Before modeling, we drew the Data Flow Diagram in draw.io with four element types: external entities (the PSP, the frontend), processes (payments-api, worker-reconciliation), datastores (Postgres tx_db, Redis idempotency_cache) and the flows between them. We marked the trust boundaries explicitly: internet then Cloudflare then ingress then internal mesh then KMS. The diagram does not need to be pretty, it needs to be correct, and in 25 minutes everyone signed off. Anyone who has built a lab knows a wrong diagram leads to wrong tests, as covered in Web Pentesting From Scratch: Building a Safe Lab with DVWA, Juice Shop and Burp Suite. Same rule here: if your webhook flow does not show HMAC validation happening before the JSON parse, you are modeling the service that lives in your head, not the one in production.

Spoofing: proving who is really calling

Spoofing showed up first on the PSP to payments-api flow. The webhook arrived with an X-Signature header, but verification ran after json.loads(body), leaving a parser-differential window where a forged event could be partially processed before the signature check failed. The fix was to validate the HMAC-SHA256 with a KMS-rotated key before touching the body at all, using a constant-time comparison via hmac.compare_digest to avoid a timing oracle. We also pinned the accepted signature algorithm rather than trusting a client-supplied header, closing the door on an algorithm-confusion downgrade. The general lesson: authenticate the message before you parse it, and never let identity be asserted by the same untrusted input you are about to trust. Every external entity on the DFD got the same question, which is where the spoofing findings clustered.

Tampering: integrity of data at rest and in flight

Tampering showed up in Redis: the idempotency cache had no fixed TTL or signature, so an attacker with internal network access could plant entries and trigger charge replays or suppress legitimate ones. We added a namespaced key prefix plus a short HMAC on the key, and enforced an ACL with requirepass and TLS on Redis 7 so the datastore is no longer a soft trust zone just because it sits inside the mesh. On the wire, we required mutual TLS between the API and the worker so a compromised sidecar could not silently rewrite reconciliation messages. The modeling question for every flow and datastore was blunt: if an attacker sat here, what could they change and would we notice. Anywhere the answer was "change silently", we added integrity protection, and anywhere it was "we would not notice", we added the logging that the next letter depends on.

Repudiation: making actions provable

Repudiation was handled with an append-only audit_log table using chained hashes, so each record commits to the previous one and a silent deletion or edit breaks the chain. This is the same tamper-evident pattern we use when documenting pivoting across segmented networks in Pivoting with Chisel and Ligolo-ng: Segmented Networks in a Pentest Lab, applied here to money movement instead of operator actions. We log the authenticated principal, the request id, the before-and-after state hash and a monotonic timestamp for every charge, refund and reconciliation decision. The point is not to collect logs for their own sake, it is to be able to prove, after an incident, exactly who did what and in what order, without relying on a mutable table an attacker with database access could rewrite. Repudiation controls are cheap to add up front and nearly impossible to reconstruct after the fact.

Information Disclosure: the heaviest category

Information Disclosure was the heaviest category with eight findings. Stack traces leaked via FastAPI 500s in a staging environment mirrored to prod, secrets surfaced in a /debug route behind a magic header a 2024 intern forgot to remove, and the Prometheus /metrics endpoint exposed labels carrying card_bin. We fixed it with middleware that only serializes {error_id, code} to the client, killed the debug route, and applied a relabel_config in Prometheus to drop sensitive labels at scrape time. To make the impact concrete for the team we demonstrated a POC equivalent to an SSRF pulling cloud metadata, an exercise documented in SSRF Demystified: Exploiting Cloud Metadata in a Local AWS Lab. Seeing a real token pulled from a metadata endpoint changed the room from "that log is fine" to "redact everything at the boundary".

Denial of Service: beyond rate limiting

Denial of Service was not treated as just rate limiting. We mapped algorithmic amplification: a /search endpoint accepted a client-supplied regex and hit Postgres with LIKE %term%, which is both a ReDoS and a full-scan risk. We replaced it with a tsvector plus GIN index and a 64-character cap on the term, turning an unbounded query into a bounded one. We added a per-API-key token bucket in Envoy at 100 rps with a burst of 200, and a circuit breaker on the KMS client using pybreaker, because the managed KMS has a 1200 ops per second quota per key and we had already caused a 14-minute self-inflicted incident in January by hammering it. DoS modeling asks where a small input produces disproportionate work, and every such point either got a bound, a cache or a breaker so a single caller cannot take the service down.

Elevation of Privilege: closing the list

Elevation of Privilege closed the list. The internal API JWT used HS256 with a single secret shared across six services, which means any one compromised service could mint tokens accepted by all the others. We migrated to RS256 with per-service keys held in KMS, audience-specific claims so a token minted for one service is rejected by another, and full exp, nbf and iss validation in a single middleware shipped as the internal library basilisk-authz==2.3.0. Centralizing the check in one audited library removed the drift where each service validated slightly differently, which is itself an elevation path. The modeling question was simple: if this component is fully owned, what does the attacker gain elsewhere, and the shared-secret answer was "everything", so it became the highest-priority fix in the batch.

Turning threats into tracked issues

By the end of the sprint every item became an issue titled STRIDE-<letter>-<num>: <threat> with a mitigation:<status> tag, because a threat without a ticket is a threat that will not be fixed. Of the 12 threats raised, 9 shipped as merged PRs inside the same sprint, 2 were accepted as residual risk with a documented 90-day review date, and 1 turned into an epic to refactor the webhook module. Total cost was about 4 hours of distributed meetings plus code work that was already on the sprint board. The discipline that makes this stick is refusing to close the modeling session until every raised threat has an owner, a status and a verifiable acceptance criterion, so the output is a backlog you can burn down rather than a report you can ignore.

Tooling and CI gates that keep it honest

We backed the manual modeling with automation so regressions do not silently reopen a fixed threat. SAST runs with custom Semgrep rules that encode the specific mistakes we found, such as an HMAC check after a parse, and SCA runs with osv-scanner against the dependency tree. Both are blocking gates for High and Critical findings in the pipeline, so a pull request that reintroduces a modeled weakness fails CI rather than shipping. We keep the Semgrep rules in the same repo as the service, versioned alongside the code they guard, and review them when a new STRIDE finding suggests a pattern worth catching automatically. Automation does not replace the human session, it makes the human session compound: each sprint the manual model finds the novel issues and the rules make sure last sprint stays fixed.

FAQ: is 90 minutes really enough?

For one service with a clear boundary, yes, and the constraint is a feature rather than a compromise. A tight box forces the team to model the flows that cross a trust boundary first, which is where the exploitable bugs actually live, and to defer the low-value analysis of purely internal helper functions. If a service is so large that 90 minutes cannot cover its boundary-crossing flows, that is a signal the service is doing too much and should be split, not that the session should run for four hours. The recurring cadence is what makes the short box work: anything you miss this sprint you catch next sprint, against an architecture that has only drifted two weeks instead of six months.

FAQ: what if we have no offensive researcher?

You can run STRIDE without a dedicated red-teamer, but you must deliberately import the adversarial mindset the researcher supplies, because a room of builders tends to model how the system should work rather than how it breaks. Assign one person per session to play attacker and hold them to concrete exploitation, asking "give me the exact request that abuses this" rather than accepting "that could be risky". Seed the session with a checklist of the six letters against every flow and a library of past findings so the questions are structured rather than improvised. It is less effective than a real offensive specialist, but a disciplined attacker-role rotation plus the automated gates recovers most of the value, and it grows the security instinct across the whole team over time.

Conclusion: a shared language, not an audit checklist

STRIDE is not an audit checklist, it is a shared language between dev, SRE and offensive that turns architecture into a prioritized list of fixes. The practical recipe is to start with a one-page DFD, force the six letters against every flow that crosses a trust boundary, require every threat to land as an issue with a verifiable acceptance criterion, and back the whole thing with SAST and SCA gates so fixed threats stay fixed. Run it every sprint against a small scope instead of once against everything, keep the session honest with an attacker role, and measure success by merged PRs rather than pages written. If it does not turn into code this sprint, it was not threat modeling, it was theater.

Related posts

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly