Software Development with AI 13 min read Updated Jul 16, 2026

How to Threat Model a Feature With AI

Threat model a feature while it is still a design: name what it protects, follow the data until it crosses a trust boundary, and write abuse cases against the real flow rather than a list of security-sounding nouns.

Get the Threat-Modelling Stance

There is nothing to look at yet

That is the point of doing it now, and it is also why the meeting keeps getting moved. Threat modelling happens while the feature is three paragraphs in a doc and a photo of a whiteboard: the exact window in which nobody can say "look at line 40," because there is no line 40. You are being asked what could go wrong with something that does not exist, by people who would rather be building it, and every objection you raise is unfalsifiable in the most literal sense.

Six weeks later there is plenty to look at. There is also a queue in the middle of it that four things now trust, and moving that trust has stopped being an afternoon's conversation. It is a migration. The cheapest hour on this feature is the one where the design is still an opinion. It is also the hour with the least to show for it, which is why it keeps losing to work that produces a diff.

Where the sentences come from

Ask anyone — a model, a room, yourself at 5pm — what could go wrong with a feature, and you will get plausible sentences. Data leakage. Injection. Rate limiting. Access control. Each names a real category, and none of them is about your feature; the same eight bullets fit the last four things you shipped, which is exactly why they generate no work and get nodded through.

The difference is where the sentences come from. Recall gives you categories. Derivation gives you findings. So you write down what the feature protects, who touches it, where it crosses from one level of trust to another, and what already stands in the way — and only then ask what goes wrong. Now the answers arrive attached to something: this asset, past this boundary, given this precondition, against this existing control, leaving this gap. Not more thorough. Arguable, because every line points at a piece of the design somebody drew.

AI is good at this once you have supplied the model. Hand it the feature, the actors, the flow, the boundaries and the controls you already have, and it will produce a covering set of candidate abuse cases, push on the assumptions you left implicit, and catch the boundary you drew two paragraphs ago and then reasoned past. What it cannot do is know your system. What NewPrompt does is assemble a prompt, in your browser, and that is the edge of it: it does not read your repository or your source control, does not reach your running application, your network, your cloud, your database or your identity provider, does not scan code, does not test for vulnerabilities, does not run a penetration test, does not confirm that anything is exploitable, and does not run monitoring or any kind of security testing. Every fact in the model is one you typed. What comes back is a set of candidates, and a candidate threat says only that nothing in the design appears to stop something. That is not the same claim as a vulnerability existing. Your security owner and the technical owners confirm the model, the mitigations and the risk you are accepting; anything high-risk still wants a qualified security engineer and real testing against the built thing. Nothing here makes a feature secure, threat-free, fully mitigated or ready for production.

Step 1: Draw the box, and say what is outside it

One paragraph: what is changing, and what is explicitly not in this model. The second half does more work than the first. Without it every threat model expands until it is about authentication, because authentication is upstream of everything and somebody always has a question about it, and you spend the hour re-modelling a system that already has a model and an owner.

So name the neighbours and hand them over out loud. The login that got the user here is a different model. Who is allowed to do what, once they are in, is access-control design, and it is one control family inside this one rather than a substitute for it: a permission matrix tells you who may press the button and nothing about what the button reaches afterwards. This box is the feature's own path. The boundary you draw here is what stops the document dissolving into every adjacent concern in the codebase.

Step 2: Name what the feature protects before naming what could go wrong

Assets first, threats second, and in that order for a reason: a threat with no asset behind it is a worry, and worries do not survive a planning meeting. The list is shorter than people expect and always contains something that is not data:

The two that get missed are the last two. Availability is an asset because a feature that lets one customer stall a shared queue produces an outage the other customers experience, and nobody files that as a security bug. Audit history is an asset because when the question is who did this, the answer has to be true, and a record that can be made to say the wrong thing is worse than no record: somebody will believe it.

