Lint speed is a quality-of-life problem. Environment variables in SPAs are a
security problem wearing a config coat. Teams keep treating .env like a
private drawer. In a Vite (or similar) SPA, anything prefixed for the client is
a public constant that happens to be injected at build time.
What can live in the browser, what must not, and the failure modes that ship secrets to every user with DevTools.
The only model that matters
| Kind | Lives where | Examples |
|---|---|---|
| Public config | Bundled into client JS | API base URL, feature flags that are not secrets, analytics write keys you already accept as public |
| Server secrets | Server / CI / edge only | DB URLs, private API keys, signing secrets, admin tokens |
| Build-time secrets | CI memory, never the artifact | npm tokens used during install, not VITE_* |
If a value is in the client bundle, assume an attacker has it. Prefixes and
.gitignore do not change that.
Vite's VITE_* contract
Vite only exposes env vars prefixed with VITE_ to client code (via
import.meta.env). That is a seatbelt, not encryption:
ts// src/lib/config.ts const apiBase = import.meta.env.VITE_API_BASE_URL; if (!apiBase) { throw new Error('VITE_API_BASE_URL is required at build time'); } export const config = { apiBase, // Fine: public URL sentryDsn: import.meta.env.VITE_SENTRY_DSN, } as const;
bash# .env.example: document the public surface VITE_API_BASE_URL=https://api.example.com VITE_SENTRY_DSN= # NEVER put these in VITE_*: they belong on the server # DATABASE_URL= # STRIPE_SECRET_KEY= # SESSION_SECRET=
Anything without the prefix is available to Vite config and SSR server code, not
to import.meta.env in browser modules. People still leak secrets by reading
process.env.SECRET in a file that accidentally ships to the client, or by
inlining secrets in define / JSON.stringify "just for local."
Patterns that stay sane
1. Public config module, one door
Centralize client env reads. Ban scattered import.meta.env.VITE_* across the
tree so reviews can grep one module.
2. Runtime config when builds must be portable
Build-once, deploy-many needs values that are not baked at compile time:
html<!-- public/index.html, inject at serve time from the host --> <script> window.__ACME_CONFIG__ = { apiBase: '%API_BASE_URL%', }; </script>
ts// src/lib/runtime-config.ts type AcmConfig = { apiBase: string }; declare global { interface Window { __ACME_CONFIG__?: AcmConfig; } } export function getRuntimeConfig(): AcmConfig { const cfg = window.__ACME_CONFIG__; if (!cfg?.apiBase) throw new Error('Missing runtime config'); return cfg; }
Still public. You only gained environment portability, not secrecy.
3. BFF / server for anything privileged
Browser talks to your backend with cookies or short-lived tokens. Backend holds Stripe/OpenAI/partner keys. The SPA never sees them, not in env, not in localStorage "for convenience."
ts// Bad, will be reverse-engineered immediately const openai = new OpenAI({ apiKey: import.meta.env.VITE_OPENAI_KEY, // do not do this });
ts// Good: SPA calls your API; server holds the key await fetch(`${config.apiBase}/v1/summarize`, { method: 'POST', credentials: 'include', body: JSON.stringify({ text }), });
Failure modes that leak secrets
VITE_ on a private key "temporarily." Temporary becomes production. The
key is in every sourcemap and old Netlify deploy.
Copying server .env into the frontend package. Monorepos share files by
accident. Keep apps/web/.env and apps/api/.env separate; document which
prefix is public.
Logging env objects. console.log(import.meta.env) in a shared helper ships
more than you meant, including modes and any custom public keys.
Assuming .env is unreadable because it is gitignored. Gitignore stops
commits, not bundle inspection. Users download your JS.
SSR confusion. In Vite SSR / meta-frameworks, the same module may run on
server and client. A secret import on the server path that gets pulled into a
client graph is a classic leak. Mark server-only modules explicitly (framework
conventions: server-only, .server.ts, etc.).
Feature flags as security. Hiding an admin route behind VITE_SHOW_ADMIN is
UI cosplay. Authorization belongs on the server.
Checklist before merge
- Every
VITE_*/NEXT_PUBLIC_*value is acceptable on a billboard - No partner secret in frontend env files or CI "frontend" variable groups
-
.env.examplelists public keys only; secrets documented for server - Sourcemaps / preview deploys reviewed for accidental inlining
- Runtime injection (if used) still treated as public
Continuity
This pairs with the Sentry post: DSNs are usually public write endpoints with project constraints, still not a place to put auth tokens. Same rule, different product.
Takeaway
In a SPA, client env vars are public build-time constants. Use VITE_* (or
equivalent) only for values you would print in the README; keep real secrets on
a server you control, prefixes are seatbelts, not vaults.