devShakib

Threat Modeling on a Whiteboard in Forty-Five Minutes

Lightweight threat modeling for small teams: run a 45 minute whiteboard session with four questions, map trust boundaries, and ship ranked security backlog tickets.

A client once asked me if we had "done threat modeling" on their app. What they meant was: is there a document? Because a security consultant three years earlier had produced a 40-page PDF with STRIDE tables and data flow diagrams, and it had lived in a Drive folder untouched ever since. The app had changed twelve times. The document had not. Nobody on the team had read past page four.

That is the default failure mode of threat modeling in small teams. Not that people skip it because they're careless — they skip it because the version they were sold is a compliance artifact, not a working tool. So here's the lightweight threat modeling process I actually run: a whiteboard, four questions, and forty-five minutes on the calendar every quarter. No dedicated security team required. The output is not a document. It's a handful of boring backlog tickets, ranked by risk, that we fix like any other work.

If you're a founder, a startup CTO, or the one engineer who inherited "security" by accident, this is the version of application security threat modeling that survives contact with a real sprint schedule. It's the same approach I run across the Flutter and Firebase apps I ship, and it scales down to a two-person team without losing the parts that actually catch vulnerabilities.

Why heavyweight threat modeling frameworks die in small teams

STRIDE, PASTA, LINDDUN, attack trees — these are good frameworks. I'm not dunking on them. They were built for organizations with a dedicated security function, a threat intelligence feed, and the time to be exhaustive. They assume someone owns the threat modeling process full-time.

At a startup, nobody owns it full-time. If threat modeling takes a specialist and two days, it happens once, badly, and then never again. The framework doesn't lose because it's wrong. It loses because it's heavy, and heavy things don't get repeated. And a security assessment that isn't repeated is a snapshot of a system that no longer exists.

The trap is thinking the alternative to a heavyweight process is no process. It isn't. The alternative is a process small enough to survive a real sprint. My bar is simple: can four engineers who are not security experts run it in under an hour, and will they still be willing to run it next quarter? If yes, it'll catch 80% of what matters. The last 20% is where you hire the specialist or book a penetration test — later, once you have something worth protecting.

One more thing worth naming up front: you do not need to abandon STRIDE entirely. I keep its threat categories — spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege — as a mental checklist in the back of my head during the session. What I drop is the ceremony: the tables, the exhaustive enumeration, the formal data flow diagram notation. Keep the vocabulary, throw away the paperwork.

Draw the system as your attacker sees it, not your architecture diagram

The first mistake is drawing your architecture. Boxes for services, arrows for API calls, a nice clean diagram that looks like your Confluence page. That diagram is organized around how you think about the system. An attacker does not care about your microservice boundaries.

Draw it around trust instead. On the whiteboard, I put three kinds of things:

The trust boundaries are the whole game. This is the single idea to take from data flow diagrams even if you ignore everything else about them. Every real breach is something crossing a boundary it shouldn't. A logged-out user reading logged-in data. A normal user hitting an admin endpoint. A contractor's laptop reaching production. Draw the boundaries as thick lines and you've already found half your threats, because your eye goes straight to the arrows that cross them.

Keep it ugly. The diagram is scaffolding for the conversation, not a deliverable. If someone reaches for a diagramming tool, you've already lost the forty-five minutes. A photo of the whiteboard on your phone is the only archival copy you need.

The four questions that surface almost every real threat

Once the boundaries are on the board, you walk each crossing and each asset with four questions. I took the skeleton from Adam Shostack's four-question framing and stripped it to what a tired team will actually run on a Thursday afternoon.

The magic is in question two, and the trick is to be specific about who the attacker is. Not "a hacker." Build a couple of concrete threat personas:

Each persona surfaces different failures. "Curious logged-in user" alone finds most authorization bugs — broken access control, insecure direct object references, missing ownership checks — and authorization bugs are the ones that actually leak data in the kind of apps I build. If you only have time for one persona, pick that one.

Turning threats into ranked, boring backlog tickets

Here's where most sessions die. You've found twenty threats, the energy is high, everyone feels productive, and then it evaporates because there's no artifact anyone works from. A document is not that artifact. A backlog is.

Every threat worth keeping becomes a ticket in the same tracker you use for features and bugs. Same board, same sprint, same standup. Security work that lives in a separate "security tracker" is security work that never ships. Write the ticket like this:

[SEC] Firestore rules allow any authed user to read any user's profile
Attacker: any logged-in user. Impact: full PII leak of the user base. Fix: scope read rules to request.auth.uid == resource.data.ownerId. Effort: S.

