Writing Secure Code
A per-surface checklist for extending DirectoryLaunch — new API routes, database tables, uploads, webhooks, outbound fetches, AI features and dependencies.
The go-live checklist covers your deployment once. This page covers every change you make afterwards. Run it against one pull request or one new module, not against the whole codebase once a quarter — the more code you review at a time, the more gets skipped.
Every item ends as PASS (with the file, line, query or command that proves it), FAIL, or N/A with a reason. "Probably fine" is not a verdict.
The five invariants
Break one of these and you have introduced a vulnerability, no matter how good the feature is.
- The
dblayer bypasses RLS. It runs as service-role. Authorization lives in the route handler. RLS is the backstop for the publishable key, not the primary control. - A secret that might be unset must fail closed. Comparing against
Bearer ${process.env.S}matches the literal string"Bearer undefined"whenSis missing. UsehasValidCronSecret(). - Ownership is checked in the handler, never in the UI. A hidden button is not a control.
- Money is server-side and in integer minor units. No price, plan, discount or paid-state field is ever read from a request body.
- State changes happen in exactly one place. Listing ownership only in
finalizeClaim(); lead delivery only indeliverQuote(); stock only via thedecrement_inventoryRPC. Adding a second write path is the bug.
New API route
- Every exported method has an explicit auth decision. Read the handler; do not infer from the path. If it is deliberately public, say so in a comment.
- Auth goes through
getCurrentUser()/isAdmin()— never a raw cookie read. - No fail-open secret comparison — this must return nothing:
grep -n 'Bearer ${process.env' <file>- Rate limited if it writes, sends email, uploads, or costs money.
-
featureGuard('<flag>')first, if it belongs to an optional module. - Body parsed through a Zod schema, and the handler uses the parsed object — reading
body.someFieldaftersafeParsedefeats schema stripping. - Body size capped on public routes.
- Writes use an explicit column allowlist. No spreading the request body. Verify with:
grep -nE "(role|is_admin|plan|price|payment_status|approved|submitted_by|user_id|owner_user_id|is_claimed|featured|status)\s*:" <file>Every hit must be server-derived.
- Ownership filter on every read, update and delete of a user-owned row.
- Errors return a generic message —
grep -n "error.message" <file>must not appear in any client-facing response body. - Demo mode blocks the write: non-GET handlers call
demoWriteResponse().
New database table or migration
-
ALTER TABLE … ENABLE ROW LEVEL SECURITYin the same migration that creates the table. - At least one policy per operation the app performs. RLS on with no policy silently "works"
through the service-role
dblayer and breaks the moment anything uses the publishable key. - Private tables are
TO authenticated— a policy with noTOclause also applies toanon. - No
USING (true)on anything holding personal data. - Column-level review. RLS grants all columns of a matching row. Before writing a public
SELECTpolicy, list the columns and ask which you would publish on a webpage. Contact details belong behind a view — seeapps_publicin migration0013. - Admin predicates use
public.is_admin_user(), which accepts both admin signals. - Any
SECURITY DEFINERfunction setssearch_path = ''. - Counters use an atomic increment function, never a read-then-write.
- After applying, re-run the RLS verification query from the go-live checklist.
New upload path
- Server-side MIME allowlist, not the client's declared type alone.
- Magic-byte validation of the real content, checking the full signature.
- Extension cross-checked against the allowlist.
- Size cap enforced before the buffer is read.
- Object key generated server-side (
uuidv4()); no path segment from the client. - No SVG, HTML or PDF served inline from the app origin.
- Copy the reference implementation in
app/api/upload/route.tsrather than writing a new one.
New inbound webhook
- The secret is mandatory — refuse when it is unset, never skip the check.
- Signature verified against the raw body, before any parsing.
- Constant-time comparison (
crypto.timingSafeEqual). - Replay protection: timestamp tolerance or a stored event id.
- Idempotent: a unique DB constraint on the provider's event id.
- The secret is in a header, not the query string — query strings land in access logs.
- Nothing in the request body is used for authorization. A chat id or account id inside an attacker-controlled payload is data, not identity.
- A missing signature returns non-2xx. A
catchthat returns200is a forged-webhook accept.
New outbound fetch
Any fetch() whose URL is not a hardcoded constant:
- Goes through
safeFetchText()fromlib/safe-fetch.ts. - The route requires a session and is rate limited.
- The response body and error text are not reflected to the caller.
grep -rn "fetch(" app/api lib | grep -v "https://" # every hit needs this sectionNew public page
- No
dangerouslySetInnerHTMLwith data that is not authored in-repo. - JSON-LD goes through
jsonLdSafe(), never bareJSON.stringify. - Any HTML from a third party or a rich-text editor is sanitised server-side.
- User-controlled
href/srcis scheme-checked (javascript:,data:,vbscript:). - Redirect targets go through
safeRedirectPath()fromlib/safe-redirect.ts.
New AI feature
- Requires an authenticated session — an unauthenticated LLM endpoint is a bill, not just a vulnerability.
- Dedicated low-ceiling rate-limit bucket, plus a server-side spend cap and provider-side limits.
- Externally scraped text fed into a prompt is treated as untrusted — it can carry instructions.
- Model output is never rendered as HTML, passed to a shell, or interpolated into SQL without the same checks a user's input would get.
New cron job
- Uses
hasValidCronSecret(request)— not an inline template-literal comparison. - The schedule in
vercel.jsonpoints at a handler that actually exists. - Idempotent: the platform may invoke it more than once.
New environment variable
-
NEXT_PUBLIC_only if the value is genuinely public — it is compiled into the client bundle and visible to everyone, forever. - A
NEXT_PUBLIC_variable must never change an authorization decision. - If security depends on it, add it to
lib/env-assert.tsso production refuses to start without it. - Documented in
.env.example, with a generation hint for secrets. - Never logged, never returned in an API response, never placed in a URL query string.
New dependency
- The package actually exists and is the one you intended — check the npm page, download counts and repository link. AI-suggested imports are frequently hallucinated, and attackers register those names.
-
pnpm audit --audit-level=highis clean, or each finding has a written reachability argument. - Pinned by the lockfile, and the lockfile is committed.
- No unreviewed
postinstallscript.
Before every release
pnpm lint && pnpm build
pnpm audit --audit-level=high
grep -rn 'dangerouslySetInnerHTML' app # every hit reviewed
git log -p -S'sk_live' -S'sb_secret' --all | head # no secret ever committedThen re-run the RLS verification query against production and confirm nothing regressed.