# Environment and Secrets

## Overview

Secrets live in environment variables, never in git, and they live in several places at once: your machine, Vercel, GitHub Actions, and Supabase Edge Functions. This skill is how to set, sync, split, and rotate them across all four without ever leaking one. The golden rule underneath everything: a secret belongs in an env var, and the only env file in git is `.env.example` with placeholders.

## When to use this skill

- You are adding a new environment variable or secret.
- You are wiring a secret into Vercel, GitHub Actions, or a Supabase Edge Function.
- You are deciding whether a value can be `NEXT_PUBLIC_` or must stay server-only.
- A secret was pasted into a chat, a log, or a commit, and you need to respond.
- You are doing a scheduled rotation or setting up leak detection.

## The golden rule

- ✅ ALWAYS keep secrets in environment variables, injected at runtime.
- ✅ ALWAYS gitignore `.env` and every `.env.*` variant. The **only** tracked env file is `.env.example`, which holds placeholders, never real values.
- ❌ NEVER commit a real secret. Not in code, not in a config file, not in a comment, not "temporarily."

Your `.gitignore` should already contain:

```gitignore
.env
.env.*
!.env.example
```

The `!.env.example` re-includes the one file you do want tracked.

## Public vs server-only: the split that prevents leaks

In Next.js the prefix decides who sees the value.

- `NEXT_PUBLIC_*` is **inlined into the browser bundle at build time.** It is public. Anyone can read it in DevTools. Only non-secret config goes here (a publishable Stripe key, a PostHog project key, a public site URL).
- Everything else is **server-only** and never reaches the client.

- ✅ ALWAYS confirm a value is safe to be public before you give it a `NEXT_PUBLIC_` prefix.
- ❌ NEVER prefix a secret with `NEXT_PUBLIC_`. A service-role key or Stripe secret key with that prefix is a shipped credential.

The tell: a "publishable" or "anon" key is designed to be public; a "secret", "service-role", or "private" key never is.

## Keep `.env.example` in sync

`.env.example` is the contract: it tells the next developer (or agent) exactly which variables the app needs. Keep it current.

- ✅ ALWAYS add a documented placeholder line to `.env.example` in the **same PR** that introduces a new variable.
- ✅ ALWAYS use an obvious placeholder, never a real value.

```bash
# .env.example

# Server-only: Stripe secret key (dashboard -> Developers -> API keys)
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx

# Public: PostHog project key, safe to ship to the browser
NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxx
```

A new var without an `.env.example` line means the next person's build fails with no clue what is missing.

## The four places env vars live

### (a) Local: `.env.local`

Your machine's copy, gitignored, read by `next dev`. Populate it once and keep it current by pulling from Vercel (below).

```bash
cp .env.example .env.local   # then fill in real values, or pull them:
vercel env pull .env.local
```

If you run parallel [worktrees](./worktrees.skill.md), symlink this one file rather than copying it, so there is a single source of truth.

### (b) Vercel

Vercel injects env vars into builds and functions. Manage them with the CLI, scoped by environment.

```bash
vercel env ls                                  # list what is set, per environment
vercel env add STRIPE_SECRET_KEY production    # prompts for the value (not on the command line)
vercel env pull .env.local                     # sync everything down for local dev
vercel env rm STRIPE_SECRET_KEY preview        # remove one
```

Two things to get right on Vercel:

- **Public vs server-only** is the same `NEXT_PUBLIC_` rule as above. A `NEXT_PUBLIC_` var here is inlined into the client bundle.
- **Scopes** are `Production`, `Preview`, and `Development`. Set a var in **Preview** too, or your preview smoke-test runs against missing config. Env changes need a rebuild to take effect, since public values are inlined at build time.

See [deploy on Vercel](./deploy-vercel.skill.md) and the [deployment guide](../docs/08-deployment.md) for the full deploy context.

### (c) GitHub Actions

CI needs its own secrets (a deploy token, a test service key). Set them as **repo secrets** and never inline the value on the command line, where it lands in your shell history.

```bash
# read the value from stdin (you are prompted, nothing is logged)
gh secret set SUPABASE_ACCESS_TOKEN
# or from a file
gh secret set SUPABASE_ACCESS_TOKEN < token.txt
gh secret list
```

