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).
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill secure-coding --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Secure Coding?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-secure-coding)More formats (shields.io, HTML) on the badges page.
---
name: secure-coding
description: "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)."
tags: [security, owasp, stride, auth, review]
recommends: [deployment]
origin: risco
---
# 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`](../agent-safety/SKILL.md),
[`building-agents`](../building-agents/SKILL.md). Running the scanners and
triaging their raw output: [`security-scan`](../security-scan/SKILL.md).
Infra/network firewalling with no code change: [`deployment`](../deployment/SKILL.md).
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)
1. **Scope.** What data, which boundary, what auth context does the diff touch?
2. **Threat-model lite.** STRIDE on the changed element only → `references/threat-modeling.md`.
3. **Map sinks to OWASP.** Match each changed sink to a category → `references/owasp-by-stack.md`.
4. **Rank by exploitability.** Reachable? User-controlled? Meaningful sink? Report the highest-impact reachable findings first, not a checklist.
5. **Propose fixes as diffs** in the repo's actual stack (Good/Bad, copy-pasteable).
6. **Run `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 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
# 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
// 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
// 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.
```python
# 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)
```
```ts
// 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.
```tsx
// 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.
```python
# 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
```
```ts
// 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, `HttpOnly` cookies, 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` (`Strict` for sensitive),
`__Host-` prefix, scoped `Path=/`. BAD = token in `localStorage` (XSS steals it).
- **Password hashing:** Argon2id (`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.)
- **CSRF:** needed for cookie-auth state-changing requests; double-submit token
or framework token; SameSite is defense-in-depth, not sufficient alone.
```python
# 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
```python
# 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"])
```
```ts
// 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; `.env` gitignored. Only `NEXT_PUBLIC_*` is
public — **BAD: a secret read in a Client Component ships to the browser.**
In a [`harness`](../harness/SKILL.md) project they live in
`01-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=high` or
`osv-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 . --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`.
## 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`](../harness/SKILL.md)), 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`](../fastapi/SKILL.md), [`nextjs`](../nextjs/SKILL.md),
[`go`](../go/SKILL.md), [`flutter`](../flutter/SKILL.md),
[`postgresdb`](../postgresdb/SKILL.md), [`deployment`](../deployment/SKILL.md))
defer security to this skill; where one doesn't exist yet, this is the canonical
reference it would point to.
Scanned 9/2/2026
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!