Skip to main content

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.

  1. The db layer 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.
  2. A secret that might be unset must fail closed. Comparing against Bearer ${process.env.S} matches the literal string "Bearer undefined" when S is missing. Use hasValidCronSecret().
  3. Ownership is checked in the handler, never in the UI. A hidden button is not a control.
  4. Money is server-side and in integer minor units. No price, plan, discount or paid-state field is ever read from a request body.
  5. State changes happen in exactly one place. Listing ownership only in finalizeClaim(); lead delivery only in deliverQuote(); stock only via the decrement_inventory RPC. 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.someField after safeParse defeats 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 SECURITY in 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 db layer and breaks the moment anything uses the publishable key.
  • Private tables are TO authenticated — a policy with no TO clause also applies to anon.
  • No USING (true) on anything holding personal data.
  • Column-level review. RLS grants all columns of a matching row. Before writing a public SELECT policy, list the columns and ask which you would publish on a webpage. Contact details belong behind a view — see apps_public in migration 0013.
  • Admin predicates use public.is_admin_user(), which accepts both admin signals.
  • Any SECURITY DEFINER function sets search_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.ts rather 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 catch that returns 200 is a forged-webhook accept.

New outbound fetch

Any fetch() whose URL is not a hardcoded constant:

  • Goes through safeFetchText() from lib/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 section

New public page

  • No dangerouslySetInnerHTML with data that is not authored in-repo.
  • JSON-LD goes through jsonLdSafe(), never bare JSON.stringify.
  • Any HTML from a third party or a rich-text editor is sanitised server-side.
  • User-controlled href / src is scheme-checked (javascript:, data:, vbscript:).
  • Redirect targets go through safeRedirectPath() from lib/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.json points 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.ts so 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=high is clean, or each finding has a written reachability argument.
  • Pinned by the lockfile, and the lockfile is committed.
  • No unreviewed postinstall script.

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 committed

Then re-run the RLS verification query against production and confirm nothing regressed.