Keep this list separate from the things that merely appear in the diagram. A queue, an object store and a log aggregator are dependencies — they do nothing on their own, they have no intent, and filing them as actors is how you end up with a threat model about infrastructure instead of about people.

  • The data itself, including other customers' data, which is the one people forget because this feature is not about them.
  • Credentials and secrets the path touches, holds, or logs.
  • Availability of this feature, and of anything sharing infrastructure with it.
  • The audit record: who did what, and whether it can be made to say something false.
  • Tenant isolation, if you have tenants. An asset in its own right, not a property of the database.
  • User trust — the quiet one. The reason a user believes what the feature shows them is theirs.

Step 3: Follow the data until it crosses something

Draw the flow in whatever ugly form you have; arrows in a text file are fine. Where does data enter, where does it go, where does it rest, who reads it later. Then mark every place it crosses from one level of trust to another. A trust boundary is not a network-diagram thing. It is any point where something stops being checked because whatever came before it was supposed to have checked.

That sentence is where most findings live. The boundary at the front door is the one everybody models, and it is usually the one already handled, because it looks like a boundary. The ones that pay are internal and invisible: a service that receives work from a queue has no session, no user, and no way to tell whether what it just received is true. The control at the front door was real. It does not extend past the hallway, and nothing announces that.

Then write the assumptions down as assumptions, and go check two of them. These are the load-bearing sentences of the whole model, they are usually said rather than verified, and one of them will be wrong in a way that quietly invalidates a chunk of your work — either by making a threat impossible, which is a relief, or by making it trivial, which is the finding. When one turns out wrong, redraw the diagram before you touch the threat list. An assumption that changes the flow has changed what everything downstream of it was resting on, and a threat list reconciled against an old diagram is the exact mistake this whole exercise exists to catch.

Step 4: Write each abuse case against the flow, not against your imagination

An abuse case earns its place by pointing at the diagram. The shape that keeps it honest:

The precondition does the filtering. "Someone sends a malicious file" has no precondition and can be neither argued with nor designed against. "Something other than the API can enqueue a job" is a precondition: it is either true or false, somebody can go and find out which, and until they do you know precisely what you do not know.

Frame these as properties rather than performances. The useful sentence says nothing in this design stops a given thing from happening — a claim about the design, checkable against the diagram, and the input to a fix. The alternative is a story about an intruder crafting something clever, which sounds more urgent, cannot be verified without building the thing, and invites a level of operational detail that nobody needs in order to close the gap. You are here to find out what to build. Keep the threat at the resolution that decides the mitigation, and no further.

  • Preconditions — what has to be true first. If nothing does, it is not a case yet.
  • Affected asset — from Step 2's list, by name. If it is not on the list, say why it is here anyway.
  • Impact — what it costs, in the currency of that asset.
  • Existing control — what is there, honestly, including "nothing".
  • Gap — why the existing control does not cover this. Usually: it lives on the wrong side of a boundary.
  • Candidate mitigation — what would close it, at the level of a requirement rather than a patch.
  • Verification need — the test somebody writes later to find out whether any of this was true.

Step 5: Keep the control you have apart from the mitigation you want

Two columns, never one. The moment "we bound the file size" and "we should bound it in the worker too" share a cell, the model reads as though the gap is closed, and everyone who skims it will believe that. Present tense is what exists. Future tense is what somebody has to build, and it needs a name on it or it will not get built.

Then check each mitigation three ways. Against the feature: a fix that changes what the feature means is a product decision in disguise, and a security review is not where product decisions get made. Against itself: the natural fix for a data-in-logs finding is to log the thing so you can debug it. And against the other mitigations, which is the one everybody skips — two fixes written an hour apart, each sensible alone, can meet in the middle and produce a behavior neither author would have signed off on.

On severity, be plain and stop. Impact usually follows from the asset and you can reason about it. Likelihood usually depends on a fact nobody in the room has, and inventing one to fill a column produces a number that outlives its own uncertainty: six weeks on it is a 3 in a spreadsheet and nobody remembers it was a shrug. Write "unknown", and keep high-impact-unknown-likelihood visible as both rather than averaging it into a comfortable medium. What you leave open, leave open deliberately: a residual risk with an owner, a reason and a revisit trigger has been accepted, and the same risk without them has merely gone unnoticed. Only one of those is a decision, and it belongs to the security owner and the product owner together. The model does not get a vote.

Step 6: Hand it over with the unknowns still marked

