Security
Security constraints — credential handling, env var scoping, server vs client boundaries
Credential handling, env scoping, server/client boundary, mechanism-asserted invariants.
MUST
- Route any credentialed client-side work through a server action / API route / Convex action reading the unprefixed var. Why:
NEXT_PUBLIC_*is inlined into the client bundle, visible in page source. - Read server-only vars via
process.env.Xonly inside'use server','use node',convex/,backend/spacetimedb/, orapp/api/*/route.ts. Why: server boundary. - Fail fast on a missing required var — validate via schema (
z.string().min(1),z.url(), NO.default()) or throw. Why: silent default = undebuggable wrong value. - Absence-as-off is allowed ONLY as a documented intentional toggle (
if (env.SENTRY_DSN) initSentry(env.SENTRY_DSN)) — test: can a user intentionally configure absence to mean off? yes = toggle (allowed), no = fallback (banned). Why: separates a real feature toggle from a silent wrong-value default. - Bash
${VAR:?VAR is required}; docker-compose${VAR:?}. Why: crash on absence, not fallback. - Set every var explicitly in
.env(sole source of truth), even conventional ones. Why: nothing implicit. - Enforce auth/isolation/ownership by a mechanism the caller can’t bypass — Convex
v.*validator + auth guard at the boundary, DB NOT NULL/CHECK/unique constraint, server-side challenge. Why: call-site checks get forgotten. - Two independent enforcement points per isolation/security invariant. Why: defense in depth.
- A regression test flips the mechanism off and asserts the invariant fails. Why: if it passes mechanism-off, it was call-site-asserted.
NEVER
NEXT_PUBLIC_*for a credential — API keys, tokens, DB secrets, anything*_SECRET/*_PASSWORD/*_PRIVATE_KEY. Cost: shipped to browser.process.env.X ?? 'fallback'/|| 'default'on a config read. Cost: silent wrong-value behavior.- Read server-only vars from a
'use client'component. Cost: leaks to bundle or is undefined. - Log PII unredacted. Cost: privacy + regulatory violation.
Allowed NEXT_PUBLIC_*
- Public deploy URLs (
NEXT_PUBLIC_CONVEX_URL,NEXT_PUBLIC_SPACETIMEDB_URI) where auth is via session token, not the URL. - Feature flags / build-time constants.
- Public OAuth client IDs (paired with server-side PKCE / redirect-URI checks).
Migration
- Found a
NEXT_PUBLIC_*API key: (1) rename to drop the prefix (NEXT_PUBLIC_TMDB_API_KEY→TMDB_KEY); (2) move the call into a server action / Convex action; (3) client invokes the server action, never sees the key; (4) add a server-side test-mode stub (isStdbTestMode()/isCvxTestMode()) so playwright stays hermetic.
Caught by
- PR env-var audit: no
NEXT_PUBLIC_*name with key/secret/token/password/private; new client fetch goes through a server boundary; credential server actions short-circuit in test-mode;.env.examplemarks server-only vars without the prefix.