Threat Modeling with STRIDE: From a Whiteboard Diagram to Defenses in Code

By Sergey Nosov

7 October 2026

Suppose a records API checks every caller’s token and still lets Mallory delete Alice’s records. Every test passes, because the code does exactly what someone wrote. Nobody wrote down who may delete what. That is a design flaw, and the cheapest place to find it is a whiteboard. There it costs a conversation; in production it costs an incident, a postmortem, and a customer’s trust.

Threat modeling is the practice of finding flaws like that one while they are still cheap. You do not need to be a security expert to start. You need a picture of the system and a structured way to ask what can go wrong. This article covers both: a data-flow diagram for the picture and STRIDE for the checklist, inside the frame of Adam Shostack’s four questions. It walks one small API through every step and turns the four threats that land in its code into checks in a C# handler. It ends with how to run a session in an hour.

What Threat Modeling Is

The Threat Modeling Manifesto, written by fifteen practitioners including Shostack, defines the practice as “analyzing representations of a system to highlight concerns about security and privacy characteristics.” In plainer words: before a change ships, you look at a picture of it and ask how it could be attacked and what you would do about it.

The case for doing it early is that a missing control cannot be tested into existence. The 2025 edition of the OWASP Top 10 gives insecure design a category of its own. It explains why testing cannot make up for one: “An insecure design cannot be fixed by a perfect implementation as needed security controls were never created to defend against specific attacks.” The missing ownership check in the records API is exactly that kind of control.

Threat modeling is a team activity, not a tool you run. The Manifesto values “people and collaboration over processes, methodologies, and tools,” and its answer to who should threat model is “You. Everyone.” Shostack has written a whole book about STRIDE, and he still argues that threat modeling “can and should be accessible to those without that knowledge.” My Software Development Principles series treats secure by design as a principle of its own and calls threat modeling its primary technique.

The Four Questions

The cleanest frame for the whole practice is Shostack’s Four Question Framework, and this article follows its order:

  1. What are we working on? Draw the system.
  2. What can go wrong? Walk the drawing with STRIDE, so that you do not depend on a flash of inspiration.
  3. What are we going to do about it? Decide, and give each decision an owner.
  4. Did we do a good job? Check the model and the mitigations.

In a November 2024 white paper, Shostack warns that “people commonly make the mistake of rephrasing the questions,” and the wording earns its place. “What are we working on?” scopes the model to the change in front of you. The innocent variant “What are we building?” drifts, he says, toward a waterfall view in which you must analyze the whole system before you start.

Question One: What Are We Working On?

The usual answer to the first question is a data-flow diagram. Its notation is older than threat modeling and small enough to learn in a minute. A November 2006 article in MSDN Magazine by Shawn Hernan, Scott Lambert, Tomasz Ostwald, and Shostack describes the elements:

The article defines a trust boundary as “the border between trusted and untrusted elements.” Look for every place where data or control passes between parts of the system that trust each other differently. Typical ones are the internet and your API, your API and its database, and your service and a partner’s. Data crosses those lines in both directions, and each direction has its own threats: requests can be forged, and responses can leak. Threats tend to concentrate at the boundaries, because that is where an attacker’s input meets your trust. The 2006 article makes the same call about its own example. A flow that crosses the boundary “is clearly more exposed” than one that stays inside it, and it deserves more of your effort.

Here is the system this article follows. Alice uses a browser app to manage her records. When she deletes one, the browser sends a DELETE request with her bearer token to a records API. The API deletes the row if Alice owns it and appends an entry to an audit log. The token comes from an identity provider. A complete model would draw it as another external entity; I leave it out to keep the picture small.

boundary boundary boundary Browser Records API Records database Audit log Investigator 1 2 3 4 5 6

The numbered flows:

  1. A DELETE request for /records/42, with Alice’s bearer token.
  2. The response, such as 204 No Content or 404 Not Found.
  3. The delete, limited to a row the caller owns.
  4. The result: one row deleted, or none.
  5. The audit entry: who, what, and when.
  6. Audit entries, read during an investigation.

The three boundaries separate the internet from the API, the API from its data, and the data from the people who investigate.

Two rules of thumb from the 2006 article catch common drawing mistakes. Be careful of “magic data sources or sinks”: if data goes into a store and nothing ever reads it, the diagram is missing a reader. That rule is why the investigator is in the drawing; without a reader, the audit log would be a sink. And beware of “psychokinesis as a data transport”: data never goes straight from a person to a disk without a process in between. You do not need formal notation, either. Shostack notes that “threat modeling can be done without a flow diagram,” and a whiteboard photo with the boundaries marked is enough to start.

