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.
  • 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_KEYTMDB_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