Overview

A secret is any value that grants access if leaked: database passwords, signing secrets, third-party API tokens, service keys. Secrets stay on the server, out of git, in a platform secret store. Public configuration (API base URLs, feature flags, publishable keys) may ship to the browser. Misclassifying one value is the difference between a config bug and an account takeover.

Treat every client-prefixed variable as public

Build tools inline browser-facing variables at build time. NEXT_PUBLIC_* values end up in the Next.js bundle (Next.js docs); VITE_* values are exposed on import.meta.env in client code (Vite docs). Once a value ships with that prefix, assume the public has read it. Never put a secret behind either prefix, and never set Vite’s envPrefix to an empty string, which exposes every variable.

Never ship a Supabase secret key to the client

The classic leak is a Supabase service key behind NEXT_PUBLIC_ or VITE_. Secret keys bypass Row Level Security, so in a bundle they are a public admin password. Supabase now issues publishable keys (sb_publishable_..., safe in browsers, bound by RLS) and secret keys (sb_secret_..., server only; Supabase rejects them from browsers with a 401). The legacy JWT anon and service_role keys are being deprecated by the end of 2026, so migrate (API keys). Enforce access with supabase-rls; see supabase for the key split. The same rule covers Stripe secret keys, webhooks signing secrets, and SMTP credentials.

Never commit .env

Add .env, .env.local, and .env.*.local to .gitignore before the first commit, and commit a .env.example with every key and empty values so contributors know what to set:

# .env.example
DATABASE_URL=
SUPABASE_SECRET_KEY=
NEXT_PUBLIC_SUPABASE_URL=

Language-specific loading and validation: python-security.

Scan the repo and CI for leaks

Fail the build when a secret appears. Gitleaks runs as a pre-commit hook or as gitleaks/gitleaks-action in CI. TruffleHog classifies 800-plus secret types and verifies them against the live API, so --results=verified reports only active credentials. Scan full git history, not just the working tree; a deleted secret still lives in past commits.

Inject secrets at runtime from a store

Use per-environment platform variables for deployed apps (vercel-env-vars), Supabase secrets for Edge Functions, and GitHub Actions secrets for CI. In CI, prefer OIDC so workflows exchange short-lived tokens instead of holding long-lived cloud keys (github-secrets). Aim for fewer standing secrets and shorter lifetimes.

Rotate on exposure and on a schedule

Treat any secret that touched git, a log, a chat message, or a client bundle as burned. Rotate it now, then audit access logs for use during the exposure window. Deleting the commit does not help; the value is already cloned and indexed. Also rotate long-lived credentials on a fixed schedule to bound a quiet leak. Confirm rotation coverage in pre-launch-checklist.