Skip to content

Environment Variables in SPAs: Patterns and Pitfalls

Client vs server secrets in SPAs: Vite VITE_* honesty, what actually ships to the browser, and the failure modes that leak credentials.

4 min read
Environment Variables
Vite
SPA
Security
Frontend
Config

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

KindLives whereExamples
Public configBundled into client JSAPI base URL, feature flags that are not secrets, analytics write keys you already accept as public
Server secretsServer / CI / edge onlyDB URLs, private API keys, signing secrets, admin tokens
Build-time secretsCI memory, never the artifactnpm 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.example lists 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.