Threat modeling has a reputation for being a whiteboard exercise that produces a PDF nobody reads again. Done properly, it’s the opposite: a focused pass over your actual architecture that turns vague unease (“is our object storage exposed somehow?”) into a prioritized list of things to fix, in language a non-security engineer can act on.

STRIDE is the most widely used framework for structuring that pass because it’s a checklist, not a philosophy: for every place data crosses a trust boundary, ask whether an attacker could achieve Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, or Elevation of privilege. This article walks the framework against a stack familiar to anyone running a small self-hosted service: a reverse proxy in front of a web app, backed by Postgres, Redis, and S3-compatible object storage, in Docker Compose behind Cloudflare.

The Stack and Its Data Flow

Before applying STRIDE, describe the system precisely enough that “where does data cross a trust boundary” has a concrete answer.

Components:

  • Cloudflare — DNS, CDN, and the first hop of TLS termination from the public internet
  • Reverse proxy (Traefik) — routes by hostname, terminates TLS to the origin, applies auth/rate-limit middlewares
  • Web app — the application container; holds session logic, business logic, and talks to every backing store
  • Postgres — primary relational data store; holds user accounts, application records
  • Redis — session cache and job queue; holds ephemeral, still-sensitive data (session tokens, rate-limit counters)
  • Object storage (S3-compatible / MinIO) — user-uploaded files, served via signed URLs

Data flow, described as a sequence (this is the “diagram,” rendered in text since STRIDE cares about the flow, not the box art):

[Browser] --HTTPS--> [Cloudflare] --HTTPS--> [Traefik] --HTTP (internal network)--> [Web App]
                                                                                       |
                                                    +----------------------------------+----------------------------------+
                                                    |                                  |                                  |
                                             [Postgres]:5432                    [Redis]:6379                  [Object Storage]:9000
                                          (internal network only,          (internal network only,        (internal network for writes,
                                           credentialed)                    no external exposure)          signed URLs for public reads)

Two trust boundaries matter most here: the boundary between the public internet and Cloudflare/Traefik (anyone can send a request), and the boundary between the web app and its backing stores (only the web app should ever reach Postgres, Redis, or object storage directly — a compromise of any one of those services should not automatically mean compromise of the others).

That second boundary is enforced (or isn’t) by how the Docker Compose networks are drawn, not by application code:

# docker-compose.yml - the data tier has no route to the public internet
# and is unreachable from anything outside this compose project
services:
  proxy:
    networks: [edge, app-tier]
  web:
    networks: [app-tier, data-tier]
  postgres:
    networks: [data-tier]        # never on edge or app-tier's public path
  redis:
    networks: [data-tier]
  object-storage:
    networks: [data-tier]

networks:
  edge:        # only the proxy touches the internet-facing network
  app-tier:    # proxy <-> web app only
  data-tier:   # web app <-> stores only; no ports: mapping to the host
    internal: true

Spoofing: Is the Identity of Each Party Verifiable?

Spoofing is about impersonation — a party (user, service, or component) claiming to be someone or something it isn’t.

  • Browser to Cloudflare/Traefik: could an attacker impersonate a legitimate user session? Yes, if session tokens are predictable, if there’s no CSRF protection on state-changing requests, or if the login flow lacks rate limiting against credential stuffing.
  • Traefik to Web App: could an internal service impersonate Traefik and talk to the web app directly, bypassing proxy-level controls (rate limiting, security headers, auth gates)? Only if the web app is reachable from outside the internal Docker network — it shouldn’t be.
  • Web App to Postgres/Redis/Object Storage: could a compromised, unrelated container on the same host impersonate the web app and connect directly to these stores? Yes, if they share a Docker network with everything else and use static, widely-known credentials.

Tampering: Can Data Be Modified in Transit or at Rest?

  • In transit, Browser to Cloudflare: mitigated by TLS, assuming HSTS is enforced and certificate validation isn’t disabled anywhere in the client tooling.
  • In transit, internal network: typically plaintext HTTP/TCP inside the Docker bridge network. An attacker who compromises any container on that network can tamper with traffic between others unless it’s segmented.
  • At rest, Postgres: could an attacker with filesystem access to the data volume modify records directly, bypassing application validation and audit logging entirely?
  • At rest, Object Storage: could stolen credentials overwrite an existing object if bucket policies don’t distinguish “write new” from “overwrite/delete existing”?

Repudiation: Can an Action Be Denied After the Fact?

Repudiation asks whether you could prove, after an incident, who did what.

  • Admin actions in the Web App: if actions like deleting a user or issuing a refund aren’t logged with an actor identity and timestamp, an internal actor can plausibly deny having done something destructive.
  • Database writes: does Postgres, or only the application layer, record who changed a row? A shared database role with no row-level audit trail means a bug or compromised instance can make changes with no attribution.
  • Object Storage writes: does the storage layer log object-level access, or only aggregate bucket-level metrics?

Information Disclosure: What Should Never Leak?

  • Error responses: do stack traces, SQL errors, or internal hostnames ever reach the client? A frequent, low-effort leak that hands an attacker your internal topology for free.
  • Object Storage signed URLs: are signed URLs scoped to a single object with a short expiry, or do they grant broader bucket access, or use expiries measured in days?
  • Redis: does Redis hold session tokens or PII in plaintext, and is it reachable from anywhere other than the web app’s own network segment? An exposed Redis instance with no requirepass is a well-known, still-common way for an entire session store to leak.
  • Backups: are Postgres backups (and any object storage snapshot/export) encrypted at rest, and is access to the backup storage location itself as tightly controlled as the primary database?

Denial of Service: What Happens Under Load or Abuse?

  • Public endpoints: is there rate limiting at the proxy layer (see the reverse-proxy hardening practices) before requests reach the application’s connection pool?
  • Expensive queries: can an unauthenticated or low-privilege endpoint trigger an expensive Postgres query (an unindexed search, a large report generation) that degrades the database for every tenant?
  • Redis as a queue: can a flood of low-value jobs (e.g., password reset requests, webhook retries) starve Redis-backed queues of capacity needed for higher-priority work?
  • Object Storage: is there a cap on upload size and rate per user, or could a single account fill the bucket and drive up cost / exhaust capacity for everyone else?

Elevation of Privilege: Can a Lower-Trust Actor Gain Higher-Trust Access?

  • Web App to Postgres: does the application connect with a single database role that has full read/write on every table, or with a scoped role reflecting what that code path actually needs? A single SQL injection bug on a low-privilege feature shouldn’t grant access to admin tables if the connecting role never had that grant in the first place.
  • Container escape: if an attacker achieves code execution inside the web app container (via a dependency vulnerability, for instance), can they reach the Docker socket, host filesystem, or other containers’ data volumes because of an overly permissive mount?
  • Object Storage credentials: does the web app’s storage credential have delete/admin permissions on the bucket when it only ever needs to read and write specific object prefixes?

Scoping the Postgres side is a few lines of SQL that directly limit the blast radius of the next injection bug in a low-privilege feature:

-- A scoped role for the web app's everyday, user-facing writes:
-- no DDL, no access to the admin schema, nothing beyond what it needs.
CREATE ROLE app_user_writer LOGIN PASSWORD 'use-a-secrets-manager-value';
GRANT CONNECT ON DATABASE appdb TO app_user_writer;
GRANT USAGE ON SCHEMA public TO app_user_writer;
GRANT SELECT, INSERT, UPDATE ON public.orders, public.profiles TO app_user_writer;
-- Explicitly no GRANT on public.admin_settings, public.audit_log, or any DDL.

-- A separate, read-only role for reporting/analytics code paths
CREATE ROLE app_reporting LOGIN PASSWORD 'use-a-secrets-manager-value';
GRANT CONNECT ON DATABASE appdb TO app_reporting;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_reporting;

The Mitigation Table

Turning the questions above into a prioritized list is the actual deliverable — not the STRIDE categories themselves, which are just the prompt.

STRIDE categoryConcrete risk in this stackMitigation
SpoofingPredictable or long-lived session tokens allow session hijackingUse cryptographically random session IDs, short-lived access tokens, and rotate on privilege change (login, password reset)
SpoofingInternal services reachable from outside the Docker networkDo not publish ports for Postgres/Redis/object storage; keep them on an internal-only Docker network with no ports: mapping
TamperingPlaintext internal traffic on a shared Docker networkSegment networks per trust tier (proxy tier, app tier, data tier); consider mTLS between app and data stores for higher-sensitivity deployments
TamperingObject storage allows overwrite/delete with the same credential used for normal uploadsUse separate credentials or bucket policies for write-new vs. overwrite/delete; enable versioning so an overwrite is recoverable
RepudiationAdmin actions not attributable to an authenticated actorEmit structured audit logs (actor id, action, timestamp, before/after state) for every state-changing admin action, shipped to storage the app itself can’t modify
RepudiationShared database role for all application writesWhere feasible, tag writes with an application-level actor id column and enforce it via application logic, since Postgres role-level attribution alone won’t cover this
Information DisclosureStack traces or internal errors reach the clientReturn generic error responses to clients; log full detail server-side only, behind the trust boundary
Information DisclosureLong-lived or overly broad signed URLs for object storageScope signed URLs to a single object, with expiry measured in minutes, generated per request
Information DisclosureRedis reachable without authenticationSet requirepass (or ACL-based auth), bind to the internal network only, and never expose it via a published port
Denial of ServiceNo rate limiting before requests reach the appEnforce rate limiting at the proxy layer, tuned per route based on expected legitimate traffic
Denial of ServiceUnbounded expensive queries or uploadsCap upload size/rate per account; add query timeouts and pagination limits; index columns used in user-facing filters
Elevation of PrivilegeSingle, over-privileged database role for the whole appCreate scoped roles per logical function (read-only reporting role, limited write role for user-facing writes); avoid a superuser role in application code
Elevation of PrivilegeOverly permissive container mounts (Docker socket, host paths)Never mount the Docker socket into an internet-facing container; use read-only mounts and drop capabilities by default
Elevation of PrivilegeStorage credential has delete/admin scope it doesn’t needScope object storage credentials to specific prefixes and the minimal action set (PutObject, GetObject) the app actually performs

Making This Repeatable

A single threat-modeling session is a snapshot; the value compounds when it becomes a habit tied to architectural change, not a one-time document.

  • Re-run the STRIDE pass whenever a new component joins the data flow (a new queue, integration, or public endpoint), not just during an annual review.
  • Keep the mitigation table a living backlog with owners and status — a threat model that changes nobody’s next sprint didn’t do its job.
  • Prioritize by likelihood and impact rather than fixing every row at once; a trivial network misconfiguration outranks a quarter-long mTLS project.
  • Involve whoever operates the infrastructure, not only whoever writes application code — network segmentation and credential scoping are infrastructure decisions, not code changes.

Key Takeaways

  • STRIDE is a checklist applied at each trust boundary in a data flow, not an abstract methodology — describe the actual components and connections first, then walk each category against them.
  • Spoofing and elevation of privilege both come back to the same root cause in most self-hosted stacks: internal services and database roles that are more reachable or more privileged than any single code path actually requires.
  • Tampering and information disclosure risks concentrate at points where credentials, signed URLs, or error messages are broader or longer-lived than necessary.
  • Repudiation is usually the most neglected category — attributable, tamper-resistant audit logs for state-changing actions are cheap to add early and expensive to reconstruct after an incident.
  • Denial of service mitigations belong at the proxy layer first (rate limiting) and the data layer second (query timeouts, upload caps), so abusive traffic is absorbed before it reaches the most expensive part of the stack.
  • The deliverable of a threat-modeling session is a prioritized mitigation table with owners, not a diagram — treat the diagram as scaffolding for the conversation, not the output.
  • Re-run the exercise whenever the architecture changes; a threat model that isn’t revisited drifts out of sync with the system it describes within a few releases.