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:
| Variable | Impact of the rotation |
|---|---|
AUTH_SECRET | None 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_SECRET | Payload 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_SECRET | None. /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:
| Variable | Who has to rotate it |
|---|---|
AUTH_GITHUB_SECRET | GitHub OAuth app settings. Do it together with the preview OAuth apps. |
AUTH_GOOGLE_SECRET | Google Cloud console for the OAuth client. |
AUTH_TWITTER_SECRET | X developer portal. |
VERCEL_CLIENT_SECRET | Vercel integration settings. There is no API to roll a client secret. |
GOOGLE_PRIVATE_KEY, GOOGLE_CLIENT_EMAIL | Service account florish@lacymorrow-com. The fleet's gcloud is authenticated as a different Google account and cannot see that project. |
STACK_SECRET_SERVER_KEY | Stack 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_KEY | Google AI Studio. Also unreferenced anywhere in src/: delete and revoke. |
AUTH_RESEND_KEY | Resend 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_KEY | Team-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
sensitiveinstead ofencrypted. A sensitive value cannot be read back, which would breakvercel 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_URLis 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
developmenttarget. Laptops still read production values for everything here. That is a separate decision, filed as LAC-5201.