It goes to somebody who can say the model is wrong: a security engineer if you have one, and the technical owners of whatever it depends on. What they are reviewing is not the threat list. It is the box and the assumptions, because everything else is downstream of those, and if the box is wrong the threats are wrong in a way no amount of care in the threat list will repair.

Mark the false positives instead of deleting them. Something gets raised in every one of these that turns out not to be a threat this model can act on: a real problem in the wrong document, a compliance question in a security question's clothes. Strike it, say why, name the person it went to. Delete it and it comes back next review, from the same person, with the same conviction, and gets re-litigated from scratch.

Then keep it, and give the unknowns names too. An open question with an owner is work. The same question with "TBD" against it is a decision nobody has noticed they are making by default.

Common mistakes

The failure mode is a document that reads like security and generates no work.

  • Threats with no asset. "Injection" is a category. "A validation error puts a customer's phone number somewhere every engineer can read" is a threat, because you can name what it costs and who it costs it to.
  • The threat model that was never reopened. It was written, it was reviewed, and then the design changed twice and nobody looked at it again. A model that is not reopened when the flow moves was a compliance exercise with good intentions.
  • Boundaries drawn around teams. The line goes where the trust changes, not where the org chart does. Two services owned by the same group, one of which cannot verify what the other sent it, have a boundary between them whatever the reporting structure says.
  • The assumption verified by asking the person who wrote it. "Can anything else reach this queue?" "No." That is the same sentence you started with, now wearing a witness. Verified means somebody looked at the configuration.
  • Precision nobody earned. A severity of 7.5 on a feature that does not exist, computed from a likelihood that was a guess. The decimal point is doing rhetorical work. Say high impact, likelihood unknown, blocked on Q1, and the reader knows exactly as much without being misled about how much.
  • Reading the list as findings. They are candidates against a design, written from what you typed. NewPrompt does not read your code, reach your systems, test anything, or establish that one of them is real. A security engineer decides which survive, and the built thing still needs testing.

Threat modelling a multi-tenant CSV import

A workspace admin uploads a CSV of contacts and the rows become contact records in their workspace. Small, dull, and it touches every tenant's data, an object store, a queue and a log aggregator. The ordinary kind of feature where this actually pays. The feature, the infrastructure and the people in it are invented.

One feature, three trust boundaries and six candidate threats: a wrong assumption that redraws the diagram and creates a threat, a control that stops at a door the file never uses, two mitigations that collide, a fix that reopens what it closes, and one struck as somebody else's problem
THREAT MODEL — Bulk CSV Contact Import        draft v1, design stage, no code

THE BOX
  In scope     The import path: upload, parse, validate, insert, import report.
  Out of scope The contacts feature itself. Export. The authentication that got
               the admin here; that has its own model and its own owner.
  Written first. In six months this paragraph is the part still true.

ACTORS
  Workspace admin      authenticated; starts the import
  Workspace member     sees the resulting contacts and the import report
  Import worker        internal service, runs async, holds no user session
  A compromised admin  not a villain. The same admin, whose session is under
                       someone else's control. Same permissions, different
                       intent. Modelled because the controls hold either way.

DEPENDENCIES (not actors: they do nothing on their own)
  Object store         holds the uploaded file
  Queue                carries the job from API to worker
  Log aggregator       third party; engineering-wide read access

ASSETS — what this path protects
  Every workspace's contact data, not only this one's
  The uploaded file, at rest in the object store
  Availability of the import pipeline for the other tenants
  The audit record of who imported what, and when
  A member's trust that a contact in their list came from their workspace

DATA FLOW
  browser --[B1]--> API  ---> file record {file_key, workspace_id, uploaded_by}
     |                     |
     |                     +--> queue {file_key, workspace_id, actor_id}
     |                                        |
     |                                     [B2]
     |                                        v
     +--[B3]--> object store <--------- import worker --> database
       pre-signed PUT                          |
       24h, no API in path                     +--> report --> browser
                                               +--> log aggregator

