Secure coding — threat modeling + OWASP across the stack
Threat-model a feature in PR-sized increments, fix OWASP-class bugs with
stack-correct vulnerable→fixed diffs, and gate the result with verify.sh.
Stacks: FastAPI/Python 3.12+, Next.js 15 / React 19 / TS, Go 1.22+,
Flutter/Dart 3, PostgreSQL 16.
Operating posture:
- Read-only by default. Identify → rank by exploitability → propose fixes as diffs. Apply changes only when the user asks.
- Exploitability over theory. Rank like a bounty triager: reachable + user-controlled + meaningful sink comes first. Do not dump a flat checklist.
- Every finding ships a fix. Never "consider sanitizing" — show the corrected code for this stack.
Stay in lane — this is about the application code the user ships. Agent /
Claude-Code config security (.claude/, hooks, MCP, prompt injection,
sandboxing) is a different concern: agent-safety,
building-agents. Running the scanners and
triaging their raw output: security-scan.
Infra/network firewalling with no code change: deployment.
A pentest/bounty PoC against a third party is out of scope (legal) — this skill
defends code, it does not attack external targets. Don't gate trivial
non-security refactors through verify.sh.
The 30-second model: lethal trifecta + trust boundaries
- Lethal trifecta (Simon Willison): private data + untrusted input + ability to exfiltrate. Flag any handler where all three meet — that's where a leak becomes a breach.
- Trust-boundary rule: every untrusted→trusted crossing is a checkpoint with one owning defense: HTTP body→SQL, user string→shell/URL/HTML, JWT claim→authz decision, filename→fs path, upload→disk/exec.
- Least privilege / least agency for tokens, DB roles, CORS origins and file perms. It is the one control that still caps blast radius after one of the defenses below fails, so it is never optional.
| Untrusted source | Dangerous sink | Defense | Ref |
|---|---|---|---|
| Request body | SQL query | Parameterize (bound params / ORM) | A03 |
| User URL | Outbound fetch | https-only + IP allowlist, block private ranges | A10 |
| User HTML | DOM render | Encode by context / DOMPurify allowlist | A03 |
| Filename | Filesystem path | Canonicalize + base-dir containment | A03 |
| JWT / claim | Authz decision | Verify signature + pinned alg + aud/iss/exp |
references/authn-authz.md |
| Upload | Disk / exec | Type+size, sniff magic bytes, store outside web root | A01 |
A-numbers index references/owasp-by-stack.md: same section, vulnerable→fixed
code in all three stacks.
Review workflow (PR-sized)
- Scope. What data, which boundary, what auth context does the diff touch?
- Threat-model lite. STRIDE on the changed element only →
references/threat-modeling.md. - Map sinks to OWASP. Match each changed sink to a category →
references/owasp-by-stack.md. - Rank by exploitability. Reachable? User-controlled? Meaningful sink? Report the highest-impact reachable findings first, not a checklist.
- Propose fixes as diffs in the repo's actual stack (Good/Bad, copy-pasteable).
- Run
scripts/verify.shin the repo root; resolve every high/critical before merge — a reachable CVE is a release blocker, not a follow-up ticket.
OWASP Top 10 — fastest fix per category
| OWASP 2021 | The mistake you'll actually see | Stack-correct fix in one phrase |
|---|---|---|
| A01 Broken Access Control | db.get(id) returned to any authed user |
Ownership-scoped query; 404 (not 403) on miss |
| A02 Cryptographic Failures | SHA-256 password hash; random token |
Argon2id + CSPRNG (secrets/crypto/rand) |
| A03 Injection | f-string SQL / shell=True |
Bound params / arg-list, no shell / canonicalize path |
| A04 Insecure Design | No rate limit, replayable payment | Lockout + idempotency-key UNIQUE constraint |
| A05 Security Misconfiguration | debug=True, *+credentials CORS |
debug=False, explicit origin allowlist, headers |
| A06 Vulnerable Components | Ignored transitive CVE | Audit + upgrade/override/replace |
| A07 Auth Failures | Reusable session id, user enumeration | Lockout + rotate session id + generic error |
| A08 Data Integrity | curl | bash, unpinned CDN script |
npm ci/go mod verify + SRI |
| A09 Logging Failures | No authz-fail log; PII in logs | Structured log on user_id, redact secrets |
| A10 SSRF | Fetch user-supplied URL directly | https-only + IP allowlist + pin dialed IP |
Flagship: A01 Broken Access Control / IDOR (the #1, stack-agnostic in shape). Authorize on the server, per object, on every request, deny by default: the id in the URL is attacker-controlled, so the ownership predicate in the query is the only thing standing between a stranger and the row.
# Python — FastAPI 3.12 + SQLAlchemy 2.0
# BAD — any authenticated user can read any document.
@router.get("/documents/{doc_id}")
def get_doc(doc_id: int, db: Session = Depends(get_db)):
return db.get(Document, doc_id)
# GOOD — ownership-scoped query; 404 on miss; injected current_user.
@router.get("/documents/{doc_id}")
def get_doc(doc_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> DocumentOut:
doc = db.execute(
select(Document).where(Document.id == doc_id, Document.owner_id == user.id)
).scalar_one_or_none()
if doc is None:
raise HTTPException(status_code=404, detail="Not found") # 404 not 403
return DocumentOut.model_validate(doc)
// TS — Next.js 15 App Router Route Handler / Server Action
// BAD — trusts the id and assumes a session exists.
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // Next.js 15: params is a Promise — await it.
return Response.json(await db.document.findUnique({ where: { id } }));
}
// GOOD — auth() guard + ownership scope + notFound().
import { auth } from "@/auth";
import { notFound } from "next/navigation";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session) notFound();
const doc = await db.document.findFirst({
where: { id, ownerId: session.user.id },
});
if (!doc) notFound();
return Response.json(doc);
}
// NOTE: Server Actions are public POST endpoints — re-authorize server-side.
// Go — 1.22 ServeMux + slog
// BAD — r.PathValue("id") straight into the query, no ownership check.
// GOOD — userID from context; scoped query; 404 on miss.
mux.HandleFunc("GET /documents/{id}", func(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(userKey).(int64)
if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
var body string
err := db.QueryRowContext(r.Context(),
"SELECT body FROM documents WHERE id=$1 AND owner_id=$2",
r.PathValue("id"), userID).Scan(&body)
if err == sql.ErrNoRows {
slog.Warn("authz_miss", "user", userID)
http.Error(w, "not found", http.StatusNotFound); return
}
if err != nil { http.Error(w, "internal error", http.StatusInternalServerError); return }
_, _ = w.Write([]byte(body))
})
Full vulnerable→fixed code for all 10 categories in all three stacks lives
in references/owasp-by-stack.md.
Input validation & output encoding
Validate at the boundary with a schema and never trust the shape that arrived — an unbounded or extra field is how the next sink gets its payload.
# BAD — raw body, unbounded, unknown fields silently accepted.
data = await request.json()
# GOOD — Pydantic v2: bounded + reject unknown fields.
from pydantic import BaseModel, ConfigDict, Field
class CreateUser(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str = Field(max_length=254)
name: str = Field(min_length=1, max_length=100)
// GOOD — Zod .strict() parsed inside the Server Action (Go: struct + go-playground/validator).
const Schema = z.object({ email: z.string().email(), name: z.string().max(100) }).strict();
const data = Schema.parse(await req.json()); // BAD: `body as any`
XSS: React auto-escapes — the bug is dangerouslySetInnerHTML. Stored
(persisted then served), reflected (echoed from the request), and DOM
(client writes user data into the DOM) XSS all need encoding/sanitizing. Encode
on output by context (HTML / attribute / JS / URL); never build HTML by
concatenating user strings.
// BAD — raw user HTML into the DOM.
<div dangerouslySetInnerHTML={{ __html: userHtml }} />
// GOOD — DOMPurify allowlist sanitize, or render as text.
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ["b","i","p"] }) }} />
Upload validation: size cap + sniffed content-type (magic bytes, not the
client-sent file.type) + extension allowlist + store outside the web root +
random filename.
# GOOD — sniff magic bytes; never trust the client content-type.
import secrets, filetype
head = await file.read(512); await file.seek(0)
kind = filetype.guess(head)
if kind is None or kind.mime not in {"image/png", "image/jpeg"}:
raise HTTPException(415, "unsupported media type")
dest = UPLOAD_DIR / f"{secrets.token_hex(16)}.{kind.extension}" # outside web root
// GOOD — magic-byte sniff on the first bytes; reject SVG.
import { fileTypeFromBuffer } from "file-type";
const buf = Buffer.from(await file.arrayBuffer());
const ft = await fileTypeFromBuffer(buf);
if (!ft || !["image/png", "image/jpeg"].includes(ft.mime)) throw new Error("bad type");
AuthN / AuthZ in 60 seconds
- Library note (2026): Auth.js / NextAuth v5 is still maintained (now under
the Better Auth team; security patches continue) and the
auth()patterns below are valid — but for new Next.js projects, evaluate Better Auth too. The principles here (server-side authz,HttpOnlycookies, pinned-alg JWT) are library-agnostic. - Sessions vs JWT: server-side session = easy revocation, default for first-party web; JWT = stateless, short access (5–15 min) + rotating refresh with reuse detection + a revocation story.
- Cookie flags:
HttpOnly; Secure; SameSite=Lax(Strictfor sensitive),__Host-prefix, scopedPath=/. BAD = token inlocalStorage(XSS steals it). - Password hashing: Argon2id (
time_cost=3, memory_cost=65536, parallelism=4) viaargon2-cffi(Py) /golang.org/x/crypto/argon2(Go); bcryptcost>=12fallback; never SHA-256/MD5. (These exceed the OWASP floor ofm=19456, t=2, p=1orm=47104, t=1, p=1—≥the minimum on every axis is the bar; tunememory_costup until a hash takes ~0.5s on prod hardware.) - CSRF: needed for cookie-auth state-changing requests; double-submit token or framework token; SameSite is defense-in-depth, not sufficient alone.
# GOOD — verify a JWT with pinned algorithms + audience + issuer (PyJWT).
import jwt
claims = jwt.decode(
token, public_key,
algorithms=["RS256"], # pinned: rejects alg:none and HS-when-RS
audience="api://my-service", issuer="https://issuer.example.com/",
options={"require": ["exp", "aud", "iss"]},
)
Sessions/OIDC/RBAC/ABAC/MFA/refresh-rotation details: references/authn-authz.md.
CORS, security headers, TLS, rate limiting, logging
# CORS — BAD: allow_origins=["*"] with allow_credentials=True (illegal + dangerous).
# GOOD — explicit origin allowlist.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(CORSMiddleware,
allow_origins=["https://app.example.com"], allow_credentials=True,
allow_methods=["GET", "POST"], allow_headers=["authorization", "content-type"])
// Security headers — next.config.ts. CSP without unsafe-inline/unsafe-eval
// (use nonces/hashes for inline scripts); HSTS only when all subdomains are HTTPS.
const headers = [
{ key: "Content-Security-Policy", value: "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
];
export default { async headers() { return [{ source: "/:path*", headers }]; } };
- Rate limiting: per-IP and per-identity, stricter on auth/OTP/search. In-memory limiters are not multi-instance safe — use Redis behind >1 replica.
- Fail closed: generic error to the client, detail to the logs. A stack trace or DB message in a response is free reconnaissance for the attacker.
- Logging without PII: redact tokens/passwords/PAN/email; log
user_id, not email; structured (slog/structlog); never log auth-route bodies.
Secrets & supply chain (the part that gets you breached)
- Env/secret-manager, never repo;
.envgitignored. OnlyNEXT_PUBLIC_*is public — BAD: a secret read in a Client Component ships to the browser. In aharnessproject they live in01-TOOLS/<PROVIDER>/.env(gitignored) — same rule, never in the repo. - Pin + commit the lockfile; install with
npm ci/pnpm i --frozen-lockfile/go mod verify/pip install --require-hashes. - Audit per stack:
pip-audit,npm audit --omit=dev --audit-level=highorosv-scanner scan source -L pnpm-lock.yaml(lockfile-aware, multi-ecosystem, v2 CLI),govulncheck ./...(reachability-aware — only vulns you call),dart pub outdated. Fix by upgrading to the fixed version; constrain or override transitive pins. - On exposure: rotate the credential first, then scrub history (
gitleaks dir . --redactfor the working tree,gitleaks git .for history, to confirm). SBOM viasyft; provenance viacosign/SLSA.
Full runbook: references/secrets-and-supply-chain.md.
Anti-patterns
| What gets said | Reality |
|---|---|
| "It's behind auth, so IDOR doesn't matter." | Authenticated ≠ authorized. Check object ownership on every request. |
| "The frontend already validates / hides the button." | Client checks are UX. Re-authorize and re-validate on the server. Server Actions and API routes are public. |
| "I'll sanitize with a blacklist of bad chars." | Allowlist + parameterize/encode. Blacklists are bypassable. |
| "JWT in localStorage is fine, it's just an access token." | Any XSS steals it instantly. Use HttpOnly cookies; short TTLs. |
"allow_origins=['*'] with credentials is convenient." |
The browser rejects it and it's dangerous. Use an explicit origin allowlist. |
| "npm audit shows criticals but they're transitive." | Transitive is still in your bundle. Pin/override or replace. |
| "I'll log the payload to debug, remove it later." | "Later" never comes; PII/secrets leak to logs. Redact now. |
| "We'll add rate limiting after launch." | Auth/OTP endpoints get brute-forced on day one. |
| "It's an internal URL fetch, SSRF isn't a risk." | Internal is exactly the SSRF target (metadata, RDS). Allowlist hosts and block 169.254.169.254, 10/8, 172.16/12, 192.168/16, 127/8, ::1, fc00::/7, fe80::/10. |
| "Error stack to the client speeds debugging." | It leaks internals to attackers. Generic to client, detail to logs. |
"Secrets in .env.example are placeholders; real ones in CI YAML are fine." |
Use the secret store; never inline real secrets in CI files. |
| "Argon2 is overkill, SHA-256 is fast." | Fast = brute-forceable. Use Argon2id (or bcrypt cost≥12). |
verify.sh — the gate
scripts/verify.sh runs gitleaks + semgrep (--config=auto --severity ERROR
gates; WARNING is informational) + the per-stack CVE audit
(pip-audit/osv-scanner/govulncheck). It is the user's to run in their own repo
root — it auto-detects the stack, skips (does not fail) when a tool is
missing, and exits non-zero only on real high/critical findings. The CI
equivalent is in references/secrets-and-supply-chain.md.
Project grounding
In a project with a 02-DOCS/ layer (harness), this
project's security decisions live in 02-DOCS/wiki/stack/security.md, indexed
from 02-DOCS/wiki/index.md (the Knowledge map; root CLAUDE.md keeps only a
pointer to it). Read it first and stay consistent; if it is missing or stale,
create/update it with the real choices — threat model, auth model, secrets
backend, CI security gates, accepted risks — index it, and bump its Updated
date in the same change. No 02-DOCS/ layer? Skip silently (optionally suggest
harness): technical conventions are recorded, not gated — never block the
task on this.
Stack skills (fastapi, nextjs,
go, flutter,
postgresdb, deployment)
defer security to this skill; where one doesn't exist yet, this is the canonical
reference it would point to.