Question Two: What Can Go Wrong?

With the picture drawn, the second question asks what can go wrong, and STRIDE answers it with a checklist. Loren Kohnfelder and Praerit Garg introduced it in “The Threats to Our Products,” an internal Microsoft paper dated 1 April 1999. It asked every product team to use the model “to identify various types of threats the product is susceptible to during the design phase.” Twenty years after it appeared, Shostack wrote that it was revolutionary because it was “the first to structure the process of how to find threats.” He also granted that “STRIDE was not the first suggestion for a systematic approach.”

Each letter names a kind of threat, and each kind violates one security property. The 2006 article pairs them up, and the pairing is the most useful thing to memorize:

The categories overlap, and that is fine. Faced with a threat that fits two letters, the 2006 article shrugs: “Don’t get too hung up over the terminology.” The letters exist to make you look in six directions, not to file each finding in the right drawer.

STRIDE is a security lens, and privacy harms need a privacy lens, such as LINDDUN, “a recognized privacy threat modeling framework, developed by privacy experts at KU Leuven.” One of its seven categories is non-repudiation, the very property that STRIDE asks you to add. An anonymous reporting system may need to preserve its users’ ability to deny having filed a report.

STRIDE per Element

Walking six threats against every element sounds like a lot of work. The shortcut is that not every threat applies to every kind of element. The 2006 article’s chart, which Microsoft calls STRIDE per element, narrows the list. Treat it as a starting checklist, not a rule: if you can describe a credible threat the chart leaves out, write it down anyway.

Look at the entry for processes. The code you write is the only kind of element exposed to all six, and that is where most of the analysis lands. The chart turns “think of everything” into a finite list per element.

Walking the Example

Here is the records API, element by element. These are the kinds of threats a first pass finds; the point is the method, not completeness.

Notice where most of them gather: at the boundary between the browser and the API, and inside the API itself.

Question Three: What Are We Going to Do about It?

Each kind of threat violates a property, so each has a matching family of controls that restore it. The 2006 article makes the same point: recasting its threat-to-property table “in terms of available technologies gives you an idea of what kinds of mitigations are necessary.”

Not every threat deserves a mitigation. Shostack lists the options plainly: “You can mitigate, eliminate, accept, or transfer risk.” Eliminating means removing the feature that creates the threat. His example is a signup flow that collects pictures of IDs, which “adds a huge burden to secure the data that we never use again.” Transferring hands the risk to another party; the OWASP cheat sheet on threat modeling describes it as allocating “contractual or financial responsibility to another party.” Accepting is legitimate when it is a decision: write it down with an owner and a date to revisit it. The cardinal sin is accepting a risk by accident, by never asking the question.

A long list needs an order before it needs decisions. The output of a session is a prioritized list, and a rough high, medium, or low for impact and for likelihood is enough to put the worst threats first.

For the records API, every threat from the walk-through gets a decision:

In a real model, every line also gets an owner. Written down, the acceptance fits on a card:

Element:  Records database
Threat:   Tampering. A database
          administrator edits
          records or audit rows
          directly.
Decision: Accept until both
          tables become ledger
          tables.
Owner:    Records API lead
Review:   1 March 2027

Four Threats in One Handler

Here are the four threats that land in the API’s own code, defended in one ASP.NET Core endpoint. It uses the Microsoft.AspNetCore.Authentication.JwtBearer and Microsoft.Data.SqlClient packages. The setup comes first, because authentication runs before any handler. With no arguments, AddJwtBearer() reads its settings, such as the authority and the valid audiences, from the Authentication:Schemes:Bearer section of the app’s configuration:

var connectionString = builder.Configuration.GetConnectionString("Records");
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddScoped(_ => new SqlConnection(connectionString));
builder.Services.AddSingleton(TimeProvider.System);

The handler then takes the threats in order:

app.MapDelete("/records/{id:int}", async (int id, ClaimsPrincipal user,
    SqlConnection db, TimeProvider clock) =>
{
    // S, spoofing: RequireAuthorization() below admits only callers whose
    // bearer token passed validation (signature, issuer, audience, and
    // expiry) and names a user. Everyone else gets 401 or 403.
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;

    await db.OpenAsync();
    await using var tx = (SqlTransaction)await db.BeginTransactionAsync();

    // E, elevation of privilege: authentication is not authorization.
    // The WHERE clause deletes the record only if this caller owns it,
    // so the check and the delete are one step, and OUTPUT returns the
    // ID only if a row was really deleted. A missing record and another
    // user's record both get 404, so callers cannot probe for IDs.
    await using var delete = new SqlCommand(
        "DELETE FROM Records OUTPUT DELETED.Id " +
        "WHERE Id = @id AND OwnerId = @owner", db, tx);

    // T, tampering: both values are bound as typed parameters, so input
    // can never become part of the SQL text.
    delete.Parameters.Add("@id", SqlDbType.Int).Value = id;
    delete.Parameters.Add("@owner", SqlDbType.NVarChar, 255).Value = userId;
    if (await delete.ExecuteScalarAsync() is null) return Results.NotFound();

    // R, repudiation: record who did what, and when, in the same
    // transaction, so that no delete commits without its audit row.
    await using var audit = new SqlCommand(
        "INSERT INTO AuditLog (UserId, Action, RecordId, At) " +
        "VALUES (@user, 'record.delete', @id, @at)", db, tx);
    audit.Parameters.Add("@user", SqlDbType.NVarChar, 255).Value = userId;
    audit.Parameters.Add("@id", SqlDbType.Int).Value = id;
    audit.Parameters.Add("@at", SqlDbType.DateTimeOffset).Value =
        clock.GetUtcNow();
    await audit.ExecuteNonQueryAsync();

    await tx.CommitAsync();
    return Results.NoContent();
}).RequireAuthorization(policy => policy
    .RequireAuthenticatedUser()
    .RequireClaim(ClaimTypes.NameIdentifier));

Two details in the delete matter more than they look. First, the handler learns whether a row went from OUTPUT DELETED.Id, not from a row count. When a connection runs with SET NOCOUNT ON, the count from ExecuteNonQuery comes back as -1. A check for zero then lets Mallory through, with a 204 and an audit row for a deletion that never happened. Second, the ownership comparison has to be exact. OIDC defines the subject as “a case-sensitive string” of at most 255 ASCII characters that is unique only within its issuer. Give the owner column a binary collation and room for 255 characters, and store the issuer too if you trust more than one. Under my test database’s default case-insensitive collation, a token for ALICE deleted Alice’s record. These are the tables I tested against:

CREATE TABLE Records (
    Id int PRIMARY KEY,
    OwnerId nvarchar(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
    Title nvarchar(200) NOT NULL);

CREATE TABLE AuditLog (
    Id bigint IDENTITY PRIMARY KEY,
    UserId nvarchar(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
    Action nvarchar(64) NOT NULL,
    RecordId int NOT NULL,
    At datetimeoffset NOT NULL);

I ran the endpoint as a .NET 10 file-based app against SQL Server LocalDB, with JwtBearer 10.0.12, Microsoft.Data.SqlClient 7.1.1, and test tokens signed by a local key:

The results were the same with SET NOCOUNT ON forced on every connection. A two-step version looks up the owner and then deletes by ID without checking how many rows went. It failed the concurrency test once I stretched the gap between its two steps to 200 milliseconds: all five requests got 204 and wrote five audit rows for one deletion. Checking the row count would stop the duplicates, but an ID-only delete still leaves a gap in which ownership can change. The single statement closes both.

Question Four: Did We Do a Good Job?

The 2006 article offers a test for completeness. Keep going until you have addressed:

Its authors add a fair warning: “None of this will guarantee that the system is secure, but it will certainly help you sleep better at night.”

Completeness is half the question. The other half is whether the mitigations are real. A mitigation that exists only in the threat model is a wish. Turn each one into a backlog item and a test, and check in code review that it landed. OWASP’s advice for authorization holds for every letter: “Write tests to evaluate the vulnerability of the authorization mechanism. Do not deploy changes that make the tests fail.” The tests in the previous section are that kind.

Last, ask the people. For teams adopting the practice, Shostack suggests two simple questions: “Would you threat model again? Would you recommend threat modeling to your colleagues?” A no to either is a reason to ask why.

Running a Session in an Hour

Threat modeling should be light enough to do often. Here is how I would run a session:

Teams that work in short iterations can go further. Izar Tarandach’s Continuous Threat Modeling, which Shostack sums up as “threat model every story,” brings the practice into each iteration. The OWASP Top 10 makes a similar recommendation: “Threat modeling should be integrated into refinement sessions (or similar activities).”

Tools That Help

The tool is optional; the habit is not.

When to Reach for It

You do not need to threat model everything. Reach for it at four moments:

OWASP recommends threat modeling “for critical parts of the application such as authentication, access control, business logic, and key flows.” NIST’s Secure Software Development Framework (SP 800-218, version 1.1) makes risk modeling, threat modeling included, a task of its own: PW.1.1.

Checklist

Further Reading