TRUST BOUNDARIES
  B1  browser -> API. Everything from the browser is untrusted. The tenant comes
      from the session, never from the request body.
  B2  queue -> worker. The worker holds no session. It has workspace_id because
      the payload contains it.
  B3  browser -> object store. Direct. The API is not in this path, so no
      API-side control applies to it at all.
  B1 is the one everybody models. B2 and B3 are the ones that pay.

ASSUMPTIONS (checked, and one was wrong)
  A-1  The queue is reachable only from the API.       UNVERIFIED — see Q1.
       T-1 rests on this.
  A-2  The bucket is private, so the file is reachable only by us.
       WRONG. The upload uses a pre-signed URL handed to the browser, valid 24
       hours. The true statement: reachable by anyone holding that URL for 24
       hours, and writable by them too. This is B3, which was not on the first
       diagram. Correcting it changed three lines: it created T-2, it removed
       C-2 from the upload path entirely, and it is why the file record — not
       the file — is what T-1's mitigation trusts.

EXISTING CONTROLS (what is there, not what we want)
  C-1  API derives workspace_id from the authenticated session, at B1, and
       writes it to the file record
  C-2  10 MB limit on the upload request
  C-3  Bucket is private; access by pre-signed URL
  C-4  Logs are access-controlled to engineering

──────────────────────────────────────────────────────────────
T-1  Rows land in another workspace
  IMPACT      high        LIKELIHOOD  unknown — turns entirely on Q1
  Preconditions   Something other than the API can enqueue, or the API copies a
                  client-supplied field into the payload. Depends on A-1.
  Asset           Every workspace's contact data. Tenant isolation.
  Impact          Contacts written into a workspace that did not import them,
                  visible to its members, attributed by the audit record to an
                  actor who did nothing.
  Existing control C-1 — at B1.
  Gap             C-1's guarantee is not re-established at B2. The worker cannot
                  tell whether the workspace_id it holds is the one C-1 derived
                  or one someone put there. The value crosses; the guarantee
                  does not.
  Mitigation      The worker reads the tenant from the file record it fetches by
                  file_key, and treats the payload's workspace_id as an assertion
                  to verify against it rather than a fact to use.
  Verification    A test that enqueues a job naming a different tenant, asserting
                  zero rows land. Nobody has written it.
      ^ high impact and unknown likelihood do not average to medium. Both stay
        on the line, and the unknown is what makes Q1 the blocking question.

T-2  A file larger than the API would take
  IMPACT      medium      LIKELIHOOD  high — this needs no bad intent
  Preconditions   An admin writes a file through the pre-signed URL (B3) that is
                  larger than C-2 would have allowed. Depends on A-2.
  Asset           Availability of the pipeline for the other tenants.
  Impact          Workers pinned on one file; queued imports stall. One tenant
                  produces an outage the other tenants experience.
  Existing control None. C-2 sits on an API path this file does not take.
      ^ this line only exists because A-2 got checked. On the first diagram the
        file went through the API, C-2 capped it at 10 MB, and T-2 was
        impossible. One wrong assumption was holding up the whole threat.
  Gap             Nothing bounds the file, and nothing bounds rows or line
                  length even if something bounded bytes.
  Mitigation      The worker enforces its own bounds — bytes, rows, line length
                  — and fails the job rather than the process. A per-tenant
                  concurrency cap would bound the blast radius further.
  Verification    A file over each bound, asserted to fail cleanly while a second
                  tenant's queued job completes.

T-3  The same import runs twice
  IMPACT      medium      LIKELIHOOD  high — retries are on by default
  Preconditions   The queue retries after a worker timeout.
  Asset           Contact data integrity; the audit record.
  Impact          Duplicate contacts. Two audit rows, reading as though the admin
                  imported twice.
  Existing control None.
  Gap             The job carries no idempotency key and the insert is not
                  idempotent.
  Mitigation      An idempotency key on the JOB — the file_key, which is unique
                  per upload — recorded only on successful completion, so a retry
                  is a no-op and a re-run after a failure is not.
      ^ two traps here, and the first one is the interesting one. 'Upsert the
        rows on email' was the first suggestion: it closes the threat and
        silently merges two people who share an inbox. That is a feature change
        wearing a mitigation's clothes, and it does not get decided in a
        security review. The second: key on completion, not on receipt. Key on
        receipt and a job that failed under T-2's new bounds can never be
        re-run — the mitigations collide and the import silently never happens.
  Verification    Enqueue the same job twice, assert one set of rows. Then fail
                  one, re-enqueue it, and assert it runs.

