StatistaAmeri/ADVERSARIAL-ARCHITECTURE-RE...

361 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🎯 Adversarial Architecture Review
### Closing the gaps a checklist can't see — written from the other side of the keyboard
> The previous checklist hardens each **component**. This document hardens the **seams between them** — the trust relationships, the assumptions, the one boundary you forgot. That's where real attacks live. A checklist asks "is this function safe?" An attacker asks "which of these 40 functions did you forget to protect, and what does owning it get me?"
>
> This is written attacker-first: for each gap, *here's how I'd come at it* → *here's the architectural control that shuts the door*. Every attack is paired with a fix. Nothing here is operational against anyone else's system — it's a threat model of **your** system so you can close it.
---
## 0. The mindset shift
A checklist is a **list of doors to lock**. An architecture review is **assuming I'm already walking your hallways and asking what I can reach**.
Three principles drive everything below:
1. **The client is enemy territory.** Anything that runs in or comes from a browser is something I control completely. Cookies, JWTs, headers, request bodies, the `user_id` field, the price — all forgeable. If a security decision happens in the browser, it doesn't happen.
2. **Defense in depth or it's not defense.** One auth check protecting your data means one forgotten check = breach. Real security is layers where **each layer assumes the one in front of it already failed.**
3. **Assume breach.** I *will* get a foothold somewhere — a leaked key, an XSS, a dependency. The question your architecture answers is: *then what?* How far do I get, how fast do you notice, how quickly can you kill it. Most vibe-coded apps have no answer because they were designed assuming the wall holds.
---
## 1. How I actually approach your system
I don't start by looking for "an XSS." I start with reconnaissance and a map. The questions I ask, in order:
- **What's the stack?** Response headers, JS bundle, `/_next/`, error pages, favicon hashes, DNS records — they tell me you're Next.js on Vercel with Supabase and Stripe before I've sent a single malicious byte. Now I know exactly which default mistakes to test.
- **Where's the data, and can I reach it directly?** If I see a Supabase anon key in your JS (I will), your database is *on the internet*. I don't need to beat your app — I can query Postgres directly and see if RLS is off.
- **Is identity proven or claimed?** I log in, watch the network tab, and look for any request where the server trusts a `user_id`, `role`, `account_id`, or `isAdmin` that *I* supplied. If changing it changes the result, you're done.
- **What costs you money when I press a button?** Every LLM call, SMS, email, image generation, and export is a way for me to drain your wallet for free.
- **What's the blast radius of one foothold?** If I get one key or one bug, do I get everything, or one small thing? This tells me whether you're worth my time.
- **Will you even notice?** No logging, no alerts, no rate limits = I can take my time, brute-force, enumerate, and exfiltrate at my leisure.
**The architectural defense against recon:** give away as little as possible (Section 6), and make the answers to those questions *boring* — data behind a deny-by-default boundary, identity proven server-side, money gated, blast radius contained, and me visible the moment I act.
---
## 2. Map your trust boundaries (the most important diagram you will ever draw)
90% of breaches happen at a **trust boundary** — the line where untrusted input crosses into a zone that trusts it. If you can't draw these lines, you can't defend them. Here is the map of a typical vibe-coded app, with every boundary I attack:
```
┌──────────────────────────────────────────────────────────────────────┐
│ ZONE 0 — FULLY HOSTILE (you control nothing here) │
│ Browsers · mobile apps · curl · bots · my laptop │
│ Every byte from here is attacker-controlled: cookies, JWTs, headers, │
│ request bodies, "user_id", "role", prices, quantities — all forgeable │
└────────────────────────────────┬─────────────────────────────────────┘
│ ◄══ BOUNDARY #1 (the big one)
│ Everything crossing is a LIE until
│ your server re-proves it.
┌────────────────────────────────▼─────────────────────────────────────┐
│ ZONE 1 — EDGE (the first thing you control) │
│ CDN · WAF · DDoS protection · bot management · TLS termination │
│ Job: drop obvious garbage before it reaches — and costs — your code. │
└────────────────────────────────┬─────────────────────────────────────┘
│ ◄══ BOUNDARY #2
┌────────────────────────────────▼─────────────────────────────────────┐
│ ZONE 2 — APPLICATION (your code) │
│ gateway → AUTHN (prove identity) → AUTHZ (prove permission) │
│ → input validation → business logic │
│ Rule: identity is ESTABLISHED here from a verified session — NEVER │
│ read from a field the client supplied. │
└──────────────┬────────────────────────────────┬──────────────────────┘
│ ◄══ BOUNDARY #3 │ ◄══ BOUNDARY #4
│ (to your data) │ (to third parties)
┌──────────────▼──────────────┐ ┌────────────▼─────────────────────┐
│ ZONE 3 — DATA │ │ ZONE 4 — EXTERNAL │
│ Postgres / Supabase (RLS) │ │ Stripe · LLM API · email · S3 │
│ Object storage │ │ Each = a separate trust domain │
│ Job: DENY BY DEFAULT, even │ │ with its own key & blast radius │
│ when the app layer failed. │ │ Inbound (webhooks) must be │
│ │ │ signature-verified. │
└──────────────┬──────────────┘ └──────────────────────────────────┘
│ ◄══ BOUNDARY #5 (the one everyone forgets)
┌──────────────▼────────────────────────────────────────────────────────┐
│ ZONE 5 — SECRETS & CONTROL PLANE │
│ Env vars · secrets manager · cloud IAM · CI/CD · DNS · your GitHub │
│ Compromise here = game over. Smallest zone, highest value, weakest │
│ protection in most vibe-coded projects. │
└────────────────────────────────────────────────────────────────────────┘
```
**Do this for your real system.** List every arrow that crosses a boundary. For each one, write down: *what does the receiving side assume about this input, and what happens if I violate that assumption?* That list is your actual attack surface — far more useful than any generic checklist.
---
## 3. The core architectural sins (these create whole *classes* of bugs)
Individual vulnerabilities are symptoms. These seven design decisions are the disease. Fix the architecture and entire categories of bugs become impossible instead of "remembered."
### Sin #1 — The client is treated as trusted
**How I exploit it:** I open DevTools and edit anything — the price in the cart, the `user_id` in the request, the `role: "user"` in the signup body, the disabled "admin" button. Client-side validation, client-set prices, and hidden-not-protected features all fall in seconds.
**The fix is architectural, not a patch:** The browser is a **rendering layer with zero authority**. Every decision — who you are, what you may do, what something costs, whether an action is allowed — is *re-made server-side* from server-held state. The client's job is to display and to submit intent; the server decides everything that matters.
### Sin #2 — Your database is on the internet and you didn't know
**How I exploit it:** Supabase/Firebase hand the browser a key that talks *directly* to the database. That's not a bug — it's the model. But it means your DB is a public API. I take the anon key from your JS and `curl` your tables directly, bypassing your app entirely. If RLS is off (the silent default risk), I read everything.
**The fix:** Decide consciously which model you're in. Either (a) **RLS makes the public-DB model safe** — deny by default, every table, policies scoped to `auth.uid()` — or (b) you **never expose the anon key** and route all data access through your own backend. Pick one on purpose. The disaster is doing (a)'s exposure with (b)'s assumptions.
### Sin #3 — Identity is claimed, not proven
**How I exploit it:** The single most common vibe-coded flaw. The server reads `user_id` / `account_id` / `org_id` from the URL, query string, or body and trusts it. I change it to yours and read your data (horizontal escalation), or set `role=admin` (vertical escalation).
**The fix:** Identity is **derived exclusively from the authenticated session server-side**, never from client input. The request says *what* to do; the *who* comes from the verified token only. Every resource access then checks `session.user owns/may-access this resource` as a **separate** step from authentication.
### Sin #4 — One layer of defense
**How I exploit it:** If the only thing between me and your data is an `if (loggedIn)` in one route handler, I only need to find the one route where you forgot it — or the one injection that skips it. One miss = total exposure.
**The fix:** **Layer it so my single win isn't enough.** Auth bypass in the app? RLS at the database still returns nothing for the wrong user. SQL injection? The least-privilege DB user can't read the tables it doesn't need or `DROP` anything. XSS slips through? CSP limits what the injected script can do. Each layer assumes the previous one failed (see the stack in Section 4).
### Sin #5 — Flat blast radius
**How I exploit it:** I get *one* foothold — a leaked key, an SSRF, an RCE in a dependency — and because everything runs in one process with one `.env` holding the DB service-role key, the Stripe secret, *and* the OpenAI key, I now own all of it. One door opens the whole building.
**The fix:** **Segment by blast radius.** Least privilege per component: the public web tier doesn't hold the DB admin key; the LLM-calling worker can't read the users table; the service that sends email can't touch payments. Separate keys, separate scopes, separate failure domains. One foothold should yield one small thing.
### Sin #6 — No idempotency or atomicity
**How I exploit it:** I fire 50 simultaneous requests to redeem a one-time coupon, withdraw a balance, or claim limited inventory. Between your "check" and your "use" there's a gap (TOCTOU), so all 50 succeed. Or I replay your Stripe "payment succeeded" webhook ten times and get ten upgrades.
**The fix:** Critical operations are **atomic and idempotent by design** — database constraints and transactions enforce invariants (unique coupon use, non-negative balance), idempotency keys make replays no-ops, optimistic locking handles concurrency. You can't bolt this on per-endpoint; it's how the operation is built.
### Sin #7 — No assume-breach posture
**How I exploit it:** Nothing watches. No rate limits, so I brute-force and enumerate freely. No anomaly alerts, so I exfiltrate 80,000 records overnight and you find out from a customer. No revocation plan, so even after you notice, I'm in for days while you scramble.
**The fix:** Design as if I'm already inside. **Detection** (log security events, alert on anomalies), **containment** (least privilege limits where I can go), and **response** (every secret rotatable in minutes, a kill switch for expensive features, a documented break-glass procedure). Covered in Section 7.
---
## 4. The hardened reference architecture (defense in depth as a stack)
To get from me at the keyboard to your data, I have to defeat **every** layer below. Each one is built assuming the layer in front of it already failed. That's the whole point — redundancy, not a single perfect wall.
```
I (attacker) ──────────────────────────────────────────────────┐
┌────────────────────────────────────────────────────────────┐ │
│ LAYER 1 · EDGE WAF · rate limit · bot mgmt · DDoS │◄┘
│ "Assume malicious traffic reaches me."│
├────────────────────────────────────────────────────────────┤
│ LAYER 2 · TRANSPORT TLS everywhere · HSTS · no mixed │
│ "Assume the network is being tapped." │
├────────────────────────────────────────────────────────────┤
│ LAYER 3 · AUTHN Prove WHO · short sessions · MFA │
│ "Assume a token will get stolen." │
├────────────────────────────────────────────────────────────┤
│ LAYER 4 · AUTHZ Prove you may touch THIS resource │
│ "Assume an auth check was forgotten." │
├────────────────────────────────────────────────────────────┤
│ LAYER 5 · INPUT Validate · parameterize · encode │
│ "Assume every input is an exploit." │
├────────────────────────────────────────────────────────────┤
│ LAYER 6 · DATA RLS · least-priv DB user · deny first │
│ "Assume the query reached me raw." │
├────────────────────────────────────────────────────────────┤
│ LAYER 7 · SECRETS Segmented · least-priv · rotatable │
│ "Assume one secret will leak." │
├────────────────────────────────────────────────────────────┤
│ LAYER 8 · DETECT Logs · anomaly alerts · kill switches │
│ "Assume I got in. See me. Contain me."│
└────────────────────────────────────────────────────────────┘
your data
```
Each layer maps to a concrete owner in a serverless/BaaS stack:
| Layer | Where it lives (Next.js + Supabase + Stripe example) | The job, in one line |
|------|------|------|
| 1 Edge | Cloudflare / Vercel WAF + bot rules | Drop bots, floods, and known-bad before compute is spent |
| 2 Transport | Platform TLS + HSTS header | No plaintext, no downgrade, no mixed content |
| 3 AuthN | Supabase Auth / Clerk / Auth0, short-lived JWT + refresh | Establish identity from a verified session only |
| 4 AuthZ | Your middleware + per-resource ownership checks | Prove permission for *this* object, separately from login |
| 5 Input | Zod/Pydantic validation, parameterized queries, DOMPurify | Treat every input as hostile |
| 6 Data | Postgres RLS + a least-privilege role | Deny by default even if layers 35 were bypassed |
| 7 Secrets | Host env vars / secrets manager, scoped per service | Contain the damage of any single leaked key |
| 8 Detect | Structured logs + alerting (Sentry, Logflare, etc.) | Notice and contain a breach in progress |
**The test of whether you have depth:** pick any one layer, imagine it completely fails, and ask "am I breached?" If the answer is yes for any single layer, you don't have depth there — you have a single point of failure wearing a security costume.
---
## 5. The kill chain — blocked at every stage
This is how an intrusion actually unfolds, start to finish, and the architectural control that breaks the chain at each link. **You only have to break the chain once** to stop the attack — but breaking it in multiple places is what "defense in depth" means in practice.
### Stage 1 · Reconnaissance
- **What I do:** Fingerprint your stack, enumerate endpoints, read error messages, grab source maps, check `/.git/`, harvest the anon key from your JS, run GraphQL introspection, look for predictable IDs.
- **What stops me:** Minimize disclosure (Section 6). Generic errors, no source maps in prod, no `/.git/` exposure, introspection disabled, UUIDs not sequential IDs, no stack/version leakage. Make recon return nothing useful.
### Stage 2 · Initial access / unauthenticated probing
- **What I do:** Hit your API directly with no auth, query your DB with the anon key, spray credential-stuffing lists at login, look for any endpoint that responds with data before checking who I am.
- **What stops me:** Edge WAF + bot management absorbs the spray. Auth boundary returns 401 before any handler logic. RLS returns empty to the anon key. Rate limiting throttles credential stuffing to uselessness.
### Stage 3 · Authentication bypass / session theft
- **What I do:** Steal a JWT via XSS, replay a non-expiring token, reuse a session that "logged out" only client-side, forge a weakly-signed token.
- **What stops me:** Short-lived access tokens + server-side refresh/revocation. Sessions invalidated server-side on logout. `httpOnly` cookies so XSS can't read them. Strong signing secrets. Re-authentication required for sensitive actions (changing email, payment, deleting account).
### Stage 4 · Authorization bypass (IDOR / privilege escalation) — *the money shot*
- **What I do:** Change a resource ID to yours and read your data. Set `role=admin` or `is_verified=true` via mass assignment. Hit an admin route with a normal session.
- **What stops me:** Identity from the session only (never client input). A separate ownership check on every resource (read *and* write) returning 403. Field whitelisting (no mass assignment). Admin checks server-side on every admin route. **And** — because of depth — RLS at the data layer denies cross-user access even if the app check was forgotten.
### Stage 5 · Injection
- **What I do:** SQL injection via concatenated queries, command injection via `exec`, NoSQL operator injection, SSTI, path traversal, XXE.
- **What stops me:** Parameterized queries everywhere (no concatenation). No user input into shell/eval. Validation at layer 5. WAF catches common payloads at the edge. **And** the least-privilege DB user means even successful injection can't read tables it doesn't need or drop anything — depth again.
### Stage 6 · Lateral movement / privilege expansion
- **What I do:** From one foothold, pivot — use the leaked key to reach other services, read other secrets from the shared env, move from the web tier into the data tier, abuse an over-permissioned service account.
- **What stops me:** **Segmentation and least privilege.** Each component holds only the keys it needs, scoped to only what it needs. The LLM worker can't read users; the email service can't touch payments; the web tier doesn't have the DB admin key. My one foothold is a dead end, not a hallway.
### Stage 7 · Exfiltration
- **What I do:** Pull every record via a list endpoint, walk sequential IDs, scrape the whole site, export bulk data, siphon it out slowly to stay under the radar.
- **What stops me:** No endpoint returns "all rows" — pagination caps + per-account volume limits. RLS scopes every read to the user. Anomaly detection flags one client pulling 50,000 records. Egress monitoring. Scraping patterns (sequential walking, high-volume single client) trigger throttling and alerts.
### Stage 8 · Denial of wallet (the modern DoS)
- **What I do:** Spam your LLM agent, trigger thousands of SMS/emails, force expensive image generation, blow up your cloud egress — not to take you down, but to *bankrupt* you.
- **What stops me:** Every paid downstream action is behind a **quota + circuit breaker + hard spend cap** (Section 8). Per-user limits on agent calls, max tokens, daily budgets, and a kill switch that stops calling the expensive service when a threshold trips.
### Stage 9 · Persistence
- **What I do:** Create a hidden admin account, plant a malicious webhook, add an API key, inject a stored payload that runs later (e.g. a poisoned prompt your agent re-executes, or stored XSS), drop an SSH key.
- **What stops me:** Audit logging of every privilege-changing action with alerts (new admin, new key, new webhook → you get pinged). Immutable audit trail. Stored content treated as untrusted on read. Periodic review of accounts, keys, and webhooks.
### Stage 10 · Covering tracks
- **What I do:** Delete or edit logs to hide what I did.
- **What stops me:** Logs shipped **off-box** to an append-only/immutable store I can't reach from the app tier. If the logging sink is in a different trust zone than the thing being logged, I can't clean up after myself.
---
## 6. The seams a checklist misses (integration trust)
Checklists audit boxes. I attack the **arrows between boxes** — every place one component trusts another. These are the highest-value, least-audited targets.
- **Webhook → your DB (Stripe, etc.):** The arrow assumes "this really came from Stripe." I send a forged `payment.succeeded` with `curl`. *Close it:* verify the signature on every webhook (reject 400 on bad/missing), enforce idempotency on event IDs, derive entitlements from verified events only — never a client redirect.
- **AI agent → tools → your data:** The arrow assumes the model's tool calls are safe. I use prompt injection (direct, or hidden in a document the agent reads) to make it call a tool on *your* data or take a destructive action. *Close it:* scope every tool to the current user with least privilege, validate model-generated tool inputs like any user input, require confirmation for destructive actions, sandbox any code execution, and keep authorization in your code — never in the prompt.
- **Upload → storage → served back:** The arrow assumes an uploaded "image" is an image. I upload a polyglot/HTML file and get it served from your origin to run as same-origin script. *Close it:* validate by magic bytes, rename to UUID, re-encode images, and serve from a **separate origin/bucket** so nothing user-uploaded executes in your security context.
- **CDN cache → private data:** The arrow assumes responses are safe to cache. You cache an authenticated response and the CDN serves User A's private data to User B. *Close it:* explicit `Cache-Control: private/no-store` on all authenticated responses, `Vary` correctly, never cache anything keyed to a user at a shared layer.
- **Service → service (internal calls):** The arrow assumes internal callers are trusted because they're "internal." I find an SSRF or get a foothold and call your internal endpoints, which trust a header like `X-User-Id`. *Close it:* internal services authenticate each other (signed requests / mTLS / a shared secret), and **never** trust client-or-peer-supplied identity headers without verification. There is no "internal therefore trusted."
- **SSO / OAuth callback:** The arrow assumes the callback is legitimate. I tamper with `redirect_uri`, skip the `state` check (CSRF), or replay a code. *Close it:* strict `redirect_uri` allowlist, mandatory `state` parameter, PKCE, single-use codes, validate the token audience.
- **DNS / subdomain → hosting:** The arrow assumes your DNS records point where you think. You delete a Vercel/S3/Heroku app but leave the `CNAME`; I claim the dangling target and now host phishing on *your* subdomain. *Close it:* audit DNS for dangling records, remove records when you tear down resources, registrar lock, CAA records, MFA on your DNS provider.
- **WebSocket / real-time:** The arrow assumes the socket is authed like your HTTP routes. It usually isn't — WS connections frequently skip authn/authz entirely. *Close it:* authenticate on connect, authorize every message, rate-limit per connection, scope subscriptions to the user.
- **Email / notifications → the world:** The arrow assumes your send features are used normally. I abuse "invite a friend" or password-reset to email-bomb a victim, or inject headers via a contact form. *Close it:* rate-limit and cap send features per user, validate/encode anything that reaches an email header, and configure SPF/DKIM/DMARC so no one can send *as* your domain.
- **Third-party scripts → your page:** The arrow assumes your analytics/chat/widget vendors are safe. Their compromise becomes yours (supply-chain via the browser). *Close it:* Subresource Integrity on pinned scripts, a strict CSP, minimize third-party JS, and treat every embedded vendor as code running with your users' trust.
---
## 7. Assume breach — blast-radius containment & response
Everything above tries to keep me out. This section accepts that **someday I get in** and asks the only question that matters then: *how bad is it, and how fast can you end it?* This is what separates an app that has "a breach" from one that has "a catastrophe."
**Contain the blast radius (limit where one foothold reaches):**
- [ ] **Secret segmentation.** No single `.env` holds the keys to everything. The DB service-role key, the Stripe secret, and the LLM key live in *different* trust zones, used by *different* components. One leak ≠ total compromise.
- [ ] **Least privilege, everywhere.** Every key, role, and service account can touch only what it needs. Read-only where possible. No `AdministratorAccess` on app roles. The DB user your app connects with can't drop tables or read tables it doesn't use.
- [ ] **Short-lived credentials.** Prefer rotating/temporary tokens over long-lived static keys. A stolen credential that expires in an hour is worth far less than one that lives forever.
- [ ] **Isolation between tenants/users.** One user's compromise (or one user *being* me) can't reach another's data — enforced at the data layer (RLS), not just the app.
**Detect me (assume I'm acting right now):**
- [ ] **Log security events off-box:** auth failures, 403s, rate-limit hits, admin actions, key/webhook creation, spend spikes — shipped to a store the app tier can't edit.
- [ ] **Alert on anomalies:** 100× traffic spike, logins from new geographies, bulk data access, spend crossing a threshold, a new admin or API key appearing. The alert should reach a human (or a kill switch) in minutes, not days.
- [ ] **Baseline normal**, so abnormal is visible. You can't detect "weird" without knowing "normal."
**End it (have the plan before you need it):**
- [ ] **Rotate everything in minutes.** A documented, tested procedure to roll every secret — DB creds, API keys, signing secrets, OAuth secrets. If rotating your DB password would take you a panicked afternoon, you've already lost.
- [ ] **Kill switches.** Feature flags / env toggles that instantly disable expensive or risky surfaces (the AI agent, file uploads, a leaking endpoint) without a redeploy.
- [ ] **Break-glass runbook.** Written steps: how to take the app offline, revoke sessions, rotate keys, preserve evidence (don't destroy the logs you'll need), and notify users. Decide this calm, not mid-incident.
- [ ] **Backups that survive ransomware.** Immutable/offline copies, and a restore you've **actually tested**. A backup you've never restored is a wish.
---
## 8. The economic attack surface (denial of wallet)
A modern attacker doesn't always want to steal your data or take you down — sometimes they just want to **make your bill explode**, for lulz or extortion. Vibe-coded apps are uniquely exposed because they wire up metered services (LLMs, SMS, email, image gen, egress) with no economic guardrails.
**The design principle:** inventory **every action a user can trigger that costs *you* money**, and gate each one. If pressing a button calls a paid API, an attacker pressing it 100,000 times is a denial-of-wallet attack.
- [ ] **Map the cost surface.** Make a literal list: LLM calls, embeddings, SMS, email, image/video generation, third-party API calls, cloud egress, heavy compute jobs. Each line is an attack vector.
- [ ] **Per-user quotas + rate limits** on every one of them. One user cannot consume the whole budget.
- [ ] **Hard spend caps** at the provider (OpenAI/Anthropic/Twilio billing limits) *and* a server-side **circuit breaker** that stops calling out when a daily/hourly threshold trips.
- [ ] **Bounded inputs/outputs:** max input length, max output tokens, max file size, max job duration — so a single request can't be made arbitrarily expensive.
- [ ] **Anomaly alerts on spend**, so a 50× cost spike pages you within minutes instead of arriving as a month-end invoice.
- [ ] **Decompression/zip-bomb and complexity limits** so one cheap request can't trigger ruinous server work.
---
## 9. Atomicity, idempotency & race conditions (business logic at the architecture level)
This is the category AI tools and checklists are *worst* at, because it's about *your* rules and *concurrent* behavior — invisible when you read code top-to-bottom. It's how I get free money without "hacking" anything.
**Race conditions / TOCTOU (time-of-check to time-of-use):**
- **How I exploit it:** I send many requests *simultaneously* so they all pass the "check" before any of them "uses." Redeem a one-time coupon 50 times. Withdraw a $100 balance 50 times. Claim the last 1 inventory item 50 times. Refer-and-reward myself in a loop.
- **The fix is at the data layer, not the handler:** Enforce invariants with **database constraints and transactions** (unique constraint on coupon-use, `CHECK balance >= 0`, atomic decrement with row locking). Optimistic or pessimistic locking on contended rows. The rule can't live in app-level "check then write" — that gap is the vulnerability.
**Idempotency / replay:**
- **How I exploit it:** I replay the same request — a payment confirmation, a webhook, a "claim reward" — and get the effect twice (or N times). Network retries can even trigger this by accident.
- **The fix:** **Idempotency keys** on state-changing operations (the same key = the same single effect, no matter how many times it arrives). Signed + timestamped + nonce'd requests to reject replays. Stored processed-event IDs for webhooks.
**Workflow / state-machine bypass:**
- **How I exploit it:** I skip steps — reach a paid feature without completing payment, hit "confirm" without "review," jump to a later state by calling its endpoint directly.
- **The fix:** Enforce state transitions **server-side** as an explicit state machine; each step verifies the prior state actually holds. Don't assume users walk your happy path — I won't.
**Value/quantity tampering:**
- **How I exploit it:** Negative quantities to get a credit, fractional cents and rounding abuse, currency confusion, a price field I supplied.
- **The fix:** Server-side validation of ranges and signs; prices and totals computed server-side from trusted data; decimal/money types, never floats for currency.
---
## 10. If I were attacking you tomorrow — my first 12 moves
A concrete red-team walk against a typical vibe-coded stack (Next.js + Supabase + Stripe + an LLM agent). For each: what I try, why it usually works, and the architectural control that turns it into a dead end. *(Patterns only — this is your threat model, not a weapon against others.)*
1. **Fingerprint the stack** from headers/bundle/DNS. → *Dead end:* nothing sensitive in headers, no source maps, generic errors. I learn nothing.
2. **Pull the Supabase anon key** from your JS and `curl` your tables directly. → *Dead end:* RLS denies by default; I get empty arrays. (This one move breaks ~half of vibe-coded apps. Make sure it breaks on yours.)
3. **Map every API route** and hit each one **unauthenticated**, looking for one that returns data. → *Dead end:* 401 before any handler logic, on every protected route.
4. **Log in and tamper with identity** — change `user_id`/`org_id` in requests, flip `role` in the signup body. → *Dead end:* identity comes from the session only; ownership checked per resource; field whitelisting blocks role injection; RLS backstops it.
5. **Walk resource IDs** (`/orders/1001`, `1002`, …) to read others' data. → *Dead end:* UUIDs + ownership checks + RLS; enumeration yields nothing.
6. **Credential-stuff the login** with a breach list. → *Dead end:* rate limiting + bot management + MFA make it uneconomical.
7. **Hammer the AI agent** with a request flood and oversized prompts to spike your LLM bill, and try **prompt injection** to make it leak data or call a tool. → *Dead end:* per-user quotas + token caps + spend circuit breaker; tools scoped to me with least privilege; injection can't change authorization because that lives in your code.
8. **Forge a Stripe webhook** (`payment.succeeded`) to grant myself a paid plan, then **replay** a real one for extra. → *Dead end:* signature verification rejects the forgery; idempotency rejects the replay.
9. **Race a one-time action** — coupon, balance, inventory — with 50 concurrent requests. → *Dead end:* DB constraints + atomic transactions; only one succeeds.
10. **Upload a disguised file** (HTML/script named `.jpg`) and load it from your origin. → *Dead end:* magic-byte validation + re-encode + served from a separate bucket; nothing runs in your context.
11. **Probe the seams** — SSRF to `169.254.169.254` for cloud creds, internal endpoints trusting `X-User-Id`, a dangling DNS subdomain. → *Dead end:* SSRF blocks private IPs; internal calls are authenticated; DNS has no dangling records.
12. **Exfiltrate in bulk** via a list endpoint or slow scrape. → *Dead end:* pagination caps + per-account volume limits + anomaly alerts; I get flagged and throttled long before I get your dataset.
**If even half of these are dead ends on your system, you're already past the bar that took down every app on the breach lists.** The ones that aren't dead ends yet — fix those first; they're your real exposure.
---
## 11. Architecture Decision Records (commit these — they're load-bearing)
Turn the principles above into **binding decisions** you write down and don't relitigate. When your AI agent (or future you) is about to violate one, the ADR is what stops it.
- **AD-1 · The client has zero authority.** Every security decision is re-made server-side from server-held state. The browser displays and submits intent; it decides nothing.
- **AD-2 · The data layer denies by default.** RLS on every table, scoped to `auth.uid()`. The database protects itself even if the app layer is wrong.
- **AD-3 · Identity is proven, never claimed.** The authenticated user comes from the verified session only — never from a client-supplied field.
- **AD-4 · Authorization is separate from authentication.** Every resource access independently verifies the user may touch *this* object, on reads and writes.
- **AD-5 · Secrets are segmented by blast radius.** No component holds a key it doesn't use; one leak compromises one thing, not everything.
- **AD-6 · Every metered action is gated.** Quota + rate limit + spend cap + circuit breaker on anything that costs money.
- **AD-7 · State-changing operations are atomic and idempotent.** Invariants enforced at the data layer; replays are no-ops.
- **AD-8 · Trust boundaries are explicit and authenticated.** No "internal therefore trusted." Every cross-boundary call verifies the caller; every inbound webhook verifies its signature.
- **AD-9 · Assume breach.** Log off-box, alert on anomalies, and keep every secret rotatable in minutes with a kill switch and a runbook.
- **AD-10 · Minimize disclosure.** Generic errors, no source maps, no introspection in prod, non-sequential IDs, nothing in recon that helps an attacker.
---
## 12. How I'd verify these actually hold (red-team test plan)
Architecture controls are worthless if they're aspirational. Prove each boundary holds with an adversarial test — not "does the feature work" but "does the *failure mode* fail closed":
- [ ] **Boundary #1 (client→server):** tamper with every client-supplied field (`user_id`, `role`, price, quantity) and confirm the server ignores or rejects it.
- [ ] **Boundary #3 (app→data):** query the DB with only the anon key; confirm RLS returns nothing for data you shouldn't see.
- [ ] **Depth check:** disable the app-layer auth check in a test and confirm RLS *still* blocks cross-user reads. (If it doesn't, you have one layer, not two.)
- [ ] **AuthZ:** with User A's session, attempt to read and write User B's resources on every endpoint that takes an ID → all 403.
- [ ] **Seams:** send an unsigned webhook (→400), replay a signed one (→ no double effect), try SSRF to internal IPs (→ blocked), upload a disguised file (→ can't execute on your origin).
- [ ] **Race:** fire concurrent requests at every one-time/limited action and confirm exactly one succeeds.
- [ ] **Denial of wallet:** burst the agent and metered endpoints; confirm quotas, caps, and the circuit breaker trip.
- [ ] **Blast radius:** assume one key leaked — trace on paper exactly what it reaches. If "everything," fix the segmentation.
- [ ] **Detection:** run the above and check that your logging/alerting actually *noticed*. Silent controls you can't observe failing are controls you can't trust.
When you've turned all of these into dead ends, **bring in a human pentester** to find the things a threat model can't — business-logic flaws specific to your domain, chained low-severity bugs, and the creative paths neither of us listed. That's the one step no document replaces.
---
## TL;DR — the architecture in one breath
> The client is hostile and decides nothing. Identity is proven server-side, never claimed. Every resource access is authorized separately, on a data layer that denies by default — so one forgotten check isn't a breach. Secrets are segmented so one leak isn't game over. Every action that costs money is gated. State changes are atomic and idempotent. Every trust boundary is explicit and authenticated. And you assume I'm already inside — so you log, alert, contain, and can rotate everything in minutes. **Make my first twelve moves dead ends, then hire someone to find the thirteenth.**
---
*Companion to the SECURITY-CHECKLIST. The checklist hardens components; this hardens the architecture and the seams between them. Neither makes you "unhackable" — together they make you a hard, contained, well-monitored target, which is the real goal. When you have real users and revenue, a human penetration test is the next step, not an optional one.*