Context & Why Threat Modeling Saves You at 2 AM
After my server got hit by SSH brute-force attacks at midnight, I realized something uncomfortable: I had spent weeks building the app but less than an hour thinking about who might attack it and how. The breach wasn’t clever — it was predictable. Preventable, too, if I had asked the right questions a month earlier.
Threat modeling is the practice of asking those questions — systematically — before you write code. Not a compliance checkbox. Not a quarterly ritual. It’s a design discipline that forces you to think like an attacker while changes are still cheap.
Most teams skip it because it sounds abstract. Wrong call. IBM’s System Science Institute found that fixing a security defect in production costs 15× more than catching it at design time. STRIDE gives you a concrete vocabulary to find those defects on a whiteboard, not a postmortem.
What STRIDE Actually Means
STRIDE is a threat categorization model developed at Microsoft in 1999. Each letter maps to a class of attack:
- S — Spoofing: Pretending to be someone else (forged JWT tokens, fake API callers)
- T — Tampering: Modifying data in transit or at rest (SQL injection, request body manipulation)
- R — Repudiation: Denying an action occurred (missing audit logs, no request signatures)
- I — Information Disclosure: Leaking sensitive data (verbose error messages, unencrypted DB fields)
- D — Denial of Service: Disrupting availability (unbounded API endpoints, resource exhaustion)
- E — Elevation of Privilege: Gaining unauthorized access levels (broken RBAC, IDOR vulnerabilities)
Don’t just memorize the acronym. Use it as a checklist to interrogate every component in your system diagram. For each trust boundary, each data flow, each process — ask which STRIDE categories apply. That’s the whole method.
Setting Up Your Threat Modeling Toolkit
Expensive licenses are optional. Here’s what actually gets used day-to-day:
Option 1: OWASP Threat Dragon (Free, Browser-Based)
Threat Dragon lets you draw data flow diagrams (DFDs) and annotate threats per component. Spin it up locally with Docker in under two minutes:
docker pull owasp/threat-dragon:latest
docker run -it --rm \
-p 3000:3000 \
-e ENCRYPTION_JWT_REFRESH_SIGNING_KEY='your-secret-key' \
-e ENCRYPTION_JWT_SIGNING_KEY='your-signing-key' \
-e ENCRYPTION_KEYS='{"current":{"issuer":"tdServer","keys":[{"isPrimary":true,"kty":"oct","use":"enc","alg":"A256GCM","kid":"k1","k":"your-base64-key"}]}}' \
owasp/threat-dragon:latest
Hit http://localhost:3000 and start drawing your architecture. No account, no SaaS, no vendor lock-in.
Option 2: Threat Modeling with Python + pytm
pytm lets you define your architecture in Python and auto-generate both DFDs and threat reports. The real advantage: threat models live in version control, right next to the code they describe.
pip install pytm
from pytm import TM, Server, Datastore, Dataflow, Boundary, Actor
tm = TM("Web API Threat Model")
tm.description = "STRIDE analysis for REST API backend"
tm.isOrdered = True
internet = Boundary("Internet")
app_boundary = Boundary("Application Server")
db_boundary = Boundary("Database Layer")
user = Actor("User", inBoundary=internet)
api = Server("REST API", inBoundary=app_boundary)
auth = Server("Auth Service", inBoundary=app_boundary)
db = Datastore("PostgreSQL", inBoundary=db_boundary)
Dataflow(user, api, "HTTPS Request")
Dataflow(api, auth, "Token Validation")
Dataflow(api, db, "SQL Query")
Dataflow(db, api, "Query Result")
Dataflow(api, user, "HTTPS Response")
tm.process()
Run it with:
python threat_model.py --report report.html
python threat_model.py --dfd # generates a DFD diagram
The output maps auto-generated threats to STRIDE categories for each component. It won’t replace human judgment, but it catches the obvious gaps and plugs directly into your CI pipeline.
Applying STRIDE to a Real Web API Architecture
Take a typical setup: user-facing REST API, PostgreSQL backend, JWT authentication, third-party payment integration. Walk each trust boundary with STRIDE and you’ll find surprises at all three layers.
Trust Boundary: Internet → API Gateway
More STRIDE categories fire here than anywhere else in the system:
- Spoofing: Can an attacker forge the
X-Forwarded-Forheader to bypass IP rate limiting? Yes — if your API trusts that header unconditionally. Fix: only honor it when the source IP falls within your load balancer’s CIDR range. - Denial of Service: Is there a rate limit on unauthenticated endpoints like
/registeror/forgot-password? Without one, both are free DoS vectors. A $5/month attacker can exhaust your DB connection pool in minutes. - Tampering: Are request bodies validated against a schema before processing? An unchecked
role: "admin"field in a registration payload has burned more than a few teams in production.
# Example: Strict request validation with Pydantic
from pydantic import BaseModel, field_validator
from typing import Literal
class RegisterRequest(BaseModel):
email: str
password: str
# Never accept role from client — assign it server-side
@field_validator('email')
@classmethod
def email_must_be_valid(cls, v):
if '@' not in v:
raise ValueError('invalid email')
return v.lower().strip()
Trust Boundary: API → Auth Service
- Spoofing: Is your JWT signature algorithm locked to
RS256orHS256? The classicalg: noneattack still works on libraries that permit it by default — and CVE databases have entries from 2023 for this exact issue. - Repudiation: When a token is issued, do you log
user_id,ip, andissued_atserver-side? Skip this and you cannot reconstruct what happened during a compromised session. - Information Disclosure: Does your auth error distinguish between “user not found” and “wrong password”? Both should return an identical generic message. User enumeration takes about 10 lines of Python to automate.
import jwt
# Always specify allowed algorithms explicitly
def verify_token(token: str, public_key: str) -> dict:
try:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # Never use ["RS256", "none"]
options={"require": ["exp", "iat", "sub"]}
)
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token expired")
except jwt.InvalidTokenError:
raise ValueError("Invalid token") # Generic message, no detail leakage
Trust Boundary: API → Database
- Tampering: Are all queries parameterized? Raw string formatting in SQL is never acceptable — not in 2024, not for internal tools, not ever.
- Elevation of Privilege: Does your app’s DB user have
DROP TABLEpermissions? It shouldn’t. A least-privilege role limits blast radius when credentials leak. - Information Disclosure: Are PII fields (email, phone, SSN) encrypted at the column level — not just at disk? Full-disk encryption doesn’t protect data that’s queried in plaintext by a compromised app process.
-- Create a least-privilege application role
CREATE ROLE api_user WITH LOGIN PASSWORD 'strong-random-password';
GRANT SELECT, INSERT, UPDATE ON users, sessions TO api_user;
GRANT SELECT ON products TO api_user;
-- No DELETE, no DROP, no schema changes
Verification & Keeping the Threat Model Alive
A threat model that lives only in a Confluence page is already dead. Architecture drifts. New microservices appear. Third-party integrations get swapped. The model needs to drift with it.
Threat Model Review Checklist
Run this whenever you ship a new feature, endpoint, or integration — takes about five minutes:
- Did we add a new trust boundary? (new microservice, new third-party API)
- Does any new data flow carry PII or credentials?
- Are new endpoints behind authentication and authorization checks?
- Did we introduce any new unauthenticated surfaces?
- Is the new component rate-limited?
- Are error responses from the new component generic enough to avoid leaking internals?
Integrating Threat Modeling into CI/CD
With pytm, you can generate and diff the threat report on every pull request. New threats block the merge until someone reviews them:
# In your CI pipeline (GitHub Actions example)
- name: Generate threat model report
run: |
pip install pytm
python threat_model.py --report threat_report_new.html
diff threat_report_baseline.html threat_report_new.html > threat_diff.txt
if [ -s threat_diff.txt ]; then
echo "Threat model changed — review required"
cat threat_diff.txt
exit 1
fi
No security engineer needs to babysit every PR. The pipeline flags the change automatically. Review happens when it matters — at code review time, not six months later in a postmortem.
Practical Prioritization: Risk Rating Your Threats
Not every identified threat needs to ship this sprint. Score each one on two axes:
- Likelihood: 1 (unlikely) to 3 (likely)
- Impact: 1 (low) to 3 (critical)
- Risk Score: Likelihood × Impact
Score 6–9: goes into the current sprint as a security task. Score 3–5: backlog, with a deadline. Score 1–2: documented, accepted, revisited next quarter. This keeps the team moving without treating every theoretical threat as a P0 blocker that stops the release.
The One Habit That Changes Everything
Put a 30-minute threat modeling session at the start of every feature design. Before any code is written. Just a whiteboard and the STRIDE checklist. Ask what could go wrong at each trust boundary. Write down the threats. Assign mitigations. Done.
The SSH brute-force incident taught me that attackers don’t wait for a convenient moment. They hit at midnight on a Friday, when the on-call engineer is asleep and nobody is watching the dashboard. Threat modeling is how you have those conversations during business hours — when fixing things costs an afternoon instead of a week.

