Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · stage by stage The four questions, asked of the URL shortener Step through one pass of the method on the lesson's URL shortener, then the change to the design that starts the next pass.
The four questions, asked of the URL shortener, step 5 of 5. Showing What are we working on?, What can go wrong?, What do we do about it?, Did we do a good job?. In focus: What are we working on?.the design changesWhat are we working on?a data flow diagramWhat can go wrong?STRIDE per elementWhat do we do about it?rank, respond, assignDid we do a good job?check, then open it againa new webhook

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. One question Which of the four questions does a data flow diagram of the design answer?

    Choose one answer.

    Show the answer: One question

    Answer: What are we working on?

    The manifesto's first question is answered with a picture of the system, here the URL shortener's data flow diagram with its 20 elements and its trust boundary. The other questions are all asked of that picture.

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.
A URL shortener as a data flow diagram: four flows cross the trust boundary between the internet and the service.Internet: untrustedOur service: trustedLink creatorVisitorAdminTarget sitesAPI servicecreate, edit, delete linksRedirect serviceGET /<code>Preview fetcherfetches target pagesAdmin consoleLinks databaseLink cacheClick logdisable a linkwritereadread on a missappend a click1. POST /links2. GET /<code>3. target page,fetched for a preview4. sign in, manage links

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

  1. Question 1 of 2 Which flows of the URL shortener's diagram cross its trust boundary? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The link creator's POST /links to the API service
    • The visitor's GET /<code> to the redirect service
    • The target page that the preview fetcher downloads
    • The admin's sign-in to the admin console

    Four flows cross the boundary: creating links, following links, fetching previews and signing in to the admin console. They are where an attacker touches the system directly, so the walk starts with them.

  2. Question 2 of 2 Only staff use the shortener's admin console, but in the first version it is reachable from the internet. Where does the diagram put the console's sign-in flow?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Across the trust boundary, because anyone on the internet can reach the console

    The boundary is drawn by reachability. In the first version the console answers anyone on the internet, so its flow is one of the four that cross, and the walk found spoofing and repudiation there: one password shared by all staff lets anyone who learns it act as staff, and nobody can tell which person did what.

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

  1. Question 1 of 2 Someone copies a user's session cookie from a shared computer and uses it from another city to act as that user. Which STRIDE threat is this?
    Show the answer: Question 1 of 2

    Answer: Spoofing

    The attacker presents someone else's credential and is treated as that person, which is a failure of authentication: spoofing. Nothing in the request was changed and no extra rights were gained beyond the victim's own, so tampering and elevation of privilege do not describe it.

  2. Question 2 of 2 A normal user calls DELETE /admin/users/42 and it works, because the endpoint only checks that the caller is signed in. Which STRIDE threat is this?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Elevation of privilege

    The user is who they claim to be, so this is not spoofing; they gained rights they were never given, which breaks authorisation. Every function needs its own permission check, not only a signed-in check at the door.

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.

One program · 14 languages STRIDE per element on the URL shortener's diagram

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

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

  1. Question 1 of 2 Which STRIDE threats does the STRIDE-per-element chart ask you to check for a data flow? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Tampering
    • Denial of service
    • Information disclosure

    A flow can be changed, read or blocked in transit, so tampering, information disclosure and denial of service apply. Spoofing is a question for the elements at each end of the flow, and elevation of privilege is a question for the processes that run code.

  2. Question 2 of 2 The four flows that cross the shortener's trust boundary are asked their STRIDE-per-element letters. How many questions is that in all?

    Type a number.

    A hint

    Count the letters the chart gives a data flow, then multiply by the number of flows.

    Show the answer: Question 2 of 2

    Answer: 12 questions

    The chart asks a data flow three letters, tampering, information disclosure and denial of service, so four crossing flows give 4 × 3 = 12 of the 44 questions on the boundary. The program lists them as T I D beside each flow.

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.

