Use when securing application code: threat-modeling a feature, security-reviewing a diff, hardening auth/authz, secrets, CORS/CSP, or fixing OWASP-class bugs (access control, injection, SSRF, supply chain) in FastAPI, Next.js, Go or Flutter. NOT running the scanners (that is security-scan), NOT agent prompt-injection guardrails (that is agent-safety).
npx skills add https://github.com/ericrisco/rsc-harness --skill secure-coding
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:
as diffs. Apply changes only when the user asks.
user-controlled + meaningful sink comes first. Do not dump a flat checklist.
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.
ability to exfiltrate. Flag any handler where all three meet — that's where a
leak becomes a breach.
one owning defense: HTTP body→SQL, user string→shell/URL/HTML, JWT claim→authz
decision, filename→fs path, upload→disk/exec.
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.
references/threat-modeling.md.references/owasp-by-stack.md.scripts/verify.sh in the repo root; resolve every high/critical before merge — a reachable CVE is a release blocker, not a follow-up ticket.| 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.
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");
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, HttpOnly cookies, pinned-alg JWT) are
library-agnostic.
first-party web; JWT = stateless, short access (5–15 min) + rotating refresh
with reuse detection + a revocation story.
HttpOnly; Secure; SameSite=Lax (Strict for sensitive),__Host- prefix, scoped Path=/. BAD = token in localStorage (XSS steals it).
time_cost=3, memory_cost=65536, parallelism=4)via argon2-cffi (Py) / golang.org/x/crypto/argon2 (Go); bcrypt cost>=12
fallback; never SHA-256/MD5. (These exceed the OWASP floor of `m=19456, t=2,
p=1 or m=47104, t=1, p=1 — ≥` the minimum on every axis is the bar; tune
memory_cost up until a hash takes ~0.5s on prod hardware.)
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 — 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 }]; } };
In-memory limiters are not multi-instance safe — use Redis behind >1 replica.
trace or DB message in a response is free reconnaissance for the attacker.
user_id,not email; structured (slog/structlog); never log auth-route bodies.
.env gitignored. Only NEXT_PUBLIC_* ispublic — BAD: a secret read in a Client Component ships to the browser.
In a harness project they live in
01-TOOLS/<PROVIDER>/.env (gitignored) — same rule, never in the repo.
npm ci / pnpm i --frozen-lockfile/ go mod verify / pip install --require-hashes.
pip-audit, npm audit --omit=dev --audit-level=high orosv-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.
dir . --redact for the working tree, gitleaks git .` for history, to
confirm). SBOM via syft; provenance via cosign/SLSA.
Full runbook: references/secrets-and-supply-chain.md.
| 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). |
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.
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.
Take ericrisco/secure-coding from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip, npm.
Without those the skill loads but fails at the first command.