T-4  Contact data leaves the workspace through the error path
  IMPACT      medium      LIKELIHOOD  high — bad rows are the common case
  Preconditions   A row fails validation. Every real import has some.
  Asset           Contact data, and who is allowed to see it.
  Impact          Two audiences widen at once. The report echoes the failing row
                  to every member of the workspace, not only the admin who
                  uploaded. The worker logs it to an aggregator all of
                  engineering can read — somewhere the workspace never agreed to.
  Existing control C-4 — logs access-controlled to engineering.
  Gap             'Engineering' is a much larger audience than 'this workspace'.
                  C-4 is a control against outsiders and was never a control
                  against this.
  Mitigation      Requirement, not a log line: no contact field value reaches the
                  aggregator by any path, including exception traces. The report
                  and the log carry a row number and a validation code.
      ^ the obvious fix is 'log the row so we can debug it', which is the threat.
        And patching one log statement misses the stack trace, which is where a
        parse failure puts the row anyway. Note this does not close everything:
        row number plus file_key still points at the value for anyone who can
        read the store. That is smaller, and it is not nothing.
  Verification    A failing import and an induced parse exception, both asserted
                  clean.

T-5  A stored cell is a formula somewhere downstream
  IMPACT      low         LIKELIHOOD  unknown — depends on the export path
  Preconditions   A contact field begins with a character a spreadsheet reads as
                  the start of a formula.
  Asset           Outside the ASSETS block above, deliberately: the risk lands on
                  whoever opens an export, and export is out of the box. It is
                  here because this is where the content enters the system.
  Impact          A cell that is data here may not be treated as data there.
  Existing control None at import. Stored verbatim, which is correct.
  Gap             Not ours to close. Import is the entry point, not the exposure.
  Mitigation      Handed to Priya (export owner) with the entry point named.
  Verification    The export owner's call, in the export owner's model.

T-6  STRUCK — not a threat this model can act on
  Raised          'An admin could import a contact list they have no right to.
                  That is a GDPR problem.'
  Assessment      Real problem, wrong document. The feature does exactly what it
                  says, for a user who is who they claim to be, with data they
                  already hold. No boundary fails and no control is missing:
                  there is nothing here to design.
  Disposition     Struck. Sent to Marta (data protection lead). Recorded so it
                  is not re-raised and re-litigated from scratch in the next
                  review.

──────────────────────────────────────────────────────────────
ACCEPTED RESIDUAL RISK
  T-2's per-tenant concurrency cap is not being built this quarter. With the
  worker's byte and row bounds in, one tenant can slow the queue but not stop
  it. Residual: a determined admin degrades import latency for others.
  Accepted by Ana (platform lead). Revisit when import volume doubles.
  Accepted with a name and a trigger is a decision. The same risk without them
  is just unnoticed.

OPEN QUESTION — blocking
  Q1  Can anything other than the API enqueue an import job? Nobody in the room
      knew. T-1 is high impact and its likelihood is unanswerable until this is.
      Owner: Ana. This is the line the review was actually for.

NOTHING HERE HAS BEEN RUN
  Every Verification line is a test somebody still has to write, against code
  that does not exist. T-1 says nothing in this design prevents a job payload
  from naming another tenant. It does not say the import is exploitable: nobody
  has tried, and there is nothing yet to try it against. A security engineer
  reviews this before any of it is treated as settled. Fictional feature,
  fictional infrastructure, fictional people.

Where this fits in NewPrompt

The Security Engineer Role Prompt is the stance, and stance is most of what separates a threat model from a list of nouns. It is a rendered role prompt, a standing instruction you paste at the top of the conversation, and its own copy names this job outright: threat-modelling a feature before it ships is the first thing it says it is for. What it puts in the model's head is the part you would otherwise have to argue for. Assume breach, so one compromised component does not hand over everything. Ask what is worth stealing and where the trust boundaries are. And the rule that keeps the exercise in its lane: stay defensive, recommend mitigations and detections, do not produce working exploit code. Its limits are declared too — it says no to offensive tooling by design. One place to depart from it deliberately: it tells the model to rank by likelihood times impact, and Step 5's position is that when you do not have the likelihood, saying so beats multiplying by a guess.

