A threat model that isn't ready yet, and how to say so
Could this platform responsibly host real client data yet, and what would have to be true first? A threat model that answers that honestly, including saying when the answer is not yet.
Could this platform responsibly host real client data at its current stage, and what would actually have to be true before that became defensible? That’s a harder question than “what vulnerabilities exist,” because it forces you to reason about the system as one operating model instead of a pile of independently-scored findings, application code, authorization, data storage, rendering, deployment, credentials, recovery, incident readiness, all at once.
Many assessment deliverables stop at enumerating threats or findings without turning them into a production-readiness decision, and let the reader infer readiness from the finding count. I don’t think that survives contact with a real launch decision, so here’s how I actually run one, using a recent multi-tenant CMS engagement as the walkthrough.
Evidence discipline is the first deliverable, not a footnote
A threat model is the document most tempted to describe the system as it appears in the code rather than as it behaves in production, and those are not always the same claim. So before any finding gets written, every material state claim gets classified by what was actually observed: verified (demonstrated directly from source or recorded live evidence), client-confirmed (the platform owner said so, not independently checked), inferred (supported by evidence but resting on an unverified runtime condition), or unknown (converted into a concrete evidence request instead of an assumption).
This is the same discipline a vulnerability program applies when it separates “this CVE exists in a transitive dependency” from “this CVE is reachable from code that actually runs.” A repo can be beautifully authorized and still be running a different artifact in production. Repository configuration was never treated as proof of production state here, and results carried over from live testing were tagged separately from source-derived conclusions, because collapsing those two into one sentence is where most overclaiming in security reports happens.
Method, in the order it actually runs
Inventory the repository, runtime components, assets, identities, and external dependencies. Reconstruct trust boundaries and data flows across the application, renderer, edge, database, object storage, queues, and operators. Define security invariants for tenant isolation, authorization, media publication, credential authority, deployment provenance, recovery, and auditability. Trace attacker-controlled sources to security-sensitive sinks, accounting for every observed control on the path. Build STRIDE scenarios and attack trees per subsystem, including post-compromise and destructive-event paths. Separate confirmed vulnerabilities from control weaknesses, review concerns, and unknowns. Score against explicit likelihood and impact criteria, then map each finding to remediation, acceptance tests, ownership, and residual risk.
The core evidence rule sits underneath all seven steps: a condition gets called exploitable only when a plausible attacker-controlled source can be traced to a security-sensitive sink and every observed control on the path has been accounted for. An incomplete trace stays a review concern or an unknown. It never gets promoted to a finding just because it feels scary.
Assets and invariants, before a single finding
Findings are downstream of invariants, so the invariants come first. A user can access or modify only resources belonging to an authorized tenant. Session and recovery tokens can’t be reused outside their intended lifetime and scope. One compromised component shouldn’t automatically grant platform-wide tenant access. A compromised application process can’t destroy the only usable recovery copy. Every production artifact can be traced to a reviewed commit and immutable build identity. Security-relevant actions create durable records outside the authority of the initiating runtime identity.
Six sentences, and every finding in the report maps back to exactly one of them. That mapping is what turns “we found some issues” into “here is the specific guarantee this system fails to make.”
Assume-breach: what does each actor actually inherit
Strong application authorization gets treated as a substitute for infrastructure containment more often than it should. The fix for that instinct is to reason explicitly about actors who already have a foothold, not just the ones trying to get one, the same posture a cloud attack-path review takes toward IAM roles: don’t audit the label on the credential, audit the privilege-escalation path it opens.
An unauthenticated external attacker is low-capability but automated, reaching edge misconfiguration, unauthenticated telemetry, any anonymously reachable storage path. An authenticated low-privilege tenant is medium-capability with one legitimate account, reaching tenant-scoping gaps, renderer injection through content fields, mass-assignment edge cases. A compromised staff or admin account is high-capability by design, every tenant simultaneously, and the blast radius question here isn’t authorization logic, it’s credential strength and MFA. Code execution inside a container is high-capability within that container’s network reach, bounded by identity scoping. A compromised infrastructure credential sits entirely outside the application’s own authorization model, deployment integrity, domain takeover, supply-chain persistence, none of it caught by any application-layer control. And operator error or destructive automation needs no attacker at all, full production scope, everything the acting identity can touch, which is a recovery finding, not an authorization finding.
One finding, carried to full depth
Most writeups only show findings at summary depth, which makes the rigor behind them invisible. Here’s one carried all the way through, named invariant, traced attack narrative, blast radius, detection capability, residual risk after the fix, so you can see the depth that’s normally hidden behind a one-line severity tag.
TENANT-02, cross-tenant object read via an unscoped lookup function, high severity. The affected invariant: a user can access or modify only resources belonging to an authorized tenant. The observed implementation: a shared lookup helper resolved records by primary key only. The calling route enforced authentication and a capability check, but that check validated that the caller could perform the action in general, not that this specific record belonged to the caller’s tenant, textbook broken object-level authorization, OWASP API Security Top 10 API1:2023, wearing a role check as camouflage.
The attack narrative is almost boring in how little it requires. Actor authenticates normally as a low-privilege member of Tenant A. Actor requests a record belonging to Tenant B by substituting a foreign identifier obtained through an otherwise-correctly-scoped listing endpoint they legitimately have access to elsewhere in the product. The route’s capability check passes, since it only confirms the actor holds the relevant role. The unscoped helper returns the full record with no tenant predicate applied. No distinguishing signal, no audit event flagging the mismatch.
Blast radius: read access to another tenant’s records through this object class, multi-tenant in scope because the vulnerable helper is shared code reused across tenant contexts, not isolated to one deployment. The write path on the same helper was checked separately and found to enforce scoping correctly, so this instance stayed read-only, which matters for the severity conversation but not for the fix. Detection capability at assessment time: none, no alert existed for a resolved-tenant-mismatch condition, which is its own gap worth naming, since there’s no legitimate reason for that condition to occur by design.
The preferred remediation isn’t patching the three call sites individually. It’s adding tenant scoping inside the shared helper itself, so every current and future caller inherits the fix, plus a regression test asserting a cross-tenant identifier returns not-found rather than the record. Residual risk after that lands: low, because a structurally identical bug elsewhere would require a genuinely new lookup path, not a missed call site of this one.
Selected findings, summary depth
RECOVERY-01 (high): no independent, restore-tested production recovery system. The fix is off-box backups, separate backup authority, immutability or versioning, not “we have version history in the app.”
DEPLOY-01 (high): production couldn’t be attributed to the reviewed source revision. Build from a clean commit, sign and deploy by digest, expose build identity at runtime, otherwise every source-derived security conclusion in the report inherits the same uncertainty.
CREDENTIAL-01 (medium): application identities carried unnecessary administrative authority. Separate owner, migration, runtime, worker, backup, and storage-admin identities, the same “the five roles you actually need” model I keep coming back to in single-node credential design.
TENANT-01 (medium): one authenticated write path failed to verify the target tenant. Require explicit target-tenant capability before persistence.
RENDER-01 (medium): stored renderer injection through insufficiently constrained style input. Reject unsafe characters, encode defensively, replace the permissive inline-script policy with nonces.
DNS-01 (medium), and this one’s worth pausing on: domain and third-party account access couldn’t be independently verified. A single compromised registrar, DNS, or email-provider account can undermine every other control in the report, including password-reset integrity, regardless of how well the application itself is built, and it’s outside what any source review or live test can observe directly. The honest move was converting it into a concrete evidence request to the client rather than assuming it away or inflating it into a finding with no evidence behind it.
What didn’t survive testing, and why that’s in the report too
Recording what was investigated and ruled out matters as much as recording what was confirmed. It tells a stakeholder the scary, plausible-sounding thing was actually checked, not just left unmentioned.
Broad SQL injection across data-entry forms was plausible given heavy free-text input, ruled out because all reviewed query construction used parameterized queries and live injection probes didn’t alter query semantics. Vertical privilege escalation via role-parameter tampering was plausible given JSON bodies that could carry a role field, ruled out because mass-assignment testing confirmed injected role, ownership, and status fields are silently ignored server-side. SSRF via a URL-shaped field in the renderer was plausible given the renderer’s entire job is fetching remote content, ruled out within tested scope because no code path accepts an arbitrary attacker-controlled URL for a server-side fetch.
None of these get to disappear from the report just because they didn’t pan out. A hypothesis that was checked and closed is worth more to the reader than a hypothesis that was never raised.
The target state, and the verdict
The proposed architecture turns findings into explicit operating boundaries instead of a patch list: immutable deployment identity, least-privilege runtime identities, off-box telemetry, project-scoped internal credentials, recovery authority separated from production. Every material finding gets expanded past a generic recommendation using the same repeatable spec: current state and evidence, required security behaviour, a concrete (clearly unimplemented) proposed implementation, migration and rollback guidance, required positive and negative and abuse-case tests, and objective acceptance criteria with an owner and residual risk attached.
The actual decision this engagement produced: the application layer was materially stronger than the operational environment. Hosting real client data wasn’t defensible until independent recovery, deployment attribution, valid edge trust, least-privilege credentials, and the confirmed application defects were addressed. That’s not a rejection of the product, it’s a specific, falsifiable list of what has to change before the answer flips.
TL;DR
The value of a threat model was never the finding count. It’s the ability to separate controls that genuinely work from conditions that actually change the business risk of operating the platform, and then convert those conditions into engineering work someone can verify instead of just promise. Evidence-tag every claim, define invariants before findings, assume breach for every actor tier, carry at least one finding to full depth so the reader can trust the summaries, and say out loud when a hypothesis didn’t pan out. A threat model that only lists what’s broken is doing half the job.