- ❌ NEVER `gh secret set NAME --body "sk_live_..."`. That value is now in your shell history and any screen capture.
- ✅ ALWAYS pipe from stdin or a file, then delete the file.

For **non-secret** CI config (a production URL, a project id, a region), use repo **variables**, not secrets. Variables are readable in logs, which is fine for non-sensitive values and makes debugging easier.

```bash
gh variable set PRODUCTION_URL --body "https://app.example.com"
gh variable list
```

In a workflow, secrets come from `secrets.*` and variables from `vars.*`:

```yaml
env:
  SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
  PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
```

### (d) Supabase Edge Functions

Edge Functions have their own secret store, separate from your app's env. Set them with the Supabase CLI.

```bash
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_xxx
supabase secrets set --env-file ./supabase/.env   # bulk set from a gitignored file
supabase secrets list
```

Inside the function they read from the environment (`Deno.env.get('STRIPE_WEBHOOK_SECRET')`). The same gitignore rule applies to any `supabase/.env` file you use for bulk-setting.

## Rotation

Secrets are perishable. Treat them that way.

- ✅ ALWAYS rotate immediately on any suspected exposure. A secret pasted into a chat, printed to a log, committed to git (even if reverted), or shared in a screenshot is **compromised**. Reverting the commit does not un-leak it; the value is in history and in anyone's clone. Rotate, do not just delete.
- ✅ ALWAYS rotate production secrets on a cadence (90 days is a sane default) even without a known leak.
- ✅ ALWAYS rotate in every place the secret lives (the reference table below) so you do not leave a stale valid copy behind.

Rotation is: issue a new value at the provider, set it in every place it lives, deploy, then revoke the old value.

## Detecting leaks

Catch a leak before it ships, not after.

- Run a secret scanner in CI and, ideally, as a pre-commit hook. **GitGuardian** and its CLI **ggshield** scan diffs for credential patterns.

```bash
# scan the working tree / staged changes before committing
ggshield secret scan pre-commit
# scan the whole repo history for anything already committed
ggshield secret scan repo .
```

- Wire `ggshield secret scan pre-commit` into your Husky pre-commit hook so a secret cannot be committed in the first place. If it fires, do not "just this once" past it: rotate the value and remove it from the staged change.

For the planning-time version of this thinking (trust boundaries, what a leak would expose), see [security in planning](./security-in-planning.skill.md).

## Quick reference: this secret lives in -> set it with

| Where it is used | Set it with | Public allowed? |
| --- | --- | --- |
| Local dev | `.env.local` (from `.env.example` or `vercel env pull`) | `NEXT_PUBLIC_*` inlined by `next dev` |
| Vercel build + functions | `vercel env add NAME <env>` | `NEXT_PUBLIC_*` inlined into client bundle |
| GitHub Actions (secret) | `gh secret set NAME` (stdin/file) | No, secrets are masked |
| GitHub Actions (non-secret) | `gh variable set NAME --body "..."` | Yes, that is the point |
| Supabase Edge Function | `supabase secrets set NAME=...` | Server-side only |
| The contract for all of them | `.env.example` placeholder line | Placeholder only, never real |

## Common mistakes

- ❌ **Committing `.env.local` or any real `.env`.** Only `.env.example` is tracked, with placeholders. Confirm your gitignore.
- ❌ **`NEXT_PUBLIC_` on a secret.** It is now shipped to every browser. Drop the prefix; that value is server-only.
- ❌ **Passing a secret on the command line** (`--body "sk_live_..."`). It lands in shell history and logs. Use stdin or a file.
- ❌ **Adding a var but not `.env.example`.** The next build fails with no explanation. Same PR, always.
- ❌ **Forgetting the Preview scope on Vercel.** Your preview smoke-test then runs against missing config and fails confusingly.
- ❌ **Reverting a leaked commit and calling it fixed.** The value is still in history. Rotate it.
- ❌ **Storing non-secret config as a masked secret in CI.** Use repo variables so logs stay debuggable.

## Related

- [deploy on Vercel](./deploy-vercel.skill.md): env scopes, the `NEXT_PUBLIC_` inlining rule, and rebuilds.
- [security in planning](./security-in-planning.skill.md): trust boundaries and what an exposed secret would reach.
- [worktrees](./worktrees.skill.md): symlink the one `.env.local` across parallel checkouts.
- [deployment guide](../docs/08-deployment.md) for the full human walkthrough.
