System Design (High-Level Design) Module 17 – Security, privacy and compliance
Threat modelling a design with STRIDE
Draw the data flows and trust boundaries of a design, ask the STRIDE questions where data crosses them, rank the threats and give each one a response.
What you will learn
- Draw a design as a data flow diagram and mark the trust boundaries that its data crosses
- Apply STRIDE per element to a design, starting with the elements on its trust boundaries
- Rank threats by likelihood times impact, breaking ties by the cost of the fix
- Choose a response and an owner for every threat, and write mitigations as testable requirements
Before you start
What you’ll learn · Before you start
What you will learn
- Draw a design as a data flow diagram and mark the trust boundaries that its data crosses
- Apply STRIDE per element to a design, starting with the elements on its trust boundaries
- Rank threats by likelihood times impact, breaking ties by the cost of the fix
- Choose a response and an owner for every threat, and write mitigations as testable requirements
Before you start
On this page
Drawn as a diagram, a URL shortener has 20 parts: the people and sites around it, four services, three stores and the nine flows of data between them. Asked “is it secure?”, a team finds little; asked the 68 questions that STRIDE puts to those 20 parts, the same team finds the edit endpoint that lets any signed-in customer change another customer’s link. A A structured look at a design that asks what can go wrong and what will be done about it, and records the answer as a ranked list of threats, each with a response and an owner. is that structured look at a design, and STRIDE supplies the kinds of wrong: a letter each for spoofing, tampering and repudiation, then for information disclosure, denial of service and elevation of privilege.
The result is a short, ranked list of threats, each with a response and an owner, written into the design document before the code exists. At that point a fix costs a change to a diagram; after launch the same fix can cost a data migration, an incident and a disclosure.
Four questions organise every threat model
The Threat Modeling Manifesto, written by a group of practitioners, reduces the method to four questions: What are we working on? What can go wrong? What are we going to do about it? Did we do a good enough job? The OWASP Threat Modeling Cheat Sheet builds its four phases on the same questions. Step through one pass of them on the URL shortener, from the diagram to the change that starts the next pass:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- What are we working on?. The first answer is a picture. The URL shortener drawn as a data flow diagram has 20 elements, and a dashed line marks where the internet ends and the service begins. Four flows cross that line.
- What can go wrong?. STRIDE per element asks each element only the letters its kind can suffer. The 20 elements give 68 questions, and the 12 elements on the boundary account for 44 of them, so those are asked first.
- What are we going to do?. The list holds ten threats. Each gets a score, likelihood times impact, then a response and an owner. After the first pass two of them still have no response, and the list is not finished until they do.
- Did we do a good enough job?. Every threat now has a response, the mitigations are written as requirements that a test can check, and every accepted risk has a name and a date beside it.
- The design changes. Partners ask to be told when their links are clicked, through a webhook the shortener calls. That is a new flow across a trust boundary, so the model opens again at the first question, for that part of the design.
Each question leaves something behind: a diagram, a set of questions for each part of it, a ranked list of threats with their responses, and a check that nothing was left without one. The sections below produce each of them for the shortener, and the last section turns them into the security section of a design document. The manifesto favours this as continuous work, small passes done early and often, over one large exercise that is out of date by the next release.
Checkpoint
Check: The four questions of a threat model
A data flow diagram shows where trust changes
The first question is answered with a picture, and the most common picture is a A drawing of a system as external entities, processes, data stores and the data flows between them, with its trust boundaries marked. with five kinds of element:
- External entities: people and systems outside your control that send or receive data, such as a visitor, a partner’s API or a payment provider.
- Processes: code you run, such as services, workers and functions.
- Data stores: anything that keeps data, such as databases, caches, queues, logs and buckets.
- Data flows: data moving between two elements, such as an HTTP request, a message or a file copy.
- Trust boundaries: lines where the level of trust changes, such as between the internet and your service, or between your service and a third party.
The URL shortener as a data flow diagram, with its trust boundary
Text description of the diagram
The diagram has two dashed boxes, the two trust zones.
The first box, "Internet: untrusted", holds the link creator, the visitor, the admin and the target sites that links point to.
The second box, "Our service: trusted", holds four processes and three data stores: the API service, which creates, edits and deletes links; the redirect service, which answers GET /<code>; the preview fetcher, which fetches target pages; the admin console; the links database; the link cache; and the click log. Inside this box, the admin console asks the API service to disable a link, the API service writes to the links database, and the redirect service reads the link cache, reads the links database on a miss and appends each click to the click log.
Four numbered flows cross the boundary between the boxes: (1) the link creator calls POST /links on the API service; (2) the visitor calls GET /<code> on the redirect service; (3) the preview fetcher fetches a page from a target site; (4) the admin signs in to the admin console and manages links.
This is the URL shortener drawn that way. Anyone on the internet can create a link, follow one, or be the target site whose page the preview fetcher downloads. Staff manage links through an admin console that, in this first version, is reachable from the internet like everything else. Four flows cross the A line in a design where data or a request passes between parts that trust each other differently, such as from the internet into a service., and those four are where an attacker touches the system directly.
Draw only as much detail as the decisions need. One box per service is enough for a design review; a box per class is too much, and a diagram without its data stores hides where the valuable data sits. Microsoft’s STRIDE article puts the same rule as thinking of major functions, not of their implementation.
Drawing the boundary by who should use a part
“The admin console is internal: only staff use it” puts the console inside the boundary, and its sign-in never gets asked about spoofing. A boundary follows who can reach a part, not who is meant to: the console answers anyone on the internet, so the admin’s flow into it crosses the boundary and is walked first.
Checkpoint
Check: Data flow diagrams and trust boundaries
Each STRIDE letter names a property the design must keep
Each letter of STRIDE is one kind of threat, and each kind breaks one property that a secure design keeps. Read the table across: the threat and its letter, what it breaks, how it looks in a web service and the usual defence.
| Threat (letter) | Property it breaks | Example in a web service | Typical mitigation |
|---|---|---|---|
| Spoofing (S) | authentication: Establishing who is calling, a person, a service or an app, for example from a session, a signed token or a certificate. More in “Authentication and authorisation for services” | A stolen session token lets someone act as another user | Strong sign-in, short-lived tokens, mutual TLS between services |
| Tampering (T) | Integrity | A price is changed in a request that the server trusts | Validation on the server, TLS, signed messages, checksums |
| Repudiation (R) | Non-repudiation | A seller denies changing a price, and nothing shows who did | Audit logs of actor, action, time and source that the actor cannot edit |
| Information disclosure (I) | Confidentiality | An API returns other users’ records, or a backup bucket is public | Object-level authorisation, encryption, collecting less |
| Denial of service (D) | Availability | Expensive search requests exhaust the database | Rate limits, quotas, caching, timeouts, load shedding |
| Elevation of privilege (E) | authorisation: Deciding what an authenticated caller may do with one particular resource. In a system of services, the final check belongs in the service that owns the data. More in “Authentication and authorisation for services” | A normal user calls an admin endpoint that only checks for a signed-in caller | A permission check on every function and object, least privilege |
The definitions follow Microsoft’s Threat Modeling Tool documentation, and the pairing of each threat with a property follows Microsoft’s STRIDE article, below. The OWASP cheat sheet pairs repudiation with accounting, which is the same idea seen from the logs: Evidence that a given actor took an action, such as an audit log the actor cannot edit, so that the actor cannot credibly deny it later. is what an audit log the actor cannot edit provides.
Read the table as six prompts, not six boxes to sort attacks into. One attack can touch two letters: a stolen admin token is spoofing first, because someone acts as the admin, and elevation of privilege next, because a normal account gains the admin’s rights. The letters only have to make sure that the review asks each question once.
Checkpoint
Check: The six STRIDE threats and their properties
STRIDE per element asks each part only the letters that fit
Not every letter applies to every element. A data flow cannot sign in as anyone, so spoofing is a question for the two ends of a flow, not for the flow itself. Microsoft’s STRIDE article gives a chart for this, often called STRIDE per element:
| Kind of element | Letters asked | Why these |
|---|---|---|
| External entity | S, R | It can pretend to be someone else and deny what it did; its code is not yours to change |
| Process | S, T, R, I, D, E | It runs code, so it can be impersonated, changed, denied, read, stalled or given too many rights |
| Data store | T, I, D | Data at rest can be changed, read or made unavailable |
| Data flow | T, I, D | Data in motion can be changed, read or blocked |
Applied to the URL shortener, the chart turns 20 elements into 68 questions, and the 12 elements on the boundary account for 44 of them. Each tab below holds the same program in one of 14 languages, and all of them printed the same lines: the totals for the whole design and for its boundary, then each boundary element with its letters.
Python 3.14 · main.py
# STRIDE per element: the letters asked of each kind.
APPLIES = {"external": "SR", "process": "STRIDE", "store": "TID", "flow": "TID"}
# The URL shortener's diagram: name, kind, on a trust boundary?
DESIGN = [
("visitor", "external", True),
("link creator", "external", True),
("admin", "external", True),
("target site", "external", True),
("API service", "process", True),
("redirect service", "process", True),
("preview fetcher", "process", True),
("admin console", "process", True),
("links database", "store", False),
("link cache", "store", False),
("click log", "store", False),
("creator -> API", "flow", True),
("visitor -> redirect", "flow", True),
("fetcher -> target site", "flow", True),
("admin -> console", "flow", True),
("console -> API", "flow", False),
("API -> database", "flow", False),
("redirect -> cache", "flow", False),
("redirect -> database", "flow", False),
("redirect -> click log", "flow", False),
]
def questions(design: list[tuple[str, str, bool]]) -> int:
return sum(len(APPLIES[kind]) for _, kind, _ in design)
edge = [element for element in DESIGN if element[2]]
print(f"STRIDE per element: {len(DESIGN)} elements, {questions(DESIGN)} questions")
print(f"On the trust boundaries: {len(edge)} elements, {questions(edge)} questions")
for name, kind, _ in edge:
print(f" {name}: {' '.join(APPLIES[kind])}") In Python: APPLIES maps a kind to a string of letters, so len(APPLIES[kind]) is the number of questions an element gets, and ' '.join spaces a string's letters out.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
// STRIDE per element: the letters asked of each kind.
const APPLIES = { external: "SR", process: "STRIDE", store: "TID", flow: "TID" };
// The URL shortener's diagram: name, kind, on a trust boundary?
const DESIGN = [
["visitor", "external", true],
["link creator", "external", true],
["admin", "external", true],
["target site", "external", true],
["API service", "process", true],
["redirect service", "process", true],
["preview fetcher", "process", true],
["admin console", "process", true],
["links database", "store", false],
["link cache", "store", false],
["click log", "store", false],
["creator -> API", "flow", true],
["visitor -> redirect", "flow", true],
["fetcher -> target site", "flow", true],
["admin -> console", "flow", true],
["console -> API", "flow", false],
["API -> database", "flow", false],
["redirect -> cache", "flow", false],
["redirect -> database", "flow", false],
["redirect -> click log", "flow", false],
];
const questions = (design) => design.reduce((sum, [, kind]) => sum + APPLIES[kind].length, 0);
const edge = DESIGN.filter(([, , onBoundary]) => onBoundary);
console.log(`STRIDE per element: ${DESIGN.length} elements, ${questions(DESIGN)} questions`);
console.log(`On the trust boundaries: ${edge.length} elements, ${questions(edge)} questions`);
for (const [name, kind] of edge) {
console.log(` ${name}: ${[...APPLIES[kind]].join(" ")}`);
} In JavaScript: reduce adds up each element's letters, and [...APPLIES[kind]] splits a string into its characters before join puts the spaces in.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.List;
import java.util.Map;
public class Main {
record Element(String name, String kind, boolean onBoundary) {}
// STRIDE per element: the letters asked of each kind.
static final Map<String, String> APPLIES =
Map.of("external", "SR", "process", "STRIDE", "store", "TID", "flow", "TID");
// The URL shortener's diagram: name, kind, on a trust boundary?
static final List<Element> DESIGN =
List.of(
new Element("visitor", "external", true),
new Element("link creator", "external", true),
new Element("admin", "external", true),
new Element("target site", "external", true),
new Element("API service", "process", true),
new Element("redirect service", "process", true),
new Element("preview fetcher", "process", true),
new Element("admin console", "process", true),
new Element("links database", "store", false),
new Element("link cache", "store", false),
new Element("click log", "store", false),
new Element("creator -> API", "flow", true),
new Element("visitor -> redirect", "flow", true),
new Element("fetcher -> target site", "flow", true),
new Element("admin -> console", "flow", true),
new Element("console -> API", "flow", false),
new Element("API -> database", "flow", false),
new Element("redirect -> cache", "flow", false),
new Element("redirect -> database", "flow", false),
new Element("redirect -> click log", "flow", false));
static int questions(List<Element> design) {
return design.stream().mapToInt(e -> APPLIES.get(e.kind()).length()).sum();
}
public static void main(String[] args) {
List<Element> edge = DESIGN.stream().filter(Element::onBoundary).toList();
System.out.println(
"STRIDE per element: " + DESIGN.size() + " elements, " + questions(DESIGN) + " questions");
System.out.println(
"On the trust boundaries: " + edge.size() + " elements, " + questions(edge) + " questions");
for (Element e : edge) {
StringBuilder letters = new StringBuilder();
for (char letter : APPLIES.get(e.kind()).toCharArray()) {
letters.append(" ").append(letter);
}
System.out.println(" " + e.name() + ":" + letters);
}
}
} In Java: Map.of holds the chart, mapToInt and sum add up each element's letters, and a StringBuilder appends a space before each letter.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <cstddef>
#include <iterator>
#include <print>
#include <span>
#include <string_view>
#include <vector>
enum Kind { EXTERNAL, PROCESS, STORE, FLOW };
// STRIDE per element: the letters asked of each kind.
constexpr std::string_view APPLIES[] = {"SR", "STRIDE", "TID", "TID"};
struct Element {
std::string_view name;
Kind kind;
bool on_boundary;
};
// The URL shortener's diagram: name, kind, on a trust boundary?
constexpr Element DESIGN[] = {
{"visitor", EXTERNAL, true},
{"link creator", EXTERNAL, true},
{"admin", EXTERNAL, true},
{"target site", EXTERNAL, true},
{"API service", PROCESS, true},
{"redirect service", PROCESS, true},
{"preview fetcher", PROCESS, true},
{"admin console", PROCESS, true},
{"links database", STORE, false},
{"link cache", STORE, false},
{"click log", STORE, false},
{"creator -> API", FLOW, true},
{"visitor -> redirect", FLOW, true},
{"fetcher -> target site", FLOW, true},
{"admin -> console", FLOW, true},
{"console -> API", FLOW, false},
{"API -> database", FLOW, false},
{"redirect -> cache", FLOW, false},
{"redirect -> database", FLOW, false},
{"redirect -> click log", FLOW, false},
};
std::size_t questions(std::span<const Element> design) {
std::size_t asked = 0;
for (const auto &element : design) {
asked += APPLIES[element.kind].size();
}
return asked;
}
int main() {
std::vector<Element> edge;
for (const auto &element : DESIGN) {
if (element.on_boundary) {
edge.push_back(element);
}
}
std::println("STRIDE per element: {} elements, {} questions",
std::size(DESIGN), questions(DESIGN));
std::println("On the trust boundaries: {} elements, {} questions",
edge.size(), questions(edge));
for (const auto &element : edge) {
std::print(" {}:", element.name);
for (const char letter : APPLIES[element.kind]) {
std::print(" {}", letter);
}
std::println("");
}
} In C++: An unscoped enum numbers the four kinds, so APPLIES[element.kind] reads the letters directly, and questions takes a std::span, which fits both the array and the vector.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// The URL shortener's diagram: name, kind, on a trust boundary?
(string Name, string Kind, bool OnBoundary)[] design =
[
("visitor", "external", true),
("link creator", "external", true),
("admin", "external", true),
("target site", "external", true),
("API service", "process", true),
("redirect service", "process", true),
("preview fetcher", "process", true),
("admin console", "process", true),
("links database", "store", false),
("link cache", "store", false),
("click log", "store", false),
("creator -> API", "flow", true),
("visitor -> redirect", "flow", true),
("fetcher -> target site", "flow", true),
("admin -> console", "flow", true),
("console -> API", "flow", false),
("API -> database", "flow", false),
("redirect -> cache", "flow", false),
("redirect -> database", "flow", false),
("redirect -> click log", "flow", false),
];
var edge = design.Where(e => e.OnBoundary).ToArray();
Console.WriteLine($"STRIDE per element: {design.Length} elements, {Questions(design)} questions");
Console.WriteLine($"On the trust boundaries: {edge.Length} elements, {Questions(edge)} questions");
foreach (var (name, kind, _) in edge)
{
Console.WriteLine($" {name}: {string.Join(" ", LettersOf(kind).ToCharArray())}");
}
// STRIDE per element: the letters asked of each kind.
static string LettersOf(string kind) => kind switch
{
"external" => "SR",
"process" => "STRIDE",
"store" or "flow" => "TID",
_ => throw new ArgumentException($"no STRIDE letters for {kind}"),
};
static int Questions((string Name, string Kind, bool OnBoundary)[] design) =>
design.Sum(e => LettersOf(e.Kind).Length); In C#: A switch expression maps each kind to its letters. The functions are static, so they read nothing from the top-level statements and take the design as a parameter.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#define COUNT(array) (sizeof(array) / sizeof((array)[0]))
enum kind { EXTERNAL, PROCESS, STORE, FLOW };
// STRIDE per element: the letters asked of each kind.
static const char *const APPLIES[] = {"SR", "STRIDE", "TID", "TID"};
// The URL shortener's diagram: name, kind, on a trust boundary?
static const struct element {
const char *name;
enum kind kind;
bool on_boundary;
} DESIGN[] = {
{"visitor", EXTERNAL, true},
{"link creator", EXTERNAL, true},
{"admin", EXTERNAL, true},
{"target site", EXTERNAL, true},
{"API service", PROCESS, true},
{"redirect service", PROCESS, true},
{"preview fetcher", PROCESS, true},
{"admin console", PROCESS, true},
{"links database", STORE, false},
{"link cache", STORE, false},
{"click log", STORE, false},
{"creator -> API", FLOW, true},
{"visitor -> redirect", FLOW, true},
{"fetcher -> target site", FLOW, true},
{"admin -> console", FLOW, true},
{"console -> API", FLOW, false},
{"API -> database", FLOW, false},
{"redirect -> cache", FLOW, false},
{"redirect -> database", FLOW, false},
{"redirect -> click log", FLOW, false},
};
static size_t questions(bool boundary_only, size_t *elements) {
size_t asked = 0;
*elements = 0;
for (size_t i = 0; i < COUNT(DESIGN); i++) {
if (!boundary_only || DESIGN[i].on_boundary) {
*elements += 1;
asked += strlen(APPLIES[DESIGN[i].kind]);
}
}
return asked;
}
int main(void) {
size_t all = 0, edge = 0;
const size_t asked = questions(false, &all), first = questions(true, &edge);
printf("STRIDE per element: %zu elements, %zu questions\n", all, asked);
printf("On the trust boundaries: %zu elements, %zu questions\n", edge, first);
for (size_t i = 0; i < COUNT(DESIGN); i++) {
if (DESIGN[i].on_boundary) {
printf(" %s:", DESIGN[i].name);
for (const char *letter = APPLIES[DESIGN[i].kind]; *letter; letter++) {
printf(" %c", *letter);
}
printf("\n");
}
}
} In C: An enum numbers the four kinds, so APPLIES is indexed by kind, and questions hands back the number of elements through a pointer, the C way of returning two values.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
// STRIDE per element: the letters asked of each kind.
const APPLIES = ['external' => 'SR', 'process' => 'STRIDE', 'store' => 'TID', 'flow' => 'TID'];
// The URL shortener's diagram: name, kind, on a trust boundary?
const DESIGN = [
['visitor', 'external', true],
['link creator', 'external', true],
['admin', 'external', true],
['target site', 'external', true],
['API service', 'process', true],
['redirect service', 'process', true],
['preview fetcher', 'process', true],
['admin console', 'process', true],
['links database', 'store', false],
['link cache', 'store', false],
['click log', 'store', false],
['creator -> API', 'flow', true],
['visitor -> redirect', 'flow', true],
['fetcher -> target site', 'flow', true],
['admin -> console', 'flow', true],
['console -> API', 'flow', false],
['API -> database', 'flow', false],
['redirect -> cache', 'flow', false],
['redirect -> database', 'flow', false],
['redirect -> click log', 'flow', false],
];
/**
* @param list<array{string, string, bool}> $design
*/
function questions(array $design): int
{
return array_sum(array_map(fn(array $element): int => strlen(APPLIES[$element[1]]), $design));
}
$edge = array_values(array_filter(DESIGN, fn(array $element): bool => $element[2]));
printf("STRIDE per element: %d elements, %d questions\n", count(DESIGN), questions(DESIGN));
printf("On the trust boundaries: %d elements, %d questions\n", count($edge), questions($edge));
foreach ($edge as [$name, $kind]) {
echo " {$name}: " . implode(' ', str_split(APPLIES[$kind])) . "\n";
} In PHP: APPLIES is a constant array, array_map and array_sum add up the letters of every element, and str_split cuts a string into its letters.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
// The URL shortener's diagram: name, kind, on a trust boundary?
const DESIGN: [(&str, &str, bool); 20] = [
("visitor", "external", true),
("link creator", "external", true),
("admin", "external", true),
("target site", "external", true),
("API service", "process", true),
("redirect service", "process", true),
("preview fetcher", "process", true),
("admin console", "process", true),
("links database", "store", false),
("link cache", "store", false),
("click log", "store", false),
("creator -> API", "flow", true),
("visitor -> redirect", "flow", true),
("fetcher -> target site", "flow", true),
("admin -> console", "flow", true),
("console -> API", "flow", false),
("API -> database", "flow", false),
("redirect -> cache", "flow", false),
("redirect -> database", "flow", false),
("redirect -> click log", "flow", false),
];
// STRIDE per element: the letters asked of each kind.
fn letters_of(kind: &str) -> &'static str {
match kind {
"external" => "SR",
"process" => "STRIDE",
"store" | "flow" => "TID",
_ => "",
}
}
fn questions(design: &[(&str, &str, bool)]) -> usize {
design
.iter()
.map(|&(_, kind, _)| letters_of(kind).len())
.sum()
}
fn main() {
let edge: Vec<_> = DESIGN.into_iter().filter(|element| element.2).collect();
let (all, asked) = (DESIGN.len(), questions(&DESIGN));
println!("STRIDE per element: {all} elements, {asked} questions");
let (on_edge, first) = (edge.len(), questions(&edge));
println!("On the trust boundaries: {on_edge} elements, {first} questions");
for (name, kind, _) in edge {
let letters: Vec<String> = letters_of(kind).chars().map(String::from).collect();
println!(" {name}: {}", letters.join(" "));
}
} In Rust: letters_of is a match on the kind, and DESIGN.into_iter() hands out the tuples by value, so filter and the for loop take them apart directly.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
type Kind = "external" | "process" | "store" | "flow";
type Element = [name: string, kind: Kind, onBoundary: boolean];
// STRIDE per element: the letters asked of each kind.
const APPLIES: Record<Kind, string> = {
external: "SR",
process: "STRIDE",
store: "TID",
flow: "TID",
};
// The URL shortener's diagram: name, kind, on a trust boundary?
const DESIGN: Element[] = [
["visitor", "external", true],
["link creator", "external", true],
["admin", "external", true],
["target site", "external", true],
["API service", "process", true],
["redirect service", "process", true],
["preview fetcher", "process", true],
["admin console", "process", true],
["links database", "store", false],
["link cache", "store", false],
["click log", "store", false],
["creator -> API", "flow", true],
["visitor -> redirect", "flow", true],
["fetcher -> target site", "flow", true],
["admin -> console", "flow", true],
["console -> API", "flow", false],
["API -> database", "flow", false],
["redirect -> cache", "flow", false],
["redirect -> database", "flow", false],
["redirect -> click log", "flow", false],
];
const questions = (design: Element[]): number =>
design.reduce((sum, [, kind]) => sum + APPLIES[kind].length, 0);
const edge = DESIGN.filter(([, , onBoundary]) => onBoundary);
console.log(`STRIDE per element: ${DESIGN.length} elements, ${questions(DESIGN)} questions`);
console.log(`On the trust boundaries: ${edge.length} elements, ${questions(edge)} questions`);
for (const [name, kind] of edge) {
console.log(` ${name}: ${[...APPLIES[kind]].join(" ")}`);
} In TypeScript: Kind is a union of the four kinds, so Record<Kind, string> must give letters for every one, and the labelled tuple type names an element's three fields.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"strings"
)
// STRIDE per element: the letters asked of each kind.
var applies = map[string]string{
"external": "SR", "process": "STRIDE", "store": "TID", "flow": "TID",
}
type element struct {
name string
kind string
onBoundary bool
}
// The URL shortener's diagram: name, kind, on a trust boundary?
var design = []element{
{"visitor", "external", true},
{"link creator", "external", true},
{"admin", "external", true},
{"target site", "external", true},
{"API service", "process", true},
{"redirect service", "process", true},
{"preview fetcher", "process", true},
{"admin console", "process", true},
{"links database", "store", false},
{"link cache", "store", false},
{"click log", "store", false},
{"creator -> API", "flow", true},
{"visitor -> redirect", "flow", true},
{"fetcher -> target site", "flow", true},
{"admin -> console", "flow", true},
{"console -> API", "flow", false},
{"API -> database", "flow", false},
{"redirect -> cache", "flow", false},
{"redirect -> database", "flow", false},
{"redirect -> click log", "flow", false},
}
// questions counts one question for each element and each STRIDE letter of its kind.
func questions(elements []element) int {
asked := 0
for _, e := range elements {
asked += len(applies[e.kind])
}
return asked
}
func main() {
var edge []element
for _, e := range design {
if e.onBoundary {
edge = append(edge, e)
}
}
fmt.Printf("STRIDE per element: %d elements, %d questions\n", len(design), questions(design))
fmt.Printf("On the trust boundaries: %d elements, %d questions\n", len(edge), questions(edge))
for _, e := range edge {
fmt.Printf(" %s: %s\n", e.name, strings.Join(strings.Split(applies[e.kind], ""), " "))
}
} In Go: The applies map is only read by key and never looped over, because Go randomises the order of a map; strings.Split with an empty separator cuts a string into its letters.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
typealias Element = (name: String, kind: String, onBoundary: Bool)
// STRIDE per element: the letters asked of each kind.
let applies: KeyValuePairs = [
"external": "SR", "process": "STRIDE", "store": "TID", "flow": "TID",
]
// The URL shortener's diagram: name, kind, on a trust boundary?
let design: [Element] = [
("visitor", "external", true),
("link creator", "external", true),
("admin", "external", true),
("target site", "external", true),
("API service", "process", true),
("redirect service", "process", true),
("preview fetcher", "process", true),
("admin console", "process", true),
("links database", "store", false),
("link cache", "store", false),
("click log", "store", false),
("creator -> API", "flow", true),
("visitor -> redirect", "flow", true),
("fetcher -> target site", "flow", true),
("admin -> console", "flow", true),
("console -> API", "flow", false),
("API -> database", "flow", false),
("redirect -> cache", "flow", false),
("redirect -> database", "flow", false),
("redirect -> click log", "flow", false),
]
func letters(of kind: String) -> String {
applies.first { $0.key == kind }?.value ?? ""
}
func questions(_ elements: [Element]) -> Int {
elements.reduce(0) { $0 + letters(of: $1.kind).count }
}
let edge = design.filter { $0.onBoundary }
print("STRIDE per element: \(design.count) elements, \(questions(design)) questions")
print("On the trust boundaries: \(edge.count) elements, \(questions(edge)) questions")
for element in edge {
let spaced = letters(of: element.kind).map { String($0) }.joined(separator: " ")
print(" \(element.name): \(spaced)")
} In Swift: KeyValuePairs keeps the chart as pairs in the order written, first(where:) looks a kind up, and reduce adds up each element's letters.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# STRIDE per element: the letters asked of each kind.
APPLIES = { 'external' => 'SR', 'process' => 'STRIDE', 'store' => 'TID', 'flow' => 'TID' }.freeze
# The URL shortener's diagram: name, kind, on a trust boundary?
DESIGN = [
['visitor', 'external', true],
['link creator', 'external', true],
['admin', 'external', true],
['target site', 'external', true],
['API service', 'process', true],
['redirect service', 'process', true],
['preview fetcher', 'process', true],
['admin console', 'process', true],
['links database', 'store', false],
['link cache', 'store', false],
['click log', 'store', false],
['creator -> API', 'flow', true],
['visitor -> redirect', 'flow', true],
['fetcher -> target site', 'flow', true],
['admin -> console', 'flow', true],
['console -> API', 'flow', false],
['API -> database', 'flow', false],
['redirect -> cache', 'flow', false],
['redirect -> database', 'flow', false],
['redirect -> click log', 'flow', false]
].freeze
def questions(design)
design.sum { |_, kind, _| APPLIES[kind].size }
end
edge = DESIGN.select { |_, _, on_boundary| on_boundary }
puts "STRIDE per element: #{DESIGN.size} elements, #{questions(DESIGN)} questions"
puts "On the trust boundaries: #{edge.size} elements, #{questions(edge)} questions"
edge.each { |name, kind, _| puts " #{name}: #{APPLIES[kind].chars.join(' ')}" } In Ruby: sum takes a block that unpacks each element, and chars.join(' ') spaces a string's letters out.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
// STRIDE per element: the letters asked of each kind.
val applies = mapOf("external" to "SR", "process" to "STRIDE", "store" to "TID", "flow" to "TID")
data class Element(
val name: String,
val kind: String,
val onBoundary: Boolean,
)
// The URL shortener's diagram: name, kind, on a trust boundary?
val design =
listOf(
Element("visitor", "external", true),
Element("link creator", "external", true),
Element("admin", "external", true),
Element("target site", "external", true),
Element("API service", "process", true),
Element("redirect service", "process", true),
Element("preview fetcher", "process", true),
Element("admin console", "process", true),
Element("links database", "store", false),
Element("link cache", "store", false),
Element("click log", "store", false),
Element("creator -> API", "flow", true),
Element("visitor -> redirect", "flow", true),
Element("fetcher -> target site", "flow", true),
Element("admin -> console", "flow", true),
Element("console -> API", "flow", false),
Element("API -> database", "flow", false),
Element("redirect -> cache", "flow", false),
Element("redirect -> database", "flow", false),
Element("redirect -> click log", "flow", false),
)
fun questions(elements: List<Element>): Int = elements.sumOf { applies.getValue(it.kind).length }
fun main() {
val edge = design.filter { it.onBoundary }
println("STRIDE per element: ${design.size} elements, ${questions(design)} questions")
println("On the trust boundaries: ${edge.size} elements, ${questions(edge)} questions")
for (element in edge) {
println(" ${element.name}: ${applies.getValue(element.kind).toList().joinToString(" ")}")
}
} In Kotlin: sumOf adds up each element's letters, getValue throws for a kind the chart does not have, and toList turns a String into its Chars.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
// STRIDE per element: the letters asked of each kind.
const applies = {
'external': 'SR',
'process': 'STRIDE',
'store': 'TID',
'flow': 'TID',
};
// The URL shortener's diagram: name, kind, on a trust boundary?
const design = [
('visitor', 'external', true),
('link creator', 'external', true),
('admin', 'external', true),
('target site', 'external', true),
('API service', 'process', true),
('redirect service', 'process', true),
('preview fetcher', 'process', true),
('admin console', 'process', true),
('links database', 'store', false),
('link cache', 'store', false),
('click log', 'store', false),
('creator -> API', 'flow', true),
('visitor -> redirect', 'flow', true),
('fetcher -> target site', 'flow', true),
('admin -> console', 'flow', true),
('console -> API', 'flow', false),
('API -> database', 'flow', false),
('redirect -> cache', 'flow', false),
('redirect -> database', 'flow', false),
('redirect -> click log', 'flow', false),
];
int questions(List<(String, String, bool)> elements) => elements.fold(
0,
(asked, element) => asked + (applies[element.$2] ?? '').length,
);
void main() {
final edge = design.where((element) => element.$3).toList();
print(
'STRIDE per element: ${design.length} elements, ${questions(design)} questions',
);
print(
'On the trust boundaries: ${edge.length} elements, ${questions(edge)} questions',
);
for (final (name, kind, _) in edge) {
print(' $name: ${(applies[kind] ?? '').split('').join(' ')}');
}
} In Dart: Records hold each element's three fields, fold adds up the letters, and the pattern (name, kind, _) unpacks a record in the for loop.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
STRIDE per element: 20 elements, 68 questions On the trust boundaries: 12 elements, 44 questions visitor: S R link creator: S R admin: S R target site: S R API service: S T R I D E redirect service: S T R I D E preview fetcher: S T R I D E admin console: S T R I D E creator -> API: T I D visitor -> redirect: T I D fetcher -> target site: T I D admin -> console: T I D
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 13 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 151 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 21 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 723 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 193 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 17 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Most of the 44 questions take a sentence each. “Can someone tamper with POST /links in transit?” is answered by
“TLS only, plain HTTP is refused”, and the answer goes into the threat list as a closed question. The questions that
take longer are the ones that change the design.
Asking all six letters of everything
Asking every element all six letters turns the shortener’s 20 elements into 120 questions instead of 68. The 52 extra ones, such as whether a data flow can be spoofed, have no useful answer, and they bury the 44 questions on the boundary that do.
Checkpoint
Check: STRIDE per element
Walking the boundary first finds the threats that change the design
Walk the four crossing flows first, then the processes they reach, then the stores behind them. Step through the walk on the shortener: each step names the flow or part, the letter that found something, and the fix.
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Four flows cross. Each arrow between the columns crosses the trust boundary, and everything an attacker sends arrives on one of them. The walk asks each crossing flow its letters first, then the part it reaches.
- Editing a link. Tampering at the API service: the handler that edits a link checks that someone is signed in, never that this customer owns this link. The fix is an ownership check in the handler.
- Who changed this link?. Repudiation at the same service: a customer says they never pointed a link at a phishing page, and nothing records who did. The fix is an audit log entry for every change to a link.
- Following a short link. Information disclosure and denial of service at the redirect: sequential codes let anyone list unlisted links, and random codes miss the cache and reach the database. The fix is random codes of 8 or more characters, short-lived not-found answers and a limit per client.
- The preview fetcher. Elevation of privilege: a user chooses the address that the fetcher downloads, so a preview of an internal address is fetched from inside the network, with the fetcher's position. The fix is an isolated network, no private or link-local addresses and no redirects followed.
- Where a link leads. Spoofing by a target site: a phishing page poses as a site that visitors trust, and the short link hides its real address behind the shortener's trusted domain. The fix is scanning targets against threat lists, a warning before flagged links and a way to report abuse.
- The admin's sign-in. Spoofing and repudiation at the admin console: staff share one password for a console on the internet, so anyone who learns it can act as staff, and nobody can tell which of them did what. The fix is single sign-on with a second factor for each person, from staff devices only.
- The store behind. Information disclosure in the click log: full IP addresses and browser strings are kept forever. The fix is truncating addresses when they are written and deleting them after 90 days.
- Eight findings. The walk found eight threats and every STRIDE letter at least once. Two of them, the missing ownership check and the fetcher's reach into the network, change the design itself rather than a setting.
The walk’s findings, written as rows of the threat list:
| Element | STRIDE | Threat | Mitigation |
|---|---|---|---|
API service, PATCH /links/<code> |
T | A caller edits another customer’s link, because the handler only checks that someone is signed in | Check in the handler that the caller owns this link |
| Redirect service | I | Sequential codes let anyone list unlisted links, such as shared documents | Random codes of 8 or more characters; limit not-found answers per client |
| Redirect service | D | Requests for random codes miss the cache and reach the database | Cache not-found answers briefly; keep a filter of existing codes; limit each client |
| Preview fetcher | E | A preview of an internal address makes the fetcher call the cloud credential service from inside the network | Fetch from an isolated network; refuse private and link-local addresses; follow no redirects |
| Target site | S | A phishing page poses as a site that visitors trust, and the short link hides its real address | Scan targets against threat lists; warn before flagged links; take abuse reports |
| Admin | S, R | Staff share one password for a console on the internet, so anyone who learns it acts as staff and nobody can tell who did what | Single sign-on with a second factor for each person; reachable only from staff devices |
| Click log | I | Full IP addresses and browser strings are kept forever | Truncate addresses when writing them; delete after 90 days |
| API service | R | A customer denies changing a link that now points to a phishing page | An audit log entry for every change to a link |
Two findings show why the boundary deserves the first pass. The tampering threat on PATCH is what OWASP ranks first
among API risks,
An API that loads an object by an id from the client without checking that this caller may act on that object, so any signed-in user can read or change another user's data.
(API1:2023): the
endpoint authenticated the caller and never asked whether this caller owned this link. The preview fetcher is
A weakness in which a server fetches an address that a user supplied, so that a request for an internal address is made from inside the network, with the server's position and permissions.
(API7:2023), which OWASP says
is becoming more common because webhooks, fetching files from URLs and URL previews make servers fetch addresses
that users choose. A fetcher that runs inside your network fetches from inside your network, so an attacker who
names an internal address borrows that position.
The boundary is wider than the network edge. The current OWASP Top 10 for web applications keeps broken access control first and lists software supply chain failures as a category of its own, so the build pipeline and the dependencies a service pulls in belong in the diagram as external entities too.
Key idea
The flows that cross a trust boundary carry whatever an attacker chooses to send, so they are walked first, and the findings that change the design, here the missing ownership check and the fetcher’s reach, turn up there.
Checkpoint
Check: Walking the trust boundary first
Rank threats by likelihood times impact, cheaper fix first on a tie
A list of forty threats needs an order. The usual first cut, which the OWASP cheat sheet describes, multiplies a likelihood by an impact, here each on a scale from 1 to 5. The cheat sheet also notes that this product ignores the work it takes to fix a problem, so use the effort to break ties: when two threats score the same, fix the cheaper one first, because it removes the same risk sooner. The program ranks the shortener’s ten threats that way and prints each one’s response beside it. Eight are the walk’s findings, and two came from the rest of the pass: stack traces on error pages, and a sign-up request that sets its own role.
Python 3.14 · main.py
from typing import NamedTuple
class Threat(NamedTuple):
id: str
letter: str
likelihood: int
impact: int
effort: int
response: str
what: str
# The URL shortener's threats, from the walk of its boundary.
# Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
THREATS = [
Threat("T01", "T", 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
Threat("T02", "R", 3, 3, 1, "mitigate", "customer denies changing a link"),
Threat("T03", "I", 4, 4, 1, "mitigate", "sequential codes expose links"),
Threat("T04", "D", 4, 4, 2, "mitigate", "random codes miss the cache"),
Threat("T05", "E", 3, 5, 2, "mitigate", "preview fetches internal addresses"),
Threat("T06", "S", 5, 3, 3, "mitigate", "phishing hides behind our domain"),
Threat("T07", "S", 2, 5, 1, "undecided", "admin console behind one password"),
Threat("T08", "I", 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
Threat("T09", "I", 2, 2, 1, "accept", "error pages show stack traces"),
Threat("T10", "E", 2, 5, 1, "undecided", "sign-up request sets its own role"),
]
ranked = sorted(THREATS, key=lambda t: (-t.likelihood * t.impact, t.effort, t.id))
print("Ranked by likelihood x impact, the smaller fix first on a tie")
for rank, t in enumerate(ranked, start=1):
score = f"{t.likelihood * t.impact:>2} = {t.likelihood}x{t.impact}"
print(f"{rank:>2}. {t.id} {t.letter} {score} {t.response:<9} {t.what}") In Python: A NamedTuple gives each threat named fields, sorted with the key (-score, effort, id) ranks the score downwards and the rest upwards, and enumerate(ranked, start=1) numbers the ranks.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const threat = (id, letter, likelihood, impact, effort, response, what) => ({
id,
letter,
likelihood,
impact,
effort,
response,
what,
score: likelihood * impact,
});
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
const THREATS = [
threat("T01", "T", 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
threat("T02", "R", 3, 3, 1, "mitigate", "customer denies changing a link"),
threat("T03", "I", 4, 4, 1, "mitigate", "sequential codes expose links"),
threat("T04", "D", 4, 4, 2, "mitigate", "random codes miss the cache"),
threat("T05", "E", 3, 5, 2, "mitigate", "preview fetches internal addresses"),
threat("T06", "S", 5, 3, 3, "mitigate", "phishing hides behind our domain"),
threat("T07", "S", 2, 5, 1, "undecided", "admin console behind one password"),
threat("T08", "I", 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
threat("T09", "I", 2, 2, 1, "accept", "error pages show stack traces"),
threat("T10", "E", 2, 5, 1, "undecided", "sign-up request sets its own role"),
];
const ranked = [...THREATS].sort(
(a, b) => b.score - a.score || a.effort - b.effort || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
console.log("Ranked by likelihood x impact, the smaller fix first on a tie");
ranked.forEach((t, i) => {
const head = `${String(i + 1).padStart(2)}. ${t.id} ${t.letter} ${String(t.score).padStart(2)}`;
console.log(`${head} = ${t.likelihood}x${t.impact} ${t.response.padEnd(9)} ${t.what}`);
}); In JavaScript: sort has been stable since ES2019, but the comparator still ends with the id, so the order never depends on the input, and the ids are compared with < rather than localeCompare, which follows a locale.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public class Main {
record Threat(
String id,
char letter,
int likelihood,
int impact,
int effort,
String response,
String what) {
int score() {
return likelihood * impact;
}
}
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
static final List<Threat> THREATS =
List.of(
new Threat("T01", 'T', 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
new Threat("T02", 'R', 3, 3, 1, "mitigate", "customer denies changing a link"),
new Threat("T03", 'I', 4, 4, 1, "mitigate", "sequential codes expose links"),
new Threat("T04", 'D', 4, 4, 2, "mitigate", "random codes miss the cache"),
new Threat("T05", 'E', 3, 5, 2, "mitigate", "preview fetches internal addresses"),
new Threat("T06", 'S', 5, 3, 3, "mitigate", "phishing hides behind our domain"),
new Threat("T07", 'S', 2, 5, 1, "undecided", "admin console behind one password"),
new Threat("T08", 'I', 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
new Threat("T09", 'I', 2, 2, 1, "accept", "error pages show stack traces"),
new Threat("T10", 'E', 2, 5, 1, "undecided", "sign-up request sets its own role"));
public static void main(String[] args) {
Locale.setDefault(Locale.ROOT);
List<Threat> ranked = new ArrayList<>(THREATS);
ranked.sort(
Comparator.comparingInt((Threat t) -> -t.score())
.thenComparingInt(Threat::effort)
.thenComparing(Threat::id));
System.out.println("Ranked by likelihood x impact, the smaller fix first on a tie");
for (int i = 0; i < ranked.size(); i++) {
Threat t = ranked.get(i);
System.out.printf(
"%2d. %s %c %2d = %dx%d %-9s %s%n",
i + 1, t.id(), t.letter(), t.score(), t.likelihood(), t.impact(), t.response(), t.what());
}
}
} In Java: Comparator.comparingInt, thenComparingInt and thenComparing chain the three parts of the ranking, and score() is a method of the record, computed from its fields.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <cstddef>
#include <print>
#include <string_view>
#include <tuple>
#include <vector>
struct Threat {
std::string_view id;
char letter;
int likelihood, impact, effort;
std::string_view response, what;
int score() const { return likelihood * impact; }
};
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
const std::vector<Threat> THREATS = {
{"T01", 'T', 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"},
{"T02", 'R', 3, 3, 1, "mitigate", "customer denies changing a link"},
{"T03", 'I', 4, 4, 1, "mitigate", "sequential codes expose links"},
{"T04", 'D', 4, 4, 2, "mitigate", "random codes miss the cache"},
{"T05", 'E', 3, 5, 2, "mitigate", "preview fetches internal addresses"},
{"T06", 'S', 5, 3, 3, "mitigate", "phishing hides behind our domain"},
{"T07", 'S', 2, 5, 1, "undecided", "admin console behind one password"},
{"T08", 'I', 3, 3, 1, "mitigate", "click log keeps full IP addresses"},
{"T09", 'I', 2, 2, 1, "accept", "error pages show stack traces"},
{"T10", 'E', 2, 5, 1, "undecided", "sign-up request sets its own role"},
};
int main() {
auto ranked = THREATS;
std::ranges::sort(ranked, {}, [](const Threat &t) {
return std::tuple(-t.score(), t.effort, t.id);
});
std::println("Ranked by likelihood x impact, the smaller fix first on a tie");
for (std::size_t i = 0; i < ranked.size(); i++) {
const auto &t = ranked[i];
std::println("{:>2}. {} {} {:>2} = {}x{} {:<9} {}", i + 1, t.id, t.letter,
t.score(), t.likelihood, t.impact, t.response, t.what);
}
} In C++: std::ranges::sort with a projection that returns std::tuple(-score, effort, id) ranks by all three at once, because tuples compare part by part.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
Threat[] threats =
[
new("T01", 'T', 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
new("T02", 'R', 3, 3, 1, "mitigate", "customer denies changing a link"),
new("T03", 'I', 4, 4, 1, "mitigate", "sequential codes expose links"),
new("T04", 'D', 4, 4, 2, "mitigate", "random codes miss the cache"),
new("T05", 'E', 3, 5, 2, "mitigate", "preview fetches internal addresses"),
new("T06", 'S', 5, 3, 3, "mitigate", "phishing hides behind our domain"),
new("T07", 'S', 2, 5, 1, "undecided", "admin console behind one password"),
new("T08", 'I', 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
new("T09", 'I', 2, 2, 1, "accept", "error pages show stack traces"),
new("T10", 'E', 2, 5, 1, "undecided", "sign-up request sets its own role"),
];
var ranked = threats
.OrderByDescending(t => t.Score)
.ThenBy(t => t.Effort)
.ThenBy(t => t.Id, StringComparer.Ordinal)
.ToList();
Console.WriteLine("Ranked by likelihood x impact, the smaller fix first on a tie");
foreach (var (t, rank) in ranked.Select((t, i) => (t, i + 1)))
{
var score = $"{t.Score,2} = {t.Likelihood}x{t.Impact}";
Console.WriteLine($"{rank,2}. {t.Id} {t.Letter} {score} {t.Response,-9} {t.What}");
}
record Threat(
string Id, char Letter, int Likelihood, int Impact, int Effort, string Response, string What)
{
public int Score => Likelihood * Impact;
} In C#: OrderByDescending, ThenBy and ThenBy with StringComparer.Ordinal rank the threats; the ordinal comparer compares the ids by character code, not by culture.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
const char *id;
char letter;
int likelihood, impact, effort;
const char *response, *what;
} Threat;
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
static const Threat THREATS[] = {
{"T01", 'T', 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"},
{"T02", 'R', 3, 3, 1, "mitigate", "customer denies changing a link"},
{"T03", 'I', 4, 4, 1, "mitigate", "sequential codes expose links"},
{"T04", 'D', 4, 4, 2, "mitigate", "random codes miss the cache"},
{"T05", 'E', 3, 5, 2, "mitigate", "preview fetches internal addresses"},
{"T06", 'S', 5, 3, 3, "mitigate", "phishing hides behind our domain"},
{"T07", 'S', 2, 5, 1, "undecided", "admin console behind one password"},
{"T08", 'I', 3, 3, 1, "mitigate", "click log keeps full IP addresses"},
{"T09", 'I', 2, 2, 1, "accept", "error pages show stack traces"},
{"T10", 'E', 2, 5, 1, "undecided", "sign-up request sets its own role"},
};
enum { COUNT = sizeof THREATS / sizeof THREATS[0] };
static int score(const Threat *t) { return t->likelihood * t->impact; }
static int by_rank(const void *a, const void *b) {
const Threat *x = a, *y = b;
if (score(x) != score(y)) {
return score(y) - score(x);
}
if (x->effort != y->effort) {
return x->effort - y->effort;
}
return strcmp(x->id, y->id);
}
int main(void) {
Threat ranked[COUNT];
memcpy(ranked, THREATS, sizeof THREATS);
qsort(ranked, COUNT, sizeof ranked[0], by_rank);
printf("Ranked by likelihood x impact, the smaller fix first on a tie\n");
for (int i = 0; i < COUNT; i++) {
const Threat *t = &ranked[i];
printf("%2d. %s %c %2d = %dx%d %-9s %s\n", i + 1, t->id, t->letter,
score(t), t->likelihood, t->impact, t->response, t->what);
}
} In C: qsort is not stable, so by_rank breaks every tie down to the id, and the order is the same with every C library.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
final readonly class Threat
{
public function __construct(
public string $id,
public string $letter,
public int $likelihood,
public int $impact,
public int $effort,
public string $response,
public string $what,
) {}
public function score(): int
{
return $this->likelihood * $this->impact;
}
}
function byRank(Threat $a, Threat $b): int
{
return [$b->score(), $a->effort, $a->id] <=> [$a->score(), $b->effort, $b->id];
}
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
$threats = [
new Threat('T01', 'T', 4, 5, 1, 'mitigate', 'PATCH lets a caller edit any link'),
new Threat('T02', 'R', 3, 3, 1, 'mitigate', 'customer denies changing a link'),
new Threat('T03', 'I', 4, 4, 1, 'mitigate', 'sequential codes expose links'),
new Threat('T04', 'D', 4, 4, 2, 'mitigate', 'random codes miss the cache'),
new Threat('T05', 'E', 3, 5, 2, 'mitigate', 'preview fetches internal addresses'),
new Threat('T06', 'S', 5, 3, 3, 'mitigate', 'phishing hides behind our domain'),
new Threat('T07', 'S', 2, 5, 1, 'undecided', 'admin console behind one password'),
new Threat('T08', 'I', 3, 3, 1, 'mitigate', 'click log keeps full IP addresses'),
new Threat('T09', 'I', 2, 2, 1, 'accept', 'error pages show stack traces'),
new Threat('T10', 'E', 2, 5, 1, 'undecided', 'sign-up request sets its own role'),
];
usort($threats, byRank(...));
echo "Ranked by likelihood x impact, the smaller fix first on a tie\n";
foreach ($threats as $i => $t) {
$score = sprintf('%2d = %dx%d', $t->score(), $t->likelihood, $t->impact);
printf("%2d. %s %s %s %-9s %s\n", $i + 1, $t->id, $t->letter, $score, $t->response, $t->what);
} In PHP: byRank compares two arrays with <=>, which compares them element by element, and usort receives it as a first-class callable, byRank(...).
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::cmp::Reverse;
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
// One threat a line: id|letter|likelihood|impact|effort|response|threat.
const THREATS: [&str; 10] = [
"T01|T|4|5|1|mitigate|PATCH lets a caller edit any link",
"T02|R|3|3|1|mitigate|customer denies changing a link",
"T03|I|4|4|1|mitigate|sequential codes expose links",
"T04|D|4|4|2|mitigate|random codes miss the cache",
"T05|E|3|5|2|mitigate|preview fetches internal addresses",
"T06|S|5|3|3|mitigate|phishing hides behind our domain",
"T07|S|2|5|1|undecided|admin console behind one password",
"T08|I|3|3|1|mitigate|click log keeps full IP addresses",
"T09|I|2|2|1|accept|error pages show stack traces",
"T10|E|2|5|1|undecided|sign-up request sets its own role",
];
struct Threat<'a> {
id: &'a str,
letter: char,
likelihood: u32,
impact: u32,
effort: u32,
response: &'a str,
what: &'a str,
}
impl Threat<'_> {
fn score(&self) -> u32 {
self.likelihood * self.impact
}
}
fn parse(line: &str) -> Option<Threat<'_>> {
let mut f = line.split('|');
Some(Threat {
id: f.next()?,
letter: f.next()?.chars().next()?,
likelihood: f.next()?.parse().ok()?,
impact: f.next()?.parse().ok()?,
effort: f.next()?.parse().ok()?,
response: f.next()?,
what: f.next()?,
})
}
fn main() {
let Some(mut ranked) = THREATS.into_iter().map(parse).collect::<Option<Vec<_>>>() else {
eprintln!("a line of THREATS is not a threat");
return;
};
ranked.sort_by_key(|t| (Reverse(t.score()), t.effort, t.id));
println!("Ranked by likelihood x impact, the smaller fix first on a tie");
for (i, t) in ranked.iter().enumerate() {
let score = format!("{:>2} = {}x{}", t.score(), t.likelihood, t.impact);
let what = format!("{:<9} {}", t.response, t.what);
println!("{:>2}. {} {} {score} {what}", i + 1, t.id, t.letter);
}
} In Rust: rustfmt would give each threat's seven fields a line of their own, so the threats are text lines that parse turns into structs; the ? operator returns None at the first bad field, the let-else stops if any line fails, and Reverse(score) sorts downwards.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
interface Threat {
id: string;
letter: string;
likelihood: number;
impact: number;
effort: number;
response: string;
what: string;
score: number;
}
const threat = (
id: string,
letter: string,
likelihood: number,
impact: number,
effort: number,
response: string,
what: string,
): Threat => ({
id,
letter,
likelihood,
impact,
effort,
response,
what,
score: likelihood * impact,
});
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
const THREATS: Threat[] = [
threat("T01", "T", 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
threat("T02", "R", 3, 3, 1, "mitigate", "customer denies changing a link"),
threat("T03", "I", 4, 4, 1, "mitigate", "sequential codes expose links"),
threat("T04", "D", 4, 4, 2, "mitigate", "random codes miss the cache"),
threat("T05", "E", 3, 5, 2, "mitigate", "preview fetches internal addresses"),
threat("T06", "S", 5, 3, 3, "mitigate", "phishing hides behind our domain"),
threat("T07", "S", 2, 5, 1, "undecided", "admin console behind one password"),
threat("T08", "I", 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
threat("T09", "I", 2, 2, 1, "accept", "error pages show stack traces"),
threat("T10", "E", 2, 5, 1, "undecided", "sign-up request sets its own role"),
];
const ranked = [...THREATS].sort(
(a, b) => b.score - a.score || a.effort - b.effort || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
console.log("Ranked by likelihood x impact, the smaller fix first on a tie");
ranked.forEach((t, i) => {
const head = `${String(i + 1).padStart(2)}. ${t.id} ${t.letter} ${String(t.score).padStart(2)}`;
console.log(`${head} = ${t.likelihood}x${t.impact} ${t.response.padEnd(9)} ${t.what}`);
}); In TypeScript: The Threat interface types the factory threat(...), so a misspelt field or a missing argument does not compile; the ranking is the JavaScript program's.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"cmp"
"fmt"
"slices"
"strings"
)
type threat struct {
id, letter string
likelihood, impact, effort int
response, what string
}
func (t threat) score() int { return t.likelihood * t.impact }
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
var threats = []threat{
{"T01", "T", 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"},
{"T02", "R", 3, 3, 1, "mitigate", "customer denies changing a link"},
{"T03", "I", 4, 4, 1, "mitigate", "sequential codes expose links"},
{"T04", "D", 4, 4, 2, "mitigate", "random codes miss the cache"},
{"T05", "E", 3, 5, 2, "mitigate", "preview fetches internal addresses"},
{"T06", "S", 5, 3, 3, "mitigate", "phishing hides behind our domain"},
{"T07", "S", 2, 5, 1, "undecided", "admin console behind one password"},
{"T08", "I", 3, 3, 1, "mitigate", "click log keeps full IP addresses"},
{"T09", "I", 2, 2, 1, "accept", "error pages show stack traces"},
{"T10", "E", 2, 5, 1, "undecided", "sign-up request sets its own role"},
}
func main() {
ranked := slices.Clone(threats)
slices.SortFunc(ranked, func(a, b threat) int {
return cmp.Or(
cmp.Compare(b.score(), a.score()),
cmp.Compare(a.effort, b.effort),
strings.Compare(a.id, b.id),
)
})
fmt.Println("Ranked by likelihood x impact, the smaller fix first on a tie")
for i, t := range ranked {
fmt.Printf("%2d. %s %s %2d = %dx%d %-9s %s\n",
i+1, t.id, t.letter, t.score(), t.likelihood, t.impact, t.response, t.what)
}
} In Go: cmp.Or returns the first comparison that is not zero, which chains the score, the effort and the id inside slices.SortFunc.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
typealias Threat = (
id: String, letter: Character, likelihood: Int, impact: Int, effort: Int, response: String,
what: String
)
func score(_ t: Threat) -> Int { t.likelihood * t.impact }
func padded(_ text: String, to width: Int, left: Bool = false) -> String {
let fill = String(repeating: " ", count: max(0, width - text.count))
return left ? fill + text : text + fill
}
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
let threats: [Threat] = [
("T01", "T", 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
("T02", "R", 3, 3, 1, "mitigate", "customer denies changing a link"),
("T03", "I", 4, 4, 1, "mitigate", "sequential codes expose links"),
("T04", "D", 4, 4, 2, "mitigate", "random codes miss the cache"),
("T05", "E", 3, 5, 2, "mitigate", "preview fetches internal addresses"),
("T06", "S", 5, 3, 3, "mitigate", "phishing hides behind our domain"),
("T07", "S", 2, 5, 1, "undecided", "admin console behind one password"),
("T08", "I", 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
("T09", "I", 2, 2, 1, "accept", "error pages show stack traces"),
("T10", "E", 2, 5, 1, "undecided", "sign-up request sets its own role"),
]
let ranked = threats.sorted { (score($1), $0.effort, $0.id) < (score($0), $1.effort, $1.id) }
print("Ranked by likelihood x impact, the smaller fix first on a tie")
for (i, t) in ranked.enumerated() {
let head = "\(padded(String(i + 1), to: 2, left: true)). \(t.id) \(t.letter)"
let points = "\(padded(String(score(t)), to: 2, left: true)) = \(t.likelihood)x\(t.impact)"
print("\(head) \(points) \(padded(t.response, to: 9)) \(t.what)")
} In Swift: Tuples compare element by element with <, so (score($1), $0.effort, $0.id) < (score($0), $1.effort, $1.id) ranks the score downwards and the rest upwards.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
Threat = Data.define(:id, :letter, :likelihood, :impact, :effort, :response, :what) do
def score = likelihood * impact
end
# The URL shortener's threats, from the walk of its boundary.
# Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
THREATS = [
Threat.new('T01', 'T', 4, 5, 1, 'mitigate', 'PATCH lets a caller edit any link'),
Threat.new('T02', 'R', 3, 3, 1, 'mitigate', 'customer denies changing a link'),
Threat.new('T03', 'I', 4, 4, 1, 'mitigate', 'sequential codes expose links'),
Threat.new('T04', 'D', 4, 4, 2, 'mitigate', 'random codes miss the cache'),
Threat.new('T05', 'E', 3, 5, 2, 'mitigate', 'preview fetches internal addresses'),
Threat.new('T06', 'S', 5, 3, 3, 'mitigate', 'phishing hides behind our domain'),
Threat.new('T07', 'S', 2, 5, 1, 'undecided', 'admin console behind one password'),
Threat.new('T08', 'I', 3, 3, 1, 'mitigate', 'click log keeps full IP addresses'),
Threat.new('T09', 'I', 2, 2, 1, 'accept', 'error pages show stack traces'),
Threat.new('T10', 'E', 2, 5, 1, 'undecided', 'sign-up request sets its own role')
].freeze
ranked = THREATS.sort_by { |t| [-t.score, t.effort, t.id] }
puts 'Ranked by likelihood x impact, the smaller fix first on a tie'
ranked.each.with_index(1) do |t, rank|
score = "#{t.score.to_s.rjust(2)} = #{t.likelihood}x#{t.impact}"
puts "#{rank.to_s.rjust(2)}. #{t.id} #{t.letter} #{score} #{t.response.ljust(9)} #{t.what}"
end In Ruby: Data.define makes an immutable value class, and sort_by with the array [-score, effort, id] compares the arrays element by element.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
data class Threat(
val id: String,
val letter: Char,
val likelihood: Int,
val impact: Int,
val effort: Int,
val response: String,
val what: String,
) {
val score: Int
get() = likelihood * impact
}
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
val threats =
listOf(
Threat("T01", 'T', 4, 5, 1, "mitigate", "PATCH lets a caller edit any link"),
Threat("T02", 'R', 3, 3, 1, "mitigate", "customer denies changing a link"),
Threat("T03", 'I', 4, 4, 1, "mitigate", "sequential codes expose links"),
Threat("T04", 'D', 4, 4, 2, "mitigate", "random codes miss the cache"),
Threat("T05", 'E', 3, 5, 2, "mitigate", "preview fetches internal addresses"),
Threat("T06", 'S', 5, 3, 3, "mitigate", "phishing hides behind our domain"),
Threat("T07", 'S', 2, 5, 1, "undecided", "admin console behind one password"),
Threat("T08", 'I', 3, 3, 1, "mitigate", "click log keeps full IP addresses"),
Threat("T09", 'I', 2, 2, 1, "accept", "error pages show stack traces"),
Threat("T10", 'E', 2, 5, 1, "undecided", "sign-up request sets its own role"),
)
fun main() {
val byRank = compareByDescending<Threat> { it.score }.thenBy { it.effort }.thenBy { it.id }
println("Ranked by likelihood x impact, the smaller fix first on a tie")
threats.sortedWith(byRank).forEachIndexed { i, t ->
val rank = (i + 1).toString().padStart(2)
val score = "${t.score.toString().padStart(2)} = ${t.likelihood}x${t.impact}"
println("$rank. ${t.id} ${t.letter} $score ${t.response.padEnd(9)} ${t.what}")
}
} In Kotlin: compareByDescending, thenBy and thenBy build one Comparator, and score is a property whose getter multiplies the likelihood by the impact.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
class Threat {
const Threat(
this.id,
this.letter,
this.likelihood,
this.impact,
this.effort,
this.response,
this.what,
);
final String id, letter, response, what;
final int likelihood, impact, effort;
int get score => likelihood * impact;
}
// The URL shortener's threats, from the walk of its boundary.
// Likelihood and impact 1 to 5; effort 1 (small) to 3 (large).
const threats = [
Threat('T01', 'T', 4, 5, 1, 'mitigate', 'PATCH lets a caller edit any link'),
Threat('T02', 'R', 3, 3, 1, 'mitigate', 'customer denies changing a link'),
Threat('T03', 'I', 4, 4, 1, 'mitigate', 'sequential codes expose links'),
Threat('T04', 'D', 4, 4, 2, 'mitigate', 'random codes miss the cache'),
Threat('T05', 'E', 3, 5, 2, 'mitigate', 'preview fetches internal addresses'),
Threat('T06', 'S', 5, 3, 3, 'mitigate', 'phishing hides behind our domain'),
Threat('T07', 'S', 2, 5, 1, 'undecided', 'admin console behind one password'),
Threat('T08', 'I', 3, 3, 1, 'mitigate', 'click log keeps full IP addresses'),
Threat('T09', 'I', 2, 2, 1, 'accept', 'error pages show stack traces'),
Threat('T10', 'E', 2, 5, 1, 'undecided', 'sign-up request sets its own role'),
];
void main() {
final ranked = [...threats]
..sort(
(a, b) => b.score != a.score
? b.score.compareTo(a.score)
: a.effort != b.effort
? a.effort.compareTo(b.effort)
: a.id.compareTo(b.id),
);
print('Ranked by likelihood x impact, the smaller fix first on a tie');
for (final (i, t) in ranked.indexed) {
final score = '${'${t.score}'.padLeft(2)} = ${t.likelihood}x${t.impact}';
print(
'${'${i + 1}'.padLeft(2)}. ${t.id} ${t.letter} $score ${t.response.padRight(9)} ${t.what}',
);
}
} In Dart: List.sort is not guaranteed to be stable, so the comparator checks the score, the effort and the id in turn; the threats are const objects of a const constructor.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
Ranked by likelihood x impact, the smaller fix first on a tie 1. T01 T 20 = 4x5 mitigate PATCH lets a caller edit any link 2. T03 I 16 = 4x4 mitigate sequential codes expose links 3. T04 D 16 = 4x4 mitigate random codes miss the cache 4. T05 E 15 = 3x5 mitigate preview fetches internal addresses 5. T06 S 15 = 5x3 mitigate phishing hides behind our domain 6. T07 S 10 = 2x5 undecided admin console behind one password 7. T10 E 10 = 2x5 undecided sign-up request sets its own role 8. T02 R 9 = 3x3 mitigate customer denies changing a link 9. T08 I 9 = 3x3 mitigate click log keeps full IP addresses 10. T09 I 4 = 2x2 accept error pages show stack traces
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 67 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 155 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 5 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 10 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 26 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 724 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 185 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 15 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Read the top five from the scores. Two threats tie at 16, and the sequential codes rank above the cache misses because their fix, random codes and a limit on unknown ones, is the smaller job (effort 1 against 2). Two more tie at 15, and the isolated fetcher goes ahead of the phishing defences for the same reason (effort 2 against 3). Further down, the pairs at 10 and at 9 tie on the effort as well, so the id decides between them.
Change a score
Press Edit on the Python tab, raise the likelihood of T07, the admin console, from 2 to 4, and run it. Its score becomes 20 and it ranks second, behind T01 on the id because both fixes are small, and the phishing defences drop to sixth. Its response still says undecided: a higher score makes a missing response more urgent, it does not supply one.
The scores are judgements, so keep the scale coarse. Arguing whether a threat is a 3 or a 4 matters less than agreeing on the top five and on who fixes each. Where a score has to be defended in detail, the OWASP Risk Rating Methodology splits likelihood and impact into named factors.
OWASP Risk Rating Calculator Score one threat on the OWASP risk rating factors when its likelihood and impact need a detailed argument.Checkpoint
Check: Ranking threats by score and effort
Every threat gets a response and an owner
A score says what to look at first; a response says what happens to the threat. The cheat sheet lists four, after Adam Shostack. Compare what each one does to a threat of the shortener, and what the list keeps beside it:
| Response | What it does | What the list records |
|---|---|---|
| Mitigate | Lowers the likelihood or the impact with a check, a limit or encryption, as the owner check on PATCH does for T01 |
The requirement, and the test that checks it |
| Eliminate | Removes the feature or component that causes the threat: a click log that kept no IP addresses at all would have no T08, where truncating them only mitigates it | What was removed |
| Transfer | Makes another party responsible, such as a payment provider. The consequences move, the threat does not | Who owns what is left |
| Accept | Lives with the threat, as the list does with T09, the stack traces on error pages, which score 4 | A named owner, a reason and a date to look at it again |
Now read the response column of the program’s output. Two threats score 10 and still say undecided: the shared admin password and the sign-up request that can set its own role. They rank sixth and seventh, just outside the top five, and both are the kind that end up in incident reports, because nobody owned them. Every other row has a response: seven mitigations and the one accepted risk.
Stopping at the top five
Fixing the five highest scores and closing the list leaves T07 and T10 with no owner, though each scores 10. A list is finished when every threat has a response, mitigate, eliminate, transfer or accept, and a name beside it, not when the top five are fixed.
Checkpoint
Check: A response and an owner for every threat
A security section turns the model into requirements
In a design document, and in a design round, the threat model becomes a short section with five parts:
- Assets: what an attacker wants. For the shortener: customers’ accounts, the link targets, the click data and the reputation of the domain.
- Trust boundaries: the diagram, with the flows that cross each boundary numbered.
- Top threats: the five to ten highest, each with its letter, score, response and owner.
- Mitigations as requirements: “the redirect service answers at most 20 unknown codes a minute per client” can be tested; “be careful with enumeration” cannot.
- Residual risk: what was accepted, by whom, and when it will be looked at again.
The fourth question, did we do a good enough job, is answered by opening the model again whenever the design changes in a way that matters: a new trust boundary (a partner integration, a mobile app, an incoming webhook), a new kind of data (payments, health records, children’s data) or a new way in for staff. Walk the shortener through five versions of its design and see which of them open it:
- Launch
The first model
Twenty elements, four flows across the trust boundary and ten threats, each ranked and given a response before the first release.
- Partner webhook
A new trust boundary open it again
Partners register an address that the shortener calls on every click. The outgoing call is a new flow across a boundary, and its address is chosen by someone outside, the same question the preview fetcher raised.
- Bigger cache
No change to the model
The link cache moves to a larger server. No trust boundary, kind of data or way in for staff has changed, so the model needs no new pass.
- Paid plans
A new kind of data open it again
Customers pay by card through a payment provider. Protecting the card data becomes the provider's job, a transfer, while the payment notifications it sends back are a new incoming flow to model.
- Support tool
A new way in for staff open it again
Support staff get a tool that can read every customer's links. A new way in for staff raises elevation of privilege and repudiation, who may look and whether every look is recorded.
The timeline has 5 events, from Launch to Support tool.
Only the bigger cache leaves the model alone: it adds no boundary, no kind of data and no way in. The other three changes each open one part of the model again, which takes an afternoon instead of the week that a full redo would.
Threat Model Builder Draw a data flow diagram with trust boundaries and record STRIDE threats for each element.Checkpoint
Check: The security section of a design
Practise: find the questions a threat list missed
The exercise gives you the chart of STRIDE per element and a threat list with gaps in it. Write the function that finds the questions nobody answered on the trust boundary; the sample tests check the order, the elements inside the boundary and a design with a mistake in it.
Exercise · Easy · Python
Find the STRIDE questions a threat list has not answered
A threat list is only as good as the questions it answers. Write gaps(design, recorded) in gaps.py, which finds the STRIDE questions that a design's threat list has not answered yet.
designis a list of elements, each a tuple(name, kind, on_boundary).kindis one of"external","process","store"and"flow";on_boundaryisTruewhen the element sits on a trust boundary.APPLIESgives the STRIDE letters to ask about for each kind: external entities"SR", processes"STRIDE", data stores and data flows"TID".recordedis a set of(name, letter)pairs that the team has already answered, with a threat or with a note that the letter does not apply.
Return a list of the (name, letter) pairs that still need an answer, for the elements on a trust boundary only: in the order of design, and for each element in the order of the letters in APPLIES. An element whose kind is not in APPLIES is a mistake in the design: raise ValueError.
For the small design in the sample tests the answer is three questions: repudiation and elevation of privilege on the redirect service, and denial of service on the flow from the visitor.
Starter code · gaps.py
APPLIES = {"external": "SR", "process": "STRIDE", "store": "TID", "flow": "TID"}
def gaps(design, recorded):
"""Return the (name, letter) pairs of boundary elements that `recorded` has not answered, in design order."""
# Replace this line with your code.
return [] The sample tests · test_gaps.py
from gaps import APPLIES, gaps
DESIGN = [
("visitor", "external", True),
("redirect service", "process", True),
("links database", "store", False),
("visitor -> redirect", "flow", True),
("redirect -> links database", "flow", False),
]
RECORDED = {
("visitor", "S"),
("visitor", "R"),
("redirect service", "S"),
("redirect service", "T"),
("redirect service", "I"),
("redirect service", "D"),
("visitor -> redirect", "T"),
("visitor -> redirect", "I"),
}
def test_finds_the_three_open_questions():
"""finds the three questions the sample threat list has not answered"""
assert gaps(DESIGN, RECORDED) == [("redirect service", "R"), ("redirect service", "E"), ("visitor -> redirect", "D")]
def test_every_letter_when_nothing_is_recorded():
"""asks all six letters, in STRIDE order, for a process on a boundary"""
assert gaps([("api", "process", True)], set()) == [("api", letter) for letter in "STRIDE"]
def test_skips_elements_inside_the_boundary():
"""asks nothing about elements that are not on a trust boundary"""
inside = [("links database", "store", False), ("redirect -> links database", "flow", False)]
assert gaps(inside, set()) == []
def test_answers_for_another_element_do_not_count():
"""matches a recorded answer to its own element only"""
assert gaps([("visitor", "external", True)], {("admin", "S"), ("admin", "R")}) == [("visitor", "S"), ("visitor", "R")]
def test_keeps_the_order_of_the_design():
"""lists the open questions in the order of the design"""
design = [("visitor -> redirect", "flow", True), ("visitor", "external", True)]
assert gaps(design, set()) == [("visitor -> redirect", "T"), ("visitor -> redirect", "I"), ("visitor -> redirect", "D"), ("visitor", "S"), ("visitor", "R")]
def test_an_unknown_kind_is_a_mistake():
"""raises ValueError for a kind of element that STRIDE per element does not know"""
try:
gaps([("queue", "pipe", False)], set())
except ValueError:
return
assert False, "expected ValueError for the kind 'pipe'"
def test_the_chart_is_unchanged():
"""keeps the STRIDE-per-element chart of the lesson"""
assert APPLIES == {"external": "SR", "process": "STRIDE", "store": "TID", "flow": "TID"} A hint
Loop over design once. Skip an element when it is not on a trust boundary, then loop over APPLIES[kind], which is a string, so each character is one letter in STRIDE order. Keep a pair only when it is not in recorded. Check the kind before anything else, so that a mistake in the design raises ValueError even for an element inside a boundary.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through questions on each step of the method, from the four questions to the security section. The session starts where your answers have been weakest:
Practice that adapts to you
Practise this lesson
20 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): What are the six STRIDE threats, and which property does each one break?
Spoofing is pretending to be someone or something else, and it breaks authentication. Tampering is changing data or code without permission, and it breaks integrity. Repudiation is denying an action when nothing can prove it happened, and it breaks non-repudiation, the evidence that audit logs provide. Information disclosure is exposing data to someone who should not see it, and it breaks confidentiality. Denial of service is making the system unavailable or unusable for real users, and it breaks availability. Elevation of privilege is gaining rights you were not given, such as a normal user reaching admin functions, and it breaks authorisation. A good answer adds that the letters are prompts for a design review, asked element by element, not a classification to argue about: one attack, such as a stolen admin token, can be spoofing that leads to elevation of privilege.
Warm-up (fresher to mid-level): What is a trust boundary, and why does a threat model start there?
A trust boundary is a line in a design where data or a request passes between parts that trust each other differently: from the internet into your service, from your service to a partner’s API, from a normal user’s session into an admin tool, or from one tenant’s data to another’s. On one side you control the code and the configuration; on the other side you have to assume the worst. A threat model starts there because that is where an attacker can send you whatever they like: forged identities, changed fields, huge inputs or addresses inside your network. Most serious findings sit on a boundary that someone drew too loosely, such as an admin console treated as internal while it is reachable from the internet.
Two of this module’s design questions build on this lesson, and each one starts with a single decision. Make it here; every choice explains what it gets right or what it leaves out:
Checkpoint
Your first decision
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your service receives payment notifications from a provider through a webhook. Threat model the receiver with STRIDE and name the mitigations you would insist on before it goes live.
-
Mid to staff
A chat product shows a preview of every pasted link by fetching the page on the server. Which threats rank highest, and how do you design the fetcher?
Key takeaways
- A threat model answers four questions: what are we working on, what can go wrong, what will we do about it, and did we do a good enough job.
- Draw the design as a data flow diagram and mark where trust changes, by who can reach a part rather than who is meant to use it.
- STRIDE asks six questions; ask each element the ones that fit its kind: three for flows and stores, two for external entities, six for processes.
- Walk the flows that cross a boundary first: the shortener’s ownership check and its fetcher were found there.
- Rank by likelihood times impact, break ties by the cost of the fix, and give every threat a response and an owner.
- Write mitigations as testable requirements, and open the model again when a new boundary, kind of data or way in for staff appears.
References
- Threats (Microsoft Threat Modeling Tool documentation) (Microsoft)
- Uncover Security Design Flaws Using The STRIDE Approach (MSDN Magazine archive) (Microsoft)
- Threat Modeling Cheat Sheet (OWASP)
- Threat Modeling Manifesto (Threat Modeling Manifesto working group)
- OWASP API Security Top 10 (2023 edition) (OWASP)
- API1:2023 Broken Object Level Authorization (OWASP)
- API7:2023 Server Side Request Forgery (OWASP)
- OWASP Top 10 (2025 edition) (OWASP)
- OWASP Risk Rating Methodology (OWASP)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress