---
key: "secure-design-patterns"
title: "Secure design patterns: a catalogue for insecure-design (A06)"
url: "https://softwaresecurity.com/domains/appsec/threat-modeling/secure-design-patterns/"
api: "https://softwaresecurity.com/api/v1/articles/secure-design-patterns.json"
domain: "appsec"
topic: "Application security"
type: "Reference"
identifiers: ["CWE-657","CWE-862","CWE-863","CWE-636","CWE-1188","A06:2025","A01:2025","V1.2","V1.3"]
tags: ["Access control","Multi-tenancy","Input validation and parsing","Threat modeling","Secure defaults and frameworks"]
stages: ["design"]
roles: ["appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Secure design patterns: a catalogue for insecure-design (A06)

A catalogue of reusable secure-design patterns, grouped by concern, each with when to use it, a short example and the failure it prevents: choke-point authorization, capability tokens, parse-don't-validate, safe-by-default APIs, tenant scoping, fail-closed, idempotency, and more.

Canonical URL: https://softwaresecurity.com/domains/appsec/threat-modeling/secure-design-patterns/

## In short

Insecure design (OWASP A06:2025) is a missing or flawed control at the design level that no amount of careful coding fixes later. Secure design patterns are reusable answers to recurring design problems. The load-bearing ones: route every sensitive operation through a single choke point that authorizes it (deny by default); hand out capability tokens (unguessable, scoped, expiring handles) instead of trusting identifiers; parse untrusted input into a typed value once at the boundary rather than validating it repeatedly; build safe-by-default APIs where the easy path is the safe path; and scope every query to the tenant from the session, with row-level security as a backstop. Use the catalogue as a checklist in a design review.

- Insecure design is a class of its own (A06:2025): the control was never designed in, so pen-testing the implementation won't find it.
- Choke-point authorization: one place every sensitive action passes through, deny by default, so you can't forget a check on a new endpoint.
- Capability tokens: give out an unguessable, scoped, expiring handle instead of an id the caller can tamper with, so possession is the authorization.
- Parse, don't validate: turn untrusted input into a typed domain value once at the boundary, so downstream code cannot receive an unchecked value.
- Safe by default: design APIs so the obvious call is the safe one (parameterized queries, auto-escaping templates, secure defaults) rather than relying on everyone remembering the safe option.
- Tenant scoping: derive the tenant from the authenticated session, scope every query to it, and add database row-level security as defense in depth.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Authorization placement | A single choke point, deny by default | New code is denied until explicitly allowed, so a forgotten check fails closed. | Saltzer &amp; Schroeder: complete mediation, fail-safe defaults |
| References to objects | Capability tokens (unguessable, scoped, expiring) where possible | Possession authorizes; a signed URL is the common form. | OWASP Secure Product Design Cheat Sheet |
| Untrusted input | Parse into a typed value once at the boundary | Downstream code receives only the validated type, not a raw string. | Parse, don't validate (Alexis King) |
| API ergonomics | The easy path is the safe path | Parameterized queries, auto-escaping templates, Trusted Types, secure defaults. | OWASP Secure Product Design Cheat Sheet |
| Multi-tenant data | Tenant from the session; scoped repository; RLS as a backstop | PostgreSQL `FORCE ROW LEVEL SECURITY` also binds the table owner. | PostgreSQL row security policies |
| Failure behavior | Fail closed | An error or an unknown state denies access rather than allowing it. | Saltzer &amp; Schroeder: fail-safe defaults |
| Privilege | Least privilege, separated by component | Each component holds only the authority it needs; a dangerous action needs separation of duties. | Saltzer &amp; Schroeder: least privilege |

## Verify it

1. Trace a sensitive operation through the design and confirm it passes through the single authorization choke point; add a test that a new endpoint with no explicit allow is denied.
2. Find a place that trusts a client-supplied identifier and confirm it either authorizes the object against the caller or uses a capability token instead.
3. Pick an untrusted input and confirm it is parsed into a typed value once at the boundary, and that downstream functions take that type, not a raw string.
4. Review the data-access APIs and confirm the obvious call is the safe one (parameterized, auto-escaping); flag any API where the easy path is the unsafe path.
5. For a multi-tenant table, confirm every query is scoped to the session tenant and that row-level security denies by default if the application filter is forgotten.
6. Inject an error into an authorization or validation step and confirm the system denies rather than allows.

## What you will learn

- Recognize when each pattern applies and what failure it prevents.
- Apply choke-point authorization, capability tokens and parse-don't-validate in a design.
- Make the safe path the default path in the APIs you build.
- Use the catalogue as a checklist in a design review.

Audience: Engineers and architects designing or reviewing systems, and anyone running a design review against OWASP A06 Insecure Design.

Before you start:

- Have designed or reviewed an application or service.
- Know the classic security principles (least privilege, defense in depth).

Time: About 29 minutes: 7 to read, 2 on the code and 20 for the exercise

## Why design patterns, not just secure coding

OWASP added Insecure Design in 2021 (A06 in the 2025 edition) to name failures that live in the design, not the code: a missing authorization choke point, a workflow with no abuse case, a reference that trusts an identifier. You cannot fix these by writing the same flawed design more carefully, which is why they need patterns applied before and during design, and checked in design review. The patterns here restate Saltzer and Schroeder's 1975 principles, least privilege, fail-safe defaults, complete mediation, economy of mechanism, in forms you can drop into a modern system, and complement the method guide at /domains/appsec/threat-modeling/threat-modeling-methods/ and the worked models for a mobile app (/domains/appsec/threat-modeling/mobile-banking-threat-model/) and an LLM agent (/domains/appsec/threat-modeling/llm-agent-threat-model/).

Use the summary table as a design-review checklist, then read the pattern sections for the detail.

| Pattern | Use it when | Prevents |
| --- | --- | --- |
| Choke-point authorization | Any system with sensitive actions | Missing or inconsistent access checks |
| Capability token | Sharing access to a specific object | IDOR / object-reference tampering |
| Parse, don't validate | Any untrusted input | Unchecked values reaching logic; re-validation drift |
| Safe-by-default API | Building an API others call | Injection and escaping mistakes at every call site |
| Tenant scoping | Multi-tenant data | Cross-tenant data access |
| Fail closed | Any auth or validation step | Errors that grant access |
| Least privilege / separation | Components and dangerous actions | Over-broad blast radius; single-actor abuse |
| Idempotency + state machine | Money and workflow operations | Double-spend and step-skipping |

## Choke-point authorization (deny by default)

Route every sensitive operation through one authorization component rather than scattering checks across endpoints. The choke point decides against the authenticated subject, and the default is deny: a new action is refused until someone explicitly grants it, so forgetting a check fails closed (complete mediation and fail-safe defaults). This is the design behind the tool gateway in the LLM-agent model and the policy enforcement point in a service mesh. It prevents the broken-access-control failures that top the risk lists, because there is one place to get right and one place to test.

```js
// Deny by default: the route table says nothing is public unless listed,
// and one middleware authorizes every request against the subject.
const POLICY = {
  "GET /orders/:id": (user, req) => owns(user, req.params.id),
  "POST /orders": (user) => user.canCreateOrders,
};

function authorize(req, user) {
  const rule = POLICY[`${req.method} ${req.route}`];
  if (!rule) return false;        // unknown route => denied, not allowed
  return rule(user, req);
}
```

## Capability tokens (object capabilities)

When you need to grant access to a specific object, hand out an unguessable, scoped, expiring token instead of an identifier the caller can change. Possessing the token is the authorization; there is no id to tamper with, so there is no insecure-direct-object-reference. A signed download URL is the everyday form: it names exactly one object, expires, and cannot be edited to point at another. Keep the token's scope narrow (one object, one action) and its lifetime short, and verify it server-side.

```python
import hmac, hashlib, time, base64

SECRET = get_secret("capability-key")

def issue(object_id: str, action: str, ttl_s: int = 300) -> str:
    exp = int(time.time()) + ttl_s
    msg = f"{object_id}:{action}:{exp}".encode()
    sig = hmac.new(SECRET, msg, hashlib.sha256).digest()
    return base64.urlsafe_b64encode(msg + b"." + sig).decode()

def verify(token: str, action: str) -> str | None:
    raw = base64.urlsafe_b64decode(token)
    msg, sig = raw.rsplit(b".", 1)
    if not hmac.compare_digest(sig, hmac.new(SECRET, msg, hashlib.sha256).digest()):
        return None                      # tampered or forged
    object_id, tok_action, exp = msg.decode().split(":")
    if tok_action != action or int(exp) < time.time():
        return None                      # wrong action or expired
    return object_id                     # possession authorizes this exact object
```

## Parse, don't validate

Validation checks a value and throws it back as the same loose type, so the next function has to trust that someone checked it. Parsing turns untrusted input into a specific type that can only hold valid values, once, at the boundary; after that the type system carries the guarantee and downstream code cannot be handed an unchecked value. Alexis King's 2019 essay named the pattern. In practice it means a constructor or schema that returns an `EmailAddress` or a `PositiveQuantity`, not a bare string or number, and functions that take those types.

```python
# Parse once at the boundary into a type that cannot hold an invalid value.
from dataclasses import dataclass


@dataclass(frozen=True)
class Quantity:
    value: int

    def __post_init__(self):
        if not (1 <= self.value <= 1000):
            raise ValueError("quantity out of range")


def parse_quantity(raw: object) -> Quantity:
    if not isinstance(raw, int):
        raise TypeError("quantity must be an integer")
    return Quantity(raw)  # downstream code takes Quantity, never a raw int
```

## Safe-by-default APIs

Design the APIs other developers call so the obvious, easy path is the safe one, and the unsafe path is hard or absent. Parameterized query builders that make string concatenation awkward, templating that auto-escapes by default and requires an explicit opt-out to emit raw HTML, Trusted Types that refuse unsafe DOM sinks, and framework settings that ship secure all push the default toward safety. This is economy of mechanism applied to ergonomics: you cannot rely on every developer remembering the safe option, so remove the choice. When you must offer an unsafe escape hatch, name it loudly (`dangerouslySetInnerHTML`, `unsafe_`, `raw`) so it stands out in review.

- Parameterized queries by default; a raw-SQL path that is clearly marked and rare.
- Auto-escaping templates; raw output requires an explicit, greppable call.
- Secure framework defaults (CSRF on, secure cookies, strict parsers) so a new app is safe before anyone configures it.
- Constructors that validate, so an object cannot exist in an invalid state.

## Tenant scoping with a backstop

In a multi-tenant system, derive the tenant from the authenticated session, never from a request parameter, and scope every query to it in a repository layer so no query can forget the filter. Then add database row-level security as defense in depth: if an application query omits the tenant predicate, the database still returns nothing. In PostgreSQL, enable RLS and write a policy against a session setting; use `FORCE ROW LEVEL SECURITY` so even the table owner is subject to it, and remember superusers and `BYPASSRLS` roles are never subject to policies. The multitenant model at /domains/appsec/threat-modeling/multitenant-design-review/ works a full example.

```sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;   -- the table owner is subject to policy too

-- current_setting(..., true) returns NULL (no rows) instead of erroring if unset.
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id', true)::int);

-- The app sets the tenant from the session, not from the request, per transaction:
SET app.tenant_id = '42';
SELECT * FROM orders;   -- only tenant 42's rows, even if the app's WHERE is wrong
```

## Fail closed, least privilege and separation of duties

Three principles from 1975 that are still the most common design gaps. Fail closed: when an authorization or validation step errors or hits an unknown state, deny, don't allow; an exception in a permission check must not let the request through. Least privilege: give each component, credential and token only the authority it needs, so a compromise reaches as little as possible; this is why the LLM-agent model scopes each tool's credential. Separation of duties: a high-value action (a large transfer, a production deploy, a role grant) requires more than one actor or a step-up, so a single compromised account cannot complete it alone.

## Idempotency keys and explicit state machines

Business flows fail through repetition and out-of-order steps, not injection. Give every state-changing operation an idempotency key so a retried or replayed request completes once, not twice (the double-spend case). Model a multi-step flow as an explicit state machine with allowed transitions, so a request to a later step fails unless the earlier steps actually happened, which closes step-skipping and workflow-bypass abuse. The business-logic guide at /domains/appsec/business-logic/business-logic-flaws/ and /domains/appsec/business-logic/transaction-races/ cover the implementation; as a design pattern, the point is to decide the states and transitions up front rather than letting them emerge.

## More patterns worth naming in a review



- Egress allowlist proxy: outbound calls go through a proxy that only reaches approved destinations, containing SSRF and exfiltration (/domains/appsec/secure-features/ssrf-egress-boundaries/).
- Workload identity over static keys: services authenticate with short-lived, attested identities instead of long-lived secrets (/domains/identity-crypto/identity/workload-identity-models/).
- Sandboxing and isolation: run untrusted processing (user code, model output execution, file parsing) in a confined environment with no ambient authority.
- Immutable audit trail: append-only logging of security-relevant actions so an incident is reconstructable and repudiation is hard.
- Rate limits and quotas as design elements: decide the limits per actor and action at design time, not as an afterthought.
- Defense in depth, honestly: layer independent controls, but check the layers really are independent, because two controls that share an assumption fail together.

## Guided practice

You are running a design review of a new document-sharing feature for a multi-tenant SaaS. The design summary below is what the team proposes. Identify which secure-design patterns are missing or misapplied and redesign the weak spots.

### Supplied material

```text
Design summary:
- Each API endpoint does its own authorization check inline; a new 'bulk export' endpoint was just added.
- A shared document is accessed at GET /documents/{id}; the handler returns the document if it exists.
- The tenant id is taken from a request header X-Tenant-Id so the mobile and web clients can set it.
- Queries are built by string concatenation in each handler; "we review them in code review."
- If the authorization service times out, the request is allowed through "to avoid downtime."
- A workflow lets a user request, then approve, then disburse a payout; each step is its own endpoint.
```

### 1. Centralize authorization

Name the missing pattern and fix the inline checks and the new endpoint.

Worked answer: Choke-point authorization with deny by default is missing. Inline checks per endpoint mean the new bulk-export endpoint can easily ship with no check. Route every request through one authorization component that denies unknown actions by default, and test that a new endpoint is denied until explicitly allowed.

### 2. Fix the object reference

Explain the problem with `GET /documents/{id}` and apply a pattern.

Worked answer: Trusting the id is an IDOR. Either authorize the document against the caller at the choke point, or hand out a capability token (an unguessable, scoped, expiring share link) so there is no id to tamper with.

### 3. Fix tenant scoping

Explain why `X-Tenant-Id` is wrong and give the design.

Worked answer: Taking the tenant from a client-set header lets a caller choose another tenant. Derive the tenant from the authenticated session, scope every query to it in a repository layer, and add PostgreSQL row-level security (`FORCE`) as a backstop so a forgotten filter still returns nothing.

### 4. Make the data API safe by default and fail closed

Fix the string-concatenated queries and the timeout behavior.

Worked answer: String concatenation relies on everyone remembering to be careful; use a parameterized query API where the safe path is the default and raw SQL is a marked exception. And allowing requests through when the authorization service times out fails open; fail closed and deny on error.

### 5. Design the payout flow

Apply patterns to the request/approve/disburse workflow.

Worked answer: Model it as an explicit state machine so disburse is reachable only after approve, which was reachable only after request; add an idempotency key so a replayed disburse pays once; and require separation of duties so the requester cannot also approve a payout above a threshold.

Deliverable: A marked-up design showing the choke point, capability tokens or object authorization, session-derived tenant scoping with RLS, a safe-by-default data API, fail-closed behavior, and the payout flow as a state machine with idempotency and separation of duties.

Scope: The exercise reviews a design summary on paper. It does not run the system or prove the patterns are implemented correctly; a real review pairs this checklist with threat modeling and with tests that confirm each pattern holds in the built system.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Top 10:2025 A06 Insecure Design](https://top10.owasp.org/2025/A06_2025-Insecure_Design/)
- [OWASP Secure Product Design Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secure_Product_Design_Cheat_Sheet.html)
- [Saltzer and Schroeder, The Protection of Information in Computer Systems (1975)](https://web.mit.edu/Saltzer/www/publications/protection/)
- [Alexis King: Parse, don't validate (2019)](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/). 5 November 2019.
- [PostgreSQL: row security policies (RLS, FORCE, BYPASSRLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html). PostgreSQL 18.
- [CWE-657: Violation of Secure Design Principles](https://cwe.mitre.org/data/definitions/657.html). CWE 4.20.
- [CWE-1188: Initialization of a Resource with an Insecure Default](https://cwe.mitre.org/data/definitions/1188.html). CWE 4.20.
- [CISA: Secure by Design](https://www.cisa.gov/resources-tools/resources/secure-by-design)
- [OWASP Application Security Verification Standard 5.0.0, V1 Encoding and Sanitization](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x10-V1-Encoding-and-Sanitization.md). Version 5.0.0.
- [Google: Building Secure and Reliable Systems, online edition (chapters 5 and 6: least privilege, understandability)](https://google.github.io/building-secure-and-reliable-systems/raw/toc.html)