It is a role, which means it supplies the perspective and not the document. The structure — the box, the assets, the flow, the seven fields per abuse case — is yours, and it is the half that makes the output reviewable. Role Prompt Generator is where a variant of that prompt gets built, in your browser, if the stock seniority or focus areas are not yours. It assembles persona text and nothing else runs.

The clean neighbour is Security Code Review Prompt, and the boundary between them is time. It reviews code that exists against a fixed list of checks and returns findings on a file you paste. This model runs before any of that, on a design, and produces requirements rather than findings. Its own note applies here too: it raises the floor, it is not a substitute for a professional penetration test. The AI Security Review Workflow is where that half lives — anchor the model as a security engineer, run a threat-focused review of the code, back the findings with tests. Read its boundary rather than assuming it is this guide's parent: it says plainly that it is not for designing security architecture from scratch, because it reviews existing code. This guide is the step before there is anything to review. NewPrompt gives you the wording and the sequence. Running them is yours, and so is every risk decision that comes out.

In six months, most of what you wrote will be gone. The threats will have been fixed, accepted, or overtaken by a rewrite that made half of them irrelevant. Somebody will be sitting in front of the same four arrows asking whether the new queue changed anything, and they will answer it in twenty minutes, because the boundaries are drawn and the assumptions are written down where a changed one is visible. That is the whole return. Do it without them and the next person starts from a blank page, in front of a feature that now has a line 40 — which is how most teams end up threat modelling exactly once.

Tools for this guide

Each generates the prompt described above — you run it in your own AI assistant.

Ready-made resources

Reusable prompts and templates for the exact steps in this guide.

Take it further

When this task is one step inside a larger workflow or build.

FAQ

The feature doesn't exist yet. What can this actually find?

The things that are expensive precisely because they are structural. A control that stops at a boundary, a service that trusts a payload because it has no session, an assumption nobody checked, a piece of data that ends up somewhere the customer never agreed to. None of those are bugs in a line of code; they are properties of the shape, and the shape is what you have right now. It is also the only moment they are cheap — moving where trust gets established costs a conversation today and a migration once the queue has four other consumers. What it cannot find is anything about the implementation, because there is not one. That is the other half of the job and it happens later, against real code.

Isn't this just STRIDE with extra steps?

A category framework is useful the way a checklist is useful at the end of a flight rather than at the start: sweep the finished model with it and see whether a whole class of problem is missing. Generating from it is where it goes wrong. Work down the letters and you will produce a threat for a surface your feature does not have, and then defend it, because an empty row feels like negligence and a filled one feels like diligence. A category is not evidence that a threat exists in your design. The flow is the source; the framework is a second pass over what the flow gave you.

We don't have a security team. Is a model's threat model better than nothing?

Better than nothing, a long way from enough, and the gap is widest exactly where you would want it narrowest. A model working from a design you described produces a reasonable set of candidates and misses what it was not told: the internal service with the shared credential, the fact that the queue is also reachable from the nightly batch job, the thing everyone knows and nobody wrote down. It also cannot tell you which risks your business is willing to carry, which is not a technical question. So run it, take the candidates, and use them to make your gaps specific enough to be worth an outside engineer's hour — a short review of a real model beats a long review of a blank page. If the feature handles money, health data, or other people's customers, get the person.

The model produced twelve threats. How do I know which are real?

None of them are real yet, in the only sense that matters: nothing has been built, nothing has been tested, nothing has been reproduced. You have twelve claims that the design does not appear to prevent something. Sort them by asking two questions of each — does it point at a specific place in the flow, and is its precondition something somebody can go and check? The ones that survive both become work: a verification test, or a question with an owner. The ones that fall out are usually categories that arrived with no boundary behind them. And the ones whose precondition nobody can answer are the most valuable output of the exercise. They are the things you did not know about your own system, and they are worth more than the threats you confirmed.