Mert Özen Further With Every Line
Security Threat Modeling and a Secure Development Process — #02

Finding and Prioritising Threats with STRIDE: STRIDE per Element, Attack Trees, Risk Scoring and Mitigation Decisions

Mert Özen Nov 15, 2026 11 min 6 views
Finding and Prioritising Threats with STRIDE: STRIDE per Element, Attack Trees, Risk Scoring and Mitigation Decisions

You apply STRIDE to every DFD element according to its type, rank the threats by likelihood × impact, decide to mitigate, eliminate, transfer or accept each one, and tie every decision to a test or a risk register row. On the Bookstore webhook and the institutional purchase approval: LINDDUN, attack trees, why DREAD was dropped and xUnit tests that verify the four-eyes rule, with real output.

Version note: The code in this post was built and run with .NET 10; the tests ran with xUnit v3 4.0.3, and the model was opened in OWASP Threat Dragon 2.6.2 and validated against its schema.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, sells to schools and libraries in bulk: an Institutional Order has at least 20 books and gets a 10% institutional discount. The approval limit of Lakeside School's library is 5,000 TRY; a purchase request above the limit becomes an order only after someone with the InstitutionApprover role approves it. In the code the approve endpoint checks: is the caller in the same institution, and is the caller's role InstitutionApprover?

Linus, who runs the school's IT, was given both the InstitutionBuyer and the InstitutionApprover role to speed things up. Guess: can Linus create a 7,002 TRY request and approve it himself? According to the check list, yes; both conditions hold. This is not a coding error but a missing rule: the four-eyes principle, where the person who does something and the person who approves it are different people, is written down nowhere. In this post we talk about finding such threats systematically, ranking them and turning each one into a test or a recorded decision.

Applying STRIDE to the Elements

The six letters of STRIDE were covered in the Insecure Design post of the Web Security (OWASP Top 10) series. There we used them as a list of questions; here we apply the same list to every element of the DFD from the first post. The approach is called STRIDE per element. The idea: not every element type is equally exposed to every threat, so for each element you ask the questions that fit its type rather than all six.

Element typeSTRIDE questions to ask firstWhat we asked in Bookstore
External entitySpoofing, RepudiationIs the sender of the webhook really the payment provider?
ProcessAll sixWho can approve in Institutional, can someone approve their own request?
Data storeTampering, Information disclosure, Denial of service; Repudiation if it is a logWho holds the BookstoreDb backups?
Data flowTampering, Information disclosure, Denial of serviceDoes the shipping carrier receive more data than it needs?

In the session Emre put the diagram on the screen and the team went element by element with the table. On the webhook flow, the Spoofing question immediately produced a threat with an obvious answer: if a payment.succeeded request from the internet marks the order Paid without its signature being verified, anyone can mark any order as paid. On the same flow, the Tampering question brought up replay, where a captured valid request is sent again. On the Payments process, the Repudiation question showed that support cannot answer "did this status change come from the provider or from back office?".

Every threat found went onto its element in Threat Dragon: title, STRIDE type, description, mitigation, status and score. The institutional purchase approval was added as a second diagram. Because the model is JSON, extracting the threat list with jq makes it easy to compare with the backlog:

jq -r '[.detail.diagrams[].cells[] | .data.name as $e | .data.threats[]? | {n: .number, e: ($e | gsub("\n"; " ")), status, severity, score, type}] | sort_by(.n)[] | [.n, .status, .severity, .score, .type, .e] | @tsv' threat-model/bookstore-checkout.json
1	Mitigated	High	L2 x I3 = 6	Tampering	1 POST /api/orders
2	Mitigated	Medium	L2 x I2 = 4	Tampering	Ordering
3	Open	Medium	L2 x I2 = 4	Denial of service	Ordering
4	Open	High	L2 x I3 = 6	Spoofing	7 webhook payment.succeeded
5	Open	Low	L1 x I2 = 2	Tampering	Payments
6	Open	Low	L1 x I2 = 2	Repudiation	Payments
7	Open	Medium	L1 x I3 = 3	Information disclosure	BookstoreDb
8	Open	Low	L2 x I1 = 2	Information disclosure	9 create shipment
9	Mitigated	High	L2 x I3 = 6	Elevation of privilege	Institutional
10	Mitigated	High	L2 x I3 = 6	Tampering	Institutional
11	Mitigated	High	L2 x I3 = 6	Elevation of privilege	Institutional
12	Open	Medium	L2 x I2 = 4	Elevation of privilege	Institutional

The table is a starting point, not a checklist. If a threat outside the letters in the "first" column comes to mind, write it down; the point of STRIDE per element is to remind you of forgotten questions, not to limit the questions you can ask.

Privacy Threats with LINDDUN