Interactive · step by step Walking the URL shortener's trust boundary The left column is outside the service and the right column is the service. Step through the four flows that cross between them, the parts they reach and the store behind them.
Walking the URL shortener's trust boundary, step 9 of 9. Showing Link creator, API service (T, R: owner check, audit log), Visitor, Redirect service (I, D: random codes, limits), Target site (S: scan and warn), Preview fetcher (E: isolated fetcher), Admin (S, R: SSO, staff devices), Admin console, Click log (I: truncate, 90 days).create and edit linksfollow a short linkfetch the pagesign inLink creatorAPI serviceT, R: owner check, audit logVisitorRedirect serviceI, D: random codes, limitsTarget siteS: scan and warnPreview fetcherE: isolated fetcherAdminS, R: SSO, staff devicesAdmin consoleClick logI: truncate, 90 days

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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

  1. Question 1 of 2 The handler of PATCH /links/<code> checks that the caller is signed in, and nothing else. What does the walk of the boundary record?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Tampering: a caller can edit another customer's link; the fix is an ownership check in the handler

    The endpoint authenticated the caller and never asked whether this caller owned this link, so any signed-in customer can change any link. This is broken object-level authorisation, and the fix is a check of ownership in the handler for every link it loads by id.

  2. Question 2 of 2 The preview fetcher runs inside the service's network. Why does the walk treat its flow to target sites as a boundary flow that ranks high?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A user chooses the address it fetches, so a request for an internal address is made from inside the network

    The address comes from outside the boundary and the request goes out from inside it. A preview of an internal address, such as the cloud credential service, is fetched with the fetcher's position and permissions, so the walk records elevation of privilege and the fix isolates the fetcher's network.

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.

One program · 14 languages Ranking the URL shortener's threats

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

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

  1. Question 1 of 2 T05, the preview fetcher, and T06, the phishing pages, both score 15. T05's fix has effort 2 and T06's fix effort 3. Which ranks higher, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: T05, because the smaller fix removes the same risk sooner

    The ranking is the score from the top, then the effort from the bottom, then the id. With equal scores the smaller fix goes first, so the program ranks T05 fourth and T06 fifth, as it ranks T03 ahead of T04 at 16.

  2. Question 2 of 2 A threat has likelihood 3 and impact 5 on the lesson's scales. What is its score?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 15 points

    The score is likelihood times impact: 3 × 5 = 15, the score of the preview fetcher's threat T05 in the program.

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

  1. Question 1 of 2 The team removes the link-preview feature because its server-side fetcher could be pointed at internal addresses. Which response to the threat is that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Eliminate

    Removing the feature that causes a threat eliminates it. Mitigating would keep the feature and reduce the risk, for example by fetching from an isolated network and refusing private addresses.

  2. Question 2 of 2 The shortener takes card payments through a payment provider instead of handling card numbers itself. Which response is that, and what does the threat list still record?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Transfer, and who owns what is left, such as the notifications the provider sends back

    Handing the card data to a provider is a transfer. It moves the consequences, not every threat: the payment notifications that come back are a new incoming flow, so the list records what remains and who owns it.

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:

  1. Assets: what an attacker wants. For the shortener: customers’ accounts, the link targets, the click data and the reputation of the domain.
  2. Trust boundaries: the diagram, with the flows that cross each boundary numbered.
  3. Top threats: the five to ten highest, each with its letter, score, response and owner.
  4. Mitigations as requirements: “the redirect service answers at most 20 unknown codes a minute per client” can be tested; “be careful with enumeration” cannot.
  5. 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:

