Architecture note: separating public config from secrets in Vite builds
Vite inlines VITE_-prefixed variables into the browser bundle at build time. This note covers a minimal design that keeps secrets in Node-side config, operational checks to prevent leaks, and failure modes to avoid.
29 Mar 2026, 07:47 UTC

The problem: Vite inlines prefixed variables into public bundles
Vite replaces import.meta.env references at build time. Any variable whose name starts with the configured envPrefix (default VITE_) is embedded directly into the JavaScript that ships to the browser. The useful takeaway: if a credential, internal URL, or private key gets that prefix, it is not protected by the prefix—it is published. Treat the browser bundle as a public artifact and keep secrets in Node-side configuration.
Requirements for a minimal configuration boundary
A Vite app typically needs three things to stay safe:
- Public values that the client legitimately needs, such as an API base URL or a feature flag.
- Secrets that only the build process, dev server, or a backend should know.
- A repeatable build that produces the same inlined values in local development, CI, and production.
The constraint is that Vite has no runtime environment in the browser. process.env is undefined in client code unless you explicitly replace it, and import.meta.env is frozen at build time. That means the boundary between public and private must be decided before the bundle is emitted.
Smallest suitable design: non-prefixed secrets, prefixed public values
The smallest design that respects the boundary uses .env files for both categories, but only prefixes the public ones.
Example .env split
VITE_PUBLIC_API_URL=https://api.example.com
VITE_FEATURE_FLAG_NEW_UI=true
SECRET_API_KEY=sk_live_abc123
INTERNAL_API_URL=http://localhost:3001
In this layout, SECRET_API_KEY and INTERNAL_API_URL are invisible to client code because they lack the VITE_ prefix. They are still loaded into the build process and can be read in vite.config.ts with loadEnv.
Example vite.config.ts using loadEnv
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
// Empty prefix loads all variables, including secrets.
// Confine this to config, plugins, and server middleware.
const env = loadEnv(mode, process.cwd(), '')
const required = ['SECRET_API_KEY', 'INTERNAL_API_URL']
for (const key of required) {
if (!env[key]) {
throw new Error(`Missing required env var: ${key}`)
}
}
return {
server: {
proxy: {
'/api': {
target: env.INTERNAL_API_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
})
Run the dev server from the project root with npm run dev. The proxy target and any credentials live in the Node process, not in the browser bundle. If SECRET_API_KEY is missing, the build or dev server fails immediately with the error above.
Trust and data boundaries
- Browser bundle: public and untrusted. Every
VITE_value is visible in the page source or the emitted assets. - Build process and dev server: trusted.
loadEnvhere can read secrets safely. - Backend or proxy: trusted. Route sensitive calls through a backend, or through a dev-server proxy whose target and credentials are loaded with
loadEnvrather than written in client source.
Never read a secret in code that ships to the browser. If the client needs data from a protected API, proxy the request through the dev server during development and through a real backend in production.
Operational checks and CI gates
Build-time assertions
Add a check in vite.config.ts (or a small plugin) that every required public variable exists after loadEnv. Failing the build when a required VITE_ variable is missing prevents silent misconfiguration.
Asset scanning commands
Run these in the project root or in your CI runner after npm run build -- --mode production:
# Check for known secret patterns in emitted assets
grep -rE "sk_live_|SECRET_API_KEY|INTERNAL_API_URL" dist/ && exit 1 || echo "No forbidden strings found"
# Check for non-prefixed environment variable names that leaked into client code
grep -rE "SECRET_[A-Z_]+" dist/ && exit 1 || echo "No non-prefixed names found"
Expected check: both commands should print the success message and exit zero. If either finds a match, the CI job fails. Risk: grep returns non-zero when no matches are found, so the || echo pattern is required to avoid false failures. Adjust the patterns to match your actual secret naming conventions.
Common failure modes
- Committing
.envfiles with real credentials. Use.env.examplefor templates and add real.env*files to.gitignore. - Assuming the
VITE_prefix protects secrecy. It only controls exposure; anything prefixed is public. - Referencing
process.envin client code. In the browser this is undefined unless you have explicitly configured a replacement, and blanketdefinereplacements can pull server-only values into the bundle. - Building locally with a different mode than CI. Mode precedence means
.env.<mode>overrides.env. A localdevelopmentbuild can inline different values than a CIproductionbuild.
Conditions that would change the design
- Runtime configuration without a rebuild: if values must change without redeploying, fetch a config endpoint at startup instead of inlining with
VITE_. - SSR frameworks: server and client environments must diverge; the same variable may be secret on the server and public on the client.
- Monorepos: shared
.envfiles or a customenvDircan change which file wins. - Multiple deploy targets from one pipeline: build per target or inject runtime config rather than baking one set of values into a shared bundle.
- Vite's per-environment dev API: if you adopt environment-specific config scoping, the loading order and prefix behavior may differ from the defaults described here.
Limitations and verification
Vite's environment loading defaults—including envPrefix and dotenv expansion—can shift across major versions. Before relying on this design in production, check the Vite version in package.json and review its release notes for changes to env, envPrefix, and the environment API.
To verify locally, create a scratch value with the VITE_ prefix, log import.meta.env during development, and run a production build. Confirm that the key appears in the emitted assets and that non-prefixed secrets do not. This practical check confirms the boundary is working as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.