Then rank. You don't need a formal scoring model like CVSS — a two-axis eyeball works: impact (how bad if it happens) times likelihood (how easy to pull off). I use a rough grid:

| | Low likelihood | High likelihood |

|---|---|---|

| High impact | Fix this quarter | Fix now |

| Low impact | Backlog / accept | Fix soon |

The "full PII leak, trivial to exploit" ticket goes to the top of the current sprint. The "theoretical timing attack that needs physical access to our office wifi" goes to the bottom or gets explicitly accepted. Writing "we accept this risk" is a valid, and honestly underused, outcome — as long as someone with the authority to accept it actually signs off, out loud, in the room. A team that never accepts a risk isn't being careful — it's being dishonest about its priorities.

Boring is the goal. If the tickets read like normal engineering tickets and get picked up in normal sprints, the process worked. If they read like a security report, they'll rot in the same folder as that 40-page PDF.

Modeling the human layer: phishing, insider mistakes, contractors

Every technical threat model I've seen skips the humans, and the humans are where most real incidents actually start. Nobody popped a zero-day on your Flutter app. Someone clicked a link, reused a password, or a contractor pushed an API key to a public repo. Spend ten of your forty-five minutes here — this is the highest-ROI slice of the whole session.

The questions change slightly for the human layer:

The human layer produces the cheapest, highest-value tickets you'll write all quarter. Enforce 2FA. Rotate a leaked key. Add a deploy review. None of it is glamorous. All of it beats a breach and an incident-response bill.

Keeping the threat model alive: the quarterly ten-minute refresh

The first session takes forty-five minutes. Every one after that takes ten, because the board already exists. That's the whole reason this lightweight approach works — you're not re-deriving the system, you're diffing it.

Once a quarter, put it on the calendar as a recurring event and ask three questions:

Tie the trigger to reality, not just the calendar. Any time we add an entry point or touch auth, that pull request gets a one-line "does this cross a trust boundary?" check in review. The quarterly session is the safety net; the per-PR reflex is what actually keeps the model current. Security that's a habit beats security that's an event.

A worked example: threat modeling a real Firebase app

Let me make it concrete with the kind of app I build all the time: a Flutter client, Firebase Auth, Firestore, Cloud Storage, one Cloud Function for a webhook. Whiteboard version.

Entry points: the mobile app, the Firestore SDK (yes, the client talks to the database directly), a public Storage bucket for avatars, the webhook function. Assets: user profiles (PII), uploaded documents, auth tokens. Trust boundaries: logged-out vs logged-in, normal user vs admin, client vs backend.

Walk the crossings with the four questions and here's what falls out.

Client writes directly to Firestore. Can a logged-in user read another user's document? If your Firebase Security Rules are lazy, yes. This is the number one Firebase mistake I see — broken access control hiding behind request.auth != null. Authentication is not authorization. Ticket: scope every rule to the owner's UID.

// Bad — any authenticated user reads everythingmatch /users/{userId} {  allow read: if request.auth != null;}// Fixed — you only read your own docmatch /users/{userId} {  allow read: if request.auth.uid == userId;}

Public avatar bucket. Can someone enumerate and download every user's uploads, or upload a 2GB file to blow the free tier (a denial-of-wallet attack)? Ticket: tighten Storage rules to owner-scoped paths and add a size limit in the rules themselves.

match /avatars/{userId}/{file} {  allow write: if request.auth.uid == userId               && request.resource.size < 5 * 1024 * 1024;}

Webhook Cloud Function. Is it authenticated? A public function that trusts its payload is an open door — anyone who finds the URL can forge events, replay old ones, or hammer it to run up your bill. Ticket: verify the signature or shared secret on every request, reject the rest, and rate-limit.

const crypto = require("crypto");function verifySignature(rawBody, signature, secret) {  const expected = crypto    .createHmac("sha256", secret)    .update(rawBody)    .digest("hex");  // constant-time compare avoids a timing side channel  return crypto.timingSafeEqual(    Buffer.from(signature),    Buffer.from(expected)  );}

Human layer. Who owns the Firebase project? Is 2FA on? Is a service account key sitting in the repo history? One command answers the scariest question fast:

git log -p --all -S 'private_key' -- '*.json'

That's a real session. Four tickets, ranked, dropped into the same sprint board as the feature work. The owner-scoped rules go in this sprint because a PII leak is high impact and trivially exploitable. The webhook signature goes right behind it. The bucket size limit is medium. And crucially, in three months the ten-minute refresh will ask "what changed?" — and when we added that new payments integration, it'll get its own four questions before it ships, not after it leaks.

Key takeaways