🧠Threat Modeling for Tired Engineers (No Whiteboard Required)
You've shipped a feature, reviewed three PRs, answered fifteen Slack pings, and now someone from the security team is asking if you've done threat modeling on the new upload endpoint.
You have not.
You haven't eaten lunch either.
Here's the thing: formal threat modeling — with its STRIDE matrices, data flow diagrams, and mandatory three-hour facilitated workshops — is genuinely useful. It's also the kind of thing that never actually gets done because everyone is already too busy surviving sprint planning. So security reviews become a checkbox, threats get found in production, and everyone agrees to "do better next time" in the postmortem.
Let's fix this with something that actually fits inside a tired engineer's day.
The Four-Question Model
Threat modeling doesn't have to be a ceremony. At its core, it's just structured paranoia. Strip away all the acronyms and you're left with four questions:
- What are we building? (What data flows where?)
- What could go wrong? (Who wants to abuse this and how?)
- What do we do about it? (Controls, mitigations, accepted risks)
- Did we actually do it? (Validation — often the most skipped step)
That's it. You can run this in fifteen minutes per feature if you build the habit. No whiteboard required. A notes doc and a suspicious mindset will do.
Threat Modeling at Code Review
The best time to think about threats is before the code goes to production — which means making it part of your PR review process, not a separate security review that happens two weeks later when everyone has forgotten the context.
Here's a practical pattern. When you're reviewing an endpoint that accepts user input, run through these questions mentally:
# New file upload endpoint — what could go wrong here?
@app.post("/api/uploads")
async def upload_file(file: UploadFile, user: User = Depends(get_current_user)):
# Q: Can the user control the filename? What if they send "../../../etc/passwd"?
# Q: Is there a size limit? What about a 10GB file?
# Q: What file types are allowed? Can they upload a .php or .exe?
# Q: Does the storage bucket have public read access?
filename = secure_filename(file.filename) # path traversal fix
if file.size > MAX_UPLOAD_SIZE: # DoS fix
raise HTTPException(413, "File too large")
if not is_allowed_type(file.content_type, file.filename): # type confusion fix
raise HTTPException(415, "File type not allowed")
key = f"uploads/{user.id}/{uuid4()}/{filename}" # user isolation + unpredictable path
await storage.upload(key, file, acl="private") # private by default
return {"key": key}
You don't need a STRIDE diagram. You need the habit of asking "what's the worst thing a motivated attacker could do with this input?" as you write each line.
The Attacker's Motivations Cheat Sheet
When you're tired and need a fast mental checklist, think about attacker archetypes:
- The opportunist — running automated scanners. They're not targeting you specifically. They want default credentials, exposed admin panels, and unpatched CVEs.
- The disgruntled insider — has valid credentials and knows your system. They'll try privilege escalation, bulk data export, and covering their tracks.
- The competitor — wants your data or wants to slow you down. API scraping, account enumeration, and subtle data poisoning are their tools.
- The extortionist — ransomware, DDoS threats, or threatening to publish a vulnerability publicly. They want money or attention.
A five-minute question: "Which of these four could most easily abuse this new feature?" grounds your threat modeling in realistic adversaries instead of abstract attack categories.
Documenting Decisions (So Future-You Isn't Blindsided)
The part engineers skip most is writing down why a security decision was made. Three months later, someone removes the "weird check" because it looks like dead code, and suddenly you have a vulnerability again.
A light ADR (Architecture Decision Record) in a comment or in your PR description goes a long way:
## Security decisions in this PR
**File type validation:** We validate both `Content-Type` header AND file extension
AND inspect the first 512 bytes for magic bytes. Content-Type alone is trivially
spoofed by attackers. Extension alone doesn't catch renamed executables. Magic bytes
catch both. If you're tempted to remove one layer — don't, they're all load-bearing.
**Unpredictable storage paths:** Using UUIDs in the path prevents enumeration even if
the bucket policy is accidentally loosened. This is defense-in-depth, not the primary
control.
**Accepted risk:** We do not scan uploaded files for malware at upload time (adds
~2s latency). Files are quarantined and async-scanned before being served to other
users. See SECURITY-012 for the full trade-off discussion.
At Cubet, we started adding a lightweight "security decisions" section to our PR template. It takes about two minutes to fill out and has caught three instances of "helpful" code reviewers removing defensive code because it looked unnecessary.
Making It Stick Without Burning Out
The goal isn't to become a security team. It's to catch the obvious stuff before it becomes an incident.
A sustainable rhythm:
- Per PR: Run the four questions mentally. Write down anything non-obvious.
- Per sprint: Spend fifteen minutes with your team on the riskiest change that shipped. One "what could go wrong?" conversation per sprint builds the habit without killing velocity.
- Per quarter: Do a proper threat model on one system — pick the one that would hurt most if compromised. That's your crown jewels exercise.
The trap is thinking threat modeling has to be comprehensive to be valuable. A thirty-second "wait, can someone enumerate user IDs here?" caught before merge is worth more than a thorough STRIDE matrix produced after the incident.
Security is a habit, not an event. Start small, stay consistent, and future-you (the one writing the postmortem at 2am) will thank present-you.
What's your go-to shortcut for threat modeling under time pressure? I'm always looking for better heuristics — find me on Twitter/X or connect on LinkedIn.