Environment isolation

Preview deployments must never hold a production credential. Run bun run check:env to prove it.

Previews sit behind Vercel SSO, which a protection-bypass secret defeats with one header, and anyone who can read preview env or dump it out of a preview build holds whatever is in it. So preview gets its own value, or it gets nothing.

How a variable is split

Three shapes, in order of how often you will need them.

A project variable. Re-target the existing record to production and development, then add a separate preview-scoped record:

PATCH /v9/projects/{projectId}/env/{envId}   {"target":["production","development"]}
POST  /v10/projects/{projectId}/env          {"key":...,"value":...,"type":"encrypted","target":["preview"]}

Leave the preview record out entirely when the feature should simply be off in preview. Every flag in src/config/features-config.ts is derived from the presence of a non-empty value, so an absent variable switches its feature off cleanly, while a placeholder string leaves the feature on and failing against a credential that cannot work.

vercel env add is not an option for preview: CLI 50.27.1 loops asking for a git branch.

A team-shared variable. ANTHROPIC_API_KEY, OPENAI_API_KEY and RESEND_API_KEY are not project records at all. They live at the team level and apply to many projects at once, so changing their target would change every project that reads them. Override them per project instead: a project-level preview record with an empty value wins over the shared variable, which is both the documented precedence and what vercel env pull --environment=preview shows afterwards.

An integration-managed variable. The database credentials carry contentHint.type: integration-store-secret. Editing those records works until the integration next re-syncs and silently puts production credentials back. Fix the store connection instead (PATCH /v1/storage/stores/{storeId}/connections/{connectionId} with envVarEnvironments). See LAC-5153.

Verifying

bun run check:env

vercel env pull is the only honest read. GET /v10/projects/:id/env?decrypt=true returns a JWE blob rather than plaintext, and two ciphertexts of the same plaintext differ, so hashing the API response reports correctly-shared variables as split and vice versa.

The check prints a verdict and never a value. On failure it prints a truncated sha256 so two people can compare notes without passing secrets around. Pulled plaintext lands in a 0700 temp directory that is removed on every exit path, including a crash.

What production and preview still share

22 values, each listed with its reason in SHARED_BY_DESIGN in scripts/check-env-isolation.ts: database role and schema names, public NEXT_PUBLIC_* keys that production already serves to every browser, ids that open nothing without a key, and build switches. Adding to that list costs one line of justification. That is deliberate. The exposure this guards against arrived by drift, not by decision.

Rotation ledger

A split closes the next leak. It does not un-leak what preview already held, and preview builds had run with all 16 of these values, so each one needs a new value in production too.

Rotated on 2026-10-20 (LAC-5200), live on the production deployment made the same day:

VariableImpact of the rotation
AUTH_SECRETNone on signed-in users. Production runs the database session strategy, so session cookies are opaque row tokens rather than anything this key signs. In-flight OAuth and CSRF cookies are invalidated once.
PAYLOAD_SECRETPayload admin tokens invalidated, so CMS users sign in again. It was 10 characters; it is now 64. No collection uses useAPIKey or an encrypted field, so nothing stored needs the old value.
BETTER_AUTH_SECRETNone. /api/better-auth/* answers 404 in production, so this key currently signs nothing.

Still holding a value that leaked, because rotating it needs a console the fleet cannot reach. Each is split, so the exposure is closed going forward:

VariableWho has to rotate it
AUTH_GITHUB_SECRETGitHub OAuth app settings. Do it together with the preview OAuth apps.
AUTH_GOOGLE_SECRETGoogle Cloud console for the OAuth client.
AUTH_TWITTER_SECRETX developer portal.
VERCEL_CLIENT_SECRETVercel integration settings. There is no API to roll a client secret.
GOOGLE_PRIVATE_KEY, GOOGLE_CLIENT_EMAILService account florish@lacymorrow-com. The fleet's gcloud is authenticated as a different Google account and cannot see that project.
STACK_SECRET_SERVER_KEYStack Auth dashboard. Dead configuration: AUTH_STACK_ENABLED needs unprefixed STACK_PROJECT_ID and STACK_PUBLISHABLE_CLIENT_KEY, which do not exist here, so the end state is delete and revoke rather than rotate.
GOOGLE_AI_API_KEYGoogle AI Studio. Also unreferenced anywhere in src/: delete and revoke.
AUTH_RESEND_KEYResend dashboard. The Auth.js resend provider is gated on NODE_ENV !== "production", so this was never live in a deployed environment.
ANTHROPIC_API_KEY, OPENAI_API_KEY, RESEND_API_KEYTeam-shared, so the same values reach the preview environment of every other project that reads them. Rotating them is a team-wide change, not a shipkit-www one.

NEXT_PUBLIC_POSTHOG_KEY and NEXT_PUBLIC_BUILDER_API_KEY needed no rotation: NEXT_PUBLIC_* is inlined into the browser bundle, so production already hands both to anyone who loads the site. PostHog is still split, so preview stops writing events into production analytics.

Things deliberately not done

  • Marking the production records sensitive instead of encrypted. A sensitive value cannot be read back, which would break vercel env pull, which is the only honest way to verify any of this. The readable-secret warning Vercel shows on these records is the price of being able to check them.
  • Preview OAuth applications. Preview sign-in with GitHub, Google or X cannot work today regardless of credentials: AUTH_URL is production-only, so Auth.js builds its callback from the per-deployment preview host, and no provider has those hosts registered. Making it work needs its own OAuth apps plus a stable preview hostname to register, which is larger than this change.
  • Changing the development target. Laptops still read production values for everything here. That is a separate decision, filed as LAC-5201.