seekrit
← all posts

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 private VITE_ value.
  • process.env outranks .env files. 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 in process.env before Vite reads its environment and the .env file 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_URL to 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:

CredentialWhen it's used
SEEKRIT_TOKEN in the environmentCI and deploy builds. A service token bound to one app environment. Nothing to install.
A seekrit login sessionLocal 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

SymptomCause
import.meta.env.VITE_X is undefinedThe name isn't prefixed, or expose excludes it. The startup line lists exactly what went into the bundle.
Dashboard value is right, app is staleA 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 startupThe 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 PATHNo 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.