mcpbeat Sign in

Secure Coding Skill for Claude

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).

22k tokens
context cost
the whole folder, loaded on every use
8
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill secure-coding

What comes with it

69 660 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/authn-authz.md
references/owasp-by-stack.md
references/secrets-and-supply-chain.md
references/threat-modeling.md
scripts/verify.sh

The instruction itself

11 sections, as written by the author

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.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 — 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, 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.

# 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; .env gitignored. Only NEXT_PUBLIC_* is

public — 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.

  • 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), 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.

How to use it

Copy the folder

Take ericrisco/secure-coding from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

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.

Install what it needs

The instructions reference pip, npm. Without those the skill loads but fails at the first command.