Vite environment variables: keeping API keys out of the bundle
Frameworks
Vite compiles every variable that starts with VITE_ into the JavaScript it
serves. Anything else stays in the build process. That rule is fine. The problem
is that the file most people use to hold those variables, .env, sits on both
sides of it: VITE_API_URL and STRIPE_SECRET_KEY live one line apart, and
renaming the second one to VITE_STRIPE_SECRET_KEY publishes it to every
visitor.
The fix is to stop keeping a .env file and load the environment from a secrets
store at startup instead. With seekrit that is a plugin:
npm install @seekrit/sdk
// vite.config.ts
import { defineConfig } from "vite";
import { seekritVite } from "@seekrit/sdk/vite";
export default defineConfig({
plugins: [seekritVite()],
});
Run npm run dev and it prints what it loaded, names only:
[seekrit] 9 secrets loaded (cli, serve/development) · in the client bundle: VITE_API_URL, VITE_SENTRY_DSN
import.meta.env.VITE_API_URL keeps working in client code.
process.env.DATABASE_URL keeps working in vite.config.ts, in other plugins,
in SSR handlers, and in Vitest. Nothing downstream changes.
How does Vite decide what goes in the bundle?
Vite reads process.env and any .env, .env.local, .env.[mode] files in
the project root. Names matching envPrefix (default VITE_) are exposed on
import.meta.env and inlined into the client build as string literals. The
rest is available only to Node code running during the build.
Two consequences people miss:
- A
VITE_variable is public no matter what it holds. View source, unminify, and it's there. There is no such thing as a privateVITE_value. process.envoutranks.envfiles. If a variable is already set in the shell, Vite uses that and ignores the file. This is what makes loading from a secrets store work: put the values inprocess.envbefore Vite reads its environment and the.envfile becomes unnecessary.
Can I use a secret API key in Vite client code?
No. Not with this plugin, not with a .env file, not with any build tool. A key
in a bundle is a key you have given away. Call the third party from a server
route, or from an egress proxy that holds the credential and adds it on the
way out. The plugin refuses to help you blur the line:
- A non-prefixed name can never reach the browser through it. There is no
option to expose
DATABASE_URLto client code. - A prefixed name that looks like a credential gets a warning at startup instead of a quiet appearance in the bundle:
[seekrit] VITE_STRIPE_SECRET_KEY will be readable by anyone who loads the site — a prefixed name is public. Rename it, or set expose: "none"
expose narrows what crosses into the bundle. It never widens it:
seekritVite({ expose: "prefixed" }) // default: every VITE_* name, as Vite would
seekritVite({ expose: ["VITE_API_URL"] }) // only this one
seekritVite({ expose: "none" }) // nothing in the bundle
expose: "none" is useful for SSR apps. The prefixed values are still injected
for the server and the config, just after Vite has read its environment, so the
bundle never sees them.
Where do the values come from?
The plugin resolves in Vite's config hook, before Vite reads its own
environment, and writes the result into process.env. It authenticates with
whichever credential is available:
| Credential | When it's used |
|---|---|
SEEKRIT_TOKEN in the environment | CI and deploy builds. A service token bound to one app environment. Nothing to install. |
A seekrit login session | Local development. A teammate clones the repo, runs npm run dev, and gets the environment. No token to mint or share. |
A login session isn't bound to an environment, so name one:
seekritVite({ app: "storefront", env: "development" })
Values are encrypted before they reach seekrit and decrypted inside the Vite process. The service only ever stores ciphertext, which is described in the encryption model.
Deploying on Vercel, Netlify, or Cloudflare Pages
Set one build variable, SEEKRIT_TOKEN, in the platform's settings. The same
config resolves at build time and the host holds nothing else. Keep in mind
that build-time resolution bakes the values into whatever the build emits: the
prefixed names into the bundle (by design), the rest into anything your build
writes to disk.
SvelteKit, Astro, Nuxt
The plugin goes in the same plugins array. One trap: these frameworks draw
their own public line under a different prefix (PUBLIC_, NUXT_PUBLIC_) by
reading process.env themselves. Set Vite's envPrefix to match, so expose
governs the names the framework will publish:
export default defineConfig({
envPrefix: "PUBLIC_",
plugins: [seekritVite()],
});
Leave them different and a PUBLIC_ variable reaches the browser through the
framework while the plugin reports nothing in the bundle.
Vitest
Vitest resolves the same vite.config.ts, so tests get the environment with no
extra setup and no .env.test. Point it at a test environment rather than
production:
plugins: [seekritVite({ app: "storefront", env: "test" })],
A test suite runs on laptops, in CI, and increasingly under coding agents. It should not be able to decrypt production.
When import.meta.env.VITE_X is undefined
| Symptom | Cause |
|---|---|
import.meta.env.VITE_X is undefined | The name isn't prefixed, or expose excludes it. The startup line lists exactly what went into the bundle. |
| Dashboard value is right, app is stale | A shell variable outranks seekrit (override: true fixes that), or Vite already inlined the old value. Restart the dev server; import.meta.env is compiled in, not hot-reloaded. |
| Dev server hangs at startup | The CLI is asking for a passphrase on a terminal you can't see. Set SEEKRIT_PASSPHRASE or use a service token. |
the seekrit CLI is not on PATH | No SEEKRIT_TOKEN, so the plugin fell back to the CLI. Install it with npm i -g @seekrit/cli, or set a token. |
Or skip the plugin
seekrit run -- vite does the same job from outside. It resolves, puts the
values in the child process's environment, and Vite reads them as usual. Use
that when you control the command. Use the plugin when you don't: a platform's
build step, an editor running Vitest, or a monorepo task runner. Running both
is harmless but pointless; the plugin leaves anything already in process.env
alone.
The full option list is in the Vite plugin guide.