STRIDE asks about security threats; it does not fully capture risks around personal data. LINDDUN is used for that: Linking, Identifying, Non-repudiation, Detecting, Data Disclosure, Unawareness & Unintervenability and Non-compliance. LINDDUN also works on a DFD; you read the same diagram once more through this lens. Two questions stood out in Bookstore. On the flow to the shipping carrier, Data Disclosure: the carrier sees the order contents and the e-mail address and needs neither (T08). On Ordering, Linking: can institutional users' order history be joined with their personal accounts through the same e-mail address? The second question is directly about KVKK, so the details are in the personal data inventory post of the Data Privacy and KVKK series; here it stays as a note in the model.

Bypassing the Institutional Approval: an Attack Tree

STRIDE goes element by element; an attack tree works backwards from an attacker's goal. The root node is the goal, the branches are alternative paths to it (OR) or steps needed together (AND). For the institutional approval the team came up with this tree; the numbers are threats in the model:

GOAL: an order above the unit limit is placed without a valid approval
  OR
  +-- approve it yourself (T09)
  |     +-- a user with both roles approves own request
  |     +-- an approver of another institution approves it
  +-- skip the approval step (T11)
  |     +-- call POST .../purchase-requests/{id}/order while PendingApproval
  +-- change the request after approval (T10)
  |     +-- PUT .../lines after approval, then order
  +-- stay under the limit (T12)
        +-- split one large request into several small ones

The value of the tree is that it shows threats sitting separately in the STRIDE list serving the same goal. At first sight T12 looks like "something the rules allow"; in the tree it leads to the same goal as the other three paths. That also explains why it needs a written decision.

Prioritising: Likelihood × Impact

You cannot close all twelve threats in the same sprint. The Bookstore team uses a simple score: likelihood 1–3 (1: needs inside knowledge and access, 3: anyone on the internet can try it) times impact 1–3 (1: annoys one user, 3: money lost or another customer's data). 6–9 is High, 3–5 Medium, 1–2 Low. The score sits in each threat's score field in the model as L2 x I3 = 6, so the answer to "why High?" is read together with the threat.

A more detailed formula can be tempting. DREAD is the best-known example. But each of its five criteria is a guess that varies from person to person, and although a number comes out at the end, precision does not increase. According to a 2018 post by Adam Shostack, disagreements over these scores happened at Microsoft again and again and led to a switch to a "bug bar", an approach that decides with predefined thresholds. Our 3×3 matrix is really a small bug bar: the goal is not to find the right number but for the team to speak the same language. CVSS, on the other hand, is for a vulnerability that has been found, not for a design threat; prioritising dependency vulnerabilities with CVSS and EPSS is covered in the dependency scanning post of the DevSecOps series.

The Mitigation Decision: Four Options

Every threat gets one of four decisions, and all of them are valid. Mitigate: you add a control; for T04, verifying the webhook signature and timestamp, then reading the payment status again from the provider's API. Eliminate: you remove the feature that creates the threat from the design; never carrying the price in the request body in T01 is such a decision, there is no field left to manipulate. Transfer: you leave the risk to the party that manages it better; card details being entered on the provider's page (flow 6 in the first post) is exactly that. Accept: you accept the risk knowingly.

Acceptance is the decision misused most, because it gets confused with "doing nothing". The difference is that the decision is written down: who accepted it, why, and when it will be looked at again. In Bookstore every accepted risk is a row in threat-model/risk-register.md:

| Id | Threat | Decision | Why | Owner | Review |
|---|---|---|---|---|---|
| R-01 | T07 Customer data readable from backups | Accept residual risk | Backups are encrypted and the storage account allows only the backup identity; the rest needs a second key management system | Emre | 2027-05-01 |
| R-02 | T12 Splitting a request to stay under the approval limit | Accept for release 1 | Each split request is still under the unit's limit and visible in the daily report; the rolling limit needs a product decision | Selin | 2027-03-01 |

Selin owns R-02 because the rule is a product decision: limiting a unit's daily total changes how institutions buy, and a developer cannot choose that alone. A row past its review date goes back to the backlog.

Turning a Threat into a Test

The only condition for marking a threat "Mitigated" is a test that verifies it and passes. For the Linus scenario (T09) we wrote the test first. The test goes through HTTP, not through the internals; it takes the user identity from an authentication handler that exists only in the test project, so no test code enters the API:

[Fact]
[Trait("threat", "T09")]
public async Task Buyer_with_both_roles_cannot_approve_own_request()
{
    HttpClient linus = factory.CreateClientAs(TestUsers.Linus);
    PurchaseRequestView request = await CreateAsync(linus, (Middlemarch, 20));

    HttpResponseMessage approve = await linus.PostAsync($"/api/institutions/me/purchase-requests/{request.Id}/approve", null, ct);

    Assert.Equal(HttpStatusCode.Forbidden, approve.StatusCode);
    ProblemDetails? problem = await approve.Content.ReadFromJsonAsync<ProblemDetails>(ct);
    Assert.Equal("The requester cannot approve their own request.", problem?.Detail);
}

The Trait ties the test to the threat in the model; you can run all tests of that threat with one command. While the rule was not in the code yet, the output was:

dotnet test --filter-trait "threat=T09"
failed Bookstore.Api.Tests.PurchaseApprovalTests.Buyer_with_both_roles_cannot_approve_own_request (2ms)
  Assert.Equal() Failure: Values differ
  Expected: Forbidden
  Actual:   OK
Test run summary: Failed!
  total: 3
  failed: 1
  succeeded: 2

OK means Linus approved his own request. The fix is a single rule in PurchaseRequestService.ApproveAsync; what matters is that it runs after the role check and regardless of the state:

public async Task<PurchaseOutcome> ApproveAsync(InstitutionalCaller caller, int requestId, CancellationToken ct)
{
    PurchaseRequest? request = await FindAsync(caller, requestId, ct);
    if (request is null)
    {
        return new PurchaseOutcome.NotFound();
    }
    if (!caller.IsApprover)
    {
        return new PurchaseOutcome.Forbidden("Only an institution approver can approve.");
    }
    // Four-eyes rule: holding both roles is not enough to approve your own request (threat T09).
    if (request.RequestedBy == caller.UserId)
    {
        return new PurchaseOutcome.Forbidden("The requester cannot approve their own request.");
    }
    if (request.Status != PurchaseRequestStatus.PendingApproval)
    {
        return new PurchaseOutcome.Conflict($"The request is {request.Status}, not PendingApproval.");
    }
    request.Status = PurchaseRequestStatus.Approved;
    request.ApprovedBy = caller.UserId;
    request.ApprovedAtUtc = clock.GetUtcNow().UtcDateTime;
    request.ApprovedLinesHash = request.ComputeLinesHash();
    await db.SaveChangesAsync(ct);
    return new PurchaseOutcome.Done(request);
}

For an approver of another institution (Alan), FindAsync already filters the request by institution id and returns 404; we do not even say that the request exists. The threat of changing lines after approval (T10) has two layers: when the lines change, the request goes back to PendingApproval, and at order time the SHA-256 hash of the approved lines is compared with the current lines. The second check kicks in if another path that changes lines (a correction from back office, say) is added later:

// The state is checked on the server; the client cannot skip the approval step (threat T11).
if (request.Status != PurchaseRequestStatus.Approved)
{
    return new PurchaseOutcome.Conflict($"The request is {request.Status}; only an approved request can be ordered.");
}
// Defence in depth: the lines must still be the ones that were approved (threat T10).
if (request.ApprovedLinesHash != request.ComputeLinesHash())
{
    return new PurchaseOutcome.Conflict("The request changed after approval.");
}

After adding the rule, the same command and the whole test suite:

Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 1s 457ms
Test run summary: Passed!
  total: 16
  failed: 0
  succeeded: 16
  skipped: 0
  duration: 1s 278ms

Only then were T09, T10 and T11 marked Mitigated in the model, with the test name in the mitigation field. The webhook signature (T04) is still Open; it sits in the backlog as SEC-11 and will be closed with its own test.

In Production

Keep threat numbers stable. Once T09 has been given, the number of a deleted threat is never reused; test Traits, backlog items and the risk register depend on these numbers. The threatTop field in the model holds the next number.

An accepted risk has to be watched. The acceptance of T12 rests on the assumption that it "shows up in the daily report"; without that report the decision is empty. In Bookstore each unit's daily institutional order total is tracked as a metric and an unusual rise raises an alert. How to set that up is in the post on observing security events in the ASP.NET Core Security series.

The tests you write for business rules such as approval are the most valuable security tests of the system, because no scanner can find them for you. DAST tries to send an unauthorised request to an endpoint, but it does not ask "what happens when the same person has two roles?".

Common Pitfalls

Saying "we'll put a WAF in front" for every threat is the most common escape. A WAF can block known attack patterns, but Linus's request is a perfectly valid HTTP request; there is nothing in it to block. The mitigation of a business logic threat lives inside the business logic.

The second pitfall is closing a mitigation without writing a test. A threat marked Mitigated because "the check was added" tells nobody when a refactoring silently removes the check. When the test's Trait and the threat number in the model are the same, that link can be read from the code.

The third is spending too long on scoring. If the "is this a 4 or a 6?" debate passes ten minutes, write the higher one and move on; the order will be discussed again in sprint planning anyway. The fourth is threats that stay in the model and never reach the backlog: a threat that is not in the backlog is nobody's job.

A Small Experiment

★☆☆ Take T05 (webhook replay) from the model: with which piece of information can you say "this event was already processed"? How do you verify that with a unique index and an integration test?

★★☆ Replace the risk register row for T12 with a mitigation: if a unit's total without approval in the last 24 hours exceeds the limit, a new request becomes PendingApproval. Write the test first, see it red, then add the rule.

★★★ Apply the seven LINDDUN types to the Shipping process. Which ones are real threats for Bookstore and which are not? Add the real ones to the model in Threat Dragon and look at the JSON diff.

We now have threats and decisions; but a decision like "verify the webhook signature" can be forgotten again in the next feature. In the third post of the series we turn these decisions into written security requirements with OWASP ASVS 5.0 and tie them to the acceptance criteria of user stories and to the Definition of Done.