pm4ai

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.X only inside 'use server', 'use node', convex/, backend/spacetimedb/, or app/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.
  • Set a session cookie's Secure flag from the request SCHEME (x-forwarded-proto else the URL protocol), never from NODE_ENV. Why: a browser silently drops a Secure cookie sent over plain HTTP, so secure: NODE_ENV === 'production' breaks the session on any prod deploy served over HTTP on a real host — and it passes every local test because http://localhost is a browser secure-context exemption, so only a live drive on a non-localhost HTTP host catches it.
  • A regression test flips the mechanism off and asserts the invariant fails. Why: if it passes mechanism-off, it was call-site-asserted.

NEVER

  • Never use NEXT_PUBLIC_* for a credential — API keys, tokens, DB secrets, anything *_SECRET/*_PASSWORD/*_PRIVATE_KEY. Cost: shipped to browser.
  • Never write process.env.X ?? 'fallback' / || 'default' on a config read. Cost: silent wrong-value behavior.
  • Never read server-only vars from a 'use client' component. Cost: leaks to bundle or is undefined.
  • Never 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.example marks server-only vars without the prefix.

On this page