Timeline · 5 events in order The shortener's threat model as its design changes Walk through five versions of the design. A change that matters opens the model again, for the part that changed.
  1. 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.

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

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

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

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

  1. Question 1 of 2 Which mitigation is written as a requirement a test can check?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The redirect service answers at most 20 unknown codes a minute per client

    A mitigation written as a requirement names a behaviour with a number, so a test can fail when it is missing. "At most 20 unknown codes a minute per client" can be tested; "be careful with enumeration" cannot.

  2. Question 2 of 2 Which changes to the shortener open its threat model again? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Partners get a webhook that the shortener calls on every click
    • Customers start paying by card through a provider
    • Support staff get a tool that can read every customer's links

    The model opens again for a new trust boundary, a new kind of data or a new way in for staff, and only for the part that changed. A bigger cache changes none of the three.

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.

  • design is a list of elements, each a tuple (name, kind, on_boundary). kind is one of "external", "process", "store" and "flow"; on_boundary is True when the element sits on a trust boundary.
  • APPLIES gives the STRIDE letters to ask about for each kind: external entities "SR", processes "STRIDE", data stores and data flows "TID".
  • recorded is 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.

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.

  1. Question 1 of 20 Checking that every threat has a response, and opening the model again when the design changes, answers which of the four questions?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Did we do a good enough job?

    The fourth question asks whether the model did its job: every threat has a response and an owner, and the model is opened again when a new boundary, kind of data or way in for staff appears.

  2. Question 2 of 20 A team skips the diagram and lists threats from memory in a meeting. What does the method say they lost?

    Choose one answer.

    Show the answer: Question 2 of 20

    Answer: The answer to what they are working on, so nobody can tell which flows cross a boundary or whether every element was asked

    The first question is answered with a picture, and every later step is asked of it. A list from memory cannot show which flows cross a boundary, and nothing checks that every element was asked its letters.

  3. Question 3 of 20 In a data flow diagram, which kind of element is a message queue?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: A data store

    Data stores are anything that keeps data: databases, caches, queues, logs and buckets. The flows into and out of the queue are data flows, drawn as arrows.

  4. Question 4 of 20 How much detail should a data flow diagram for a design review have?

    Choose one answer.

    Show the answer: Question 4 of 20

    Answer: One box per service, with every data store shown

    Draw only as much detail as the decisions need: one box per service, the major functions rather than their implementation, and every data store.

  5. Question 5 of 20 Which property does tampering break?

    Choose one answer.

    Show the answer: Question 5 of 20

    Answer: Integrity

    Each STRIDE letter is the failure of one property: spoofing breaks authentication, tampering integrity, repudiation non-repudiation, information disclosure confidentiality, denial of service availability and elevation of privilege authorisation.

  6. Question 6 of 20 A mobile app sends the order total to the payment service, and the service charges whatever total arrives. An attacker edits the total in the request. Which STRIDE threat is this?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: Tampering

    Data was changed on its way into the system, which breaks integrity: tampering. The fix is to compute the total on the server from the cart and the prices it trusts, and never to take it from the client.

  7. Question 7 of 20 A seller insists they never lowered the price of a phone to 1 % of its value, and the marketplace has no record of who changed it or when. Which STRIDE threat is this?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: Repudiation

    The problem is not the change itself but that nobody can prove who made it, which is repudiation. An audit log of the actor, the action, the time and the source, kept where the actor cannot edit it, is the mitigation.

  8. Question 8 of 20 The profile API returns the whole user record, including the password hash and the phone number, and the app shows only the name. Which STRIDE threat is this?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: Information disclosure

    Data reaches someone who should not see it, which breaks confidentiality. That the app does not display the fields does not matter: anyone can read the response. Return only the fields the screen needs.

  9. Question 9 of 20 An attacker steals an administrator's token and uses it to change other users' roles. How should the threat list record it?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: As spoofing that leads to elevation of privilege, with a mitigation for each step

    STRIDE letters are prompts for a design review, not a classification to argue about. A stolen admin token is spoofing, because someone acts as the admin, and elevation of privilege, because a normal account gains admin rights; both questions need an answer.

  10. Question 10 of 20 Which kind of element does the STRIDE-per-element chart ask all six letters?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: Processes

    The chart asks processes all six letters, data flows and data stores three (T, I, D) and external entities two (S, R). The shortener's four processes give 24 of its 68 questions.

  11. Question 11 of 20 A design has 2 external entities, 3 processes, 2 data stores and 6 data flows. How many questions does STRIDE per element ask?

    Type a number.

    A hint

    Two letters for each external entity, six for each process, three for each store and each flow.

    Show the answer: Question 11 of 20

    Answer: 46 questions

    2 × 2 + 3 × 6 + 2 × 3 + 6 × 3 = 4 + 18 + 6 + 18 = 46 questions. The shortener's 20 elements give 68 the same way: 4 × 2 + 4 × 6 + 3 × 3 + 9 × 3.

  12. Question 12 of 20 A team asks every one of the shortener's 20 elements all six letters. What does that cost them?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: 120 questions instead of 68, and the 52 extra ones, such as whether a flow can be spoofed, have no useful answer

    Six letters for 20 elements is 120 questions, 52 more than the chart's 68. Those 52 cannot be answered usefully, and they bury the 44 questions on the boundary that matter most.

  13. Question 13 of 20 Why does the walk start with the flows that cross a trust boundary?

    Choose one answer.

    Show the answer: Question 13 of 20

    Answer: Because an attacker can send whatever they like there, and the serious findings sit there

    The boundary is where forged identities, changed fields, huge inputs and internal addresses arrive. Walking it first is how the shortener's ownership check and fetcher were found before anything else.

  14. Question 14 of 20 Where do the build pipeline and the dependencies that a service pulls in belong in the threat model?

    Choose one answer.

    Show the answer: Question 14 of 20

    Answer: In the diagram, as external entities across a boundary

    The boundary is wider than the network edge. Software supply chain failures are a category of the current OWASP Top 10, so the pipeline and the dependencies belong in the diagram as external entities.

  15. Question 15 of 20 Why does the ranking break ties between equal scores by the effort of the fix?

    Choose one answer.

    Show the answer: Question 15 of 20

    Answer: Equal scores remove equal risk, so the smaller fix removes it sooner

    Likelihood times impact ignores what a fix costs. Among threats with the same score, the one with the smaller fix comes first, because it removes the same risk sooner.

  16. Question 16 of 20 Two engineers argue for an hour whether a threat's likelihood is 3 or 4. What does the lesson advise?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: Keep the scale coarse, agree on the top five and their owners, and use the OWASP factors only where a score must be defended

    Arguing about 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.

  17. Question 17 of 20 What must a threat that is accepted have beside it in the list?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: A named owner, a reason and a date to look at it again

    Accept means living with a threat, with a named owner, a reason and a date to look at it again, as the program's one accepted threat, the stack traces on error pages, would record.

  18. Question 18 of 20 The five highest-scoring threats of the shortener are fixed. Is its threat list finished?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: No; T07 and T10 still have no response, though each scores 10

    A threat list is finished when every threat has a response and an owner, not when the top five are fixed. The program still prints two threats as undecided: the shared admin password and the sign-up request that sets its own role.

  19. Question 19 of 20 Which part of a design's security section records what was accepted, by whom, and when it will be looked at again?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: Residual risk

    The section has five parts: assets, trust boundaries, top threats, mitigations as requirements and residual risk. Residual risk records what was accepted, by whom and when it will be looked at again.

  20. Question 20 of 20 Partners ask for a webhook that the shortener calls on every click. What happens to the security section?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: The new boundary is drawn and its flows walked, and the threats found join the list with responses and owners

    A new trust boundary is one of the three changes that open the model again. The section gains the new flow on the diagram, its threats with their responses, and any requirement the mitigations add.

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

  1. Question 1 of 2 Design brief: your service receives payment notifications from a provider through a public webhook. Which STRIDE letter do you ask about first, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Spoofing, because anyone on the internet can send a request that looks exactly like a notification

    Draw the boundary first: the provider, the public endpoint, the queue behind it and the orders database. The endpoint is a process on the boundary, so all six letters apply, and spoofing comes first because a forged notification looks exactly like a real one.

  2. Question 2 of 2 Design brief: a chat product shows a preview of every pasted link by fetching the page on the server. Which threats rank highest?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Elevation of privilege and information disclosure, because an internal address would be fetched with the fetcher's position

    The fetcher is a process inside your network whose outbound flow carries an address that a user chose. Rank elevation of privilege and information disclosure first: an internal address, such as the cloud credential service, would be fetched with the fetcher's position and permissions.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.