Vite plugin
Vite draws a hard line through your configuration: variables prefixed with
VITE_ are compiled into the bundle you serve, and everything else stays in
the build process. That line is the right one — but a .env file sits on both
sides of it, so the file holding your Stripe secret key is the same file holding
your public analytics id, and one rename is the difference between the two.
@seekrit/sdk/vite resolves the environment from seekrit instead, and keeps
Vite's line exactly where Vite drew it:
npm install @seekrit/sdk
// vite.config.ts
import { defineConfig } from "vite";
import { seekritVite } from "@seekrit/sdk/vite";
export default defineConfig({
plugins: [seekritVite()],
});
That's the whole integration. Start the dev server and the plugin prints what it did — names only, never values:
[seekrit] 9 secrets loaded (cli, serve/development) · in the client bundle: VITE_API_URL, VITE_SENTRY_DSN
Nothing downstream changes. import.meta.env.VITE_API_URL works in client code,
process.env.DATABASE_URL works in vite.config.ts, in your other plugins, in
an SSR handler, and in anything Vitest runs.
What it actually does
The plugin resolves in Vite's config hook — before Vite reads its own
environment — and writes the values into process.env. Vite then picks up the
prefix-matching ones exactly as if they had come from a .env file, so the
behaviour is the behaviour you already know:
| Where the name is read | What it gets |
|---|---|
import.meta.env in client code | resolved names matching envPrefix (VITE_ by default) |
process.env in vite.config.ts, plugins, SSR, Vitest | every resolved name |
| A shell variable you set yourself | wins — seekrit does not overwrite it (override: true flips that) |
A .env file in the project | loses to seekrit, because process.env outranks env files in Vite |
The plugin declares enforce: "pre", so its config hook runs before other
plugins' and they see a populated process.env too — a Sentry or S3 upload
plugin reading process.env.SENTRY_AUTH_TOKEN needs no change.
The one rule: the bundle is public
A value compiled into client code is readable by anyone who loads the site. View
source, unminify, done — there is no such thing as a private VITE_ variable.
So the plugin refuses to blur that line:
- A non-prefixed name can never reach the browser through this plugin. There
is no option to expose
DATABASE_URLto client code, because that is never the thing you meant. - A prefixed name is public whatever it holds.
VITE_STRIPE_SECRET_KEYin your environment is a published credential, and the plugin says so at startup rather than letting you find out from a minified 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, and only ever narrows:
seekritVite({ expose: "prefixed" }) // default: every VITE_* name, as Vite would
seekritVite({ expose: ["VITE_API_URL"] }) // just this one
seekritVite({ expose: "none" }) // nothing in the bundle
expose: "none" is worth knowing about for an SSR app: the prefixed values are
still injected, just after Vite has read its environment, so the server and
your config see them and the bundle does not. Listing a non-prefixed name in
expose is an error naming the trap — rename the secret if you really do want
it published.
This plugin is not a way to give browser code a third-party API key. Nothing is: a key in a bundle is a key you have given away. Call the third party from a server route, or from the egress proxy, which holds the credential and swaps it in on the way out.
How it authenticates
By default (source: "auto") the plugin uses whichever credential is there:
$SEEKRIT_TOKENif it is set — a service token bound to one app environment. This is what CI and deploy builds use, and it needs nothing installed.- Otherwise the
seekritCLI, which can resolve from aseekrit loginsession. A teammate who has runseekrit loginclones the repo, runsnpm run dev, and gets the environment — no token to mint, hand around, or leave in a dotfile. They will be asked for their passphrase, unlessSEEKRIT_PASSPHRASEis set.
Session auth is not bound to an environment, so tell the plugin which one to read:
seekritVite({ app: "storefront", env: "development" })
Pin one source with source: "token" or source: "cli" when "whichever is
there" is too loose — a CI job that must never silently fall back to a developer
session, say.
Deploying
Set SEEKRIT_TOKEN as a build environment variable on your host (Vercel,
Netlify, Cloudflare Pages, GitHub Actions) and the same config resolves at build
time. It is the only variable that host needs to hold:
SEEKRIT_TOKEN=skt_… # in the platform's build environment settings
Build-time resolution means the values are baked into whatever the build emits
— the prefixed ones into the bundle, the rest into anything your build writes.
For a server that should read secrets at run time instead, resolve in the
server process with the SDK, wrap it with
seekrit run, or push the values into the platform's own
environment with third-party sync.
Frameworks built on Vite
The plugin is a plain Vite plugin, so it goes in the same plugins array
whatever is on top:
| Framework | Notes |
|---|---|
| SvelteKit | $env/static/private and $env/dynamic/private read process.env, so every non-prefixed secret is already there. Set envPrefix: "PUBLIC_" so the plugin's public line is the same one $env/*/public uses |
| Astro | Same shape — set envPrefix: "PUBLIC_" to match Astro's public prefix |
| Nuxt | Add it through vite: { plugins: [...] } in nuxt.config.ts. runtimeConfig reads process.env at build; runtimeConfig.public is the client half, so keep those names under whatever prefix you set |
| Remix / React Router | Works as-is. Loaders read process.env on the server, so most secrets need no prefix at all |
| Vitest | Picks up vite.config.ts, so tests get the environment with no extra wiring. Point it at a dedicated test environment rather than production — see below |
| Storybook, Ladle, Histoire | Any tool that resolves a Vite config gets the same treatment |
A framework can draw a second public line, and the plugin only knows about
Vite's. SvelteKit, Astro, and Nuxt each publish variables under their own
prefix (PUBLIC_, NUXT_PUBLIC_) by reading process.env themselves, after
the plugin has written to it. Set Vite's envPrefix to that same prefix — an
array if you use more than one — so expose governs the names your framework
will publish. Leave them different and a PUBLIC_ name will reach the browser
through the framework while the plugin reports nothing in the bundle.
Vitest
Because Vitest resolves the same config, integration tests can hold real
credentials without a .env.test:
export default defineConfig({
plugins: [seekritVite({ app: "storefront", env: "test" })],
});
Bind that token (or those flags) to a test environment. A test suite is exactly the kind of thing that gets run on a laptop, in CI, and by an AI agent, and it should not be able to decrypt production.
Or skip the plugin
seekrit run -- vite does the same job from outside — it resolves, injects
environment variables into the child process, and Vite reads them as usual. Both
are the same rung of the ladder: the values live in the process, so anything the
build runs can read them.
| Use | When |
|---|---|
seekrit run -- vite | You control the command. Nothing to install in the project, works for any tool, not just Vite |
| The plugin | You don't control the command — a platform's build step, an editor running Vitest, a monorepo task runner, npm run dev for a teammate who won't remember a prefix |
They compose, but there is no point running both: seekrit run has already put
the values in the child's environment, and the plugin leaves anything already in
process.env alone — so the second resolve changes nothing. Pick one.
Options
| Option | Default | What it does |
|---|---|---|
expose | "prefixed" | Which resolved names client code may read: "prefixed", "none", or a list of prefixed names |
source | "auto" | "token", "cli", or "auto" (token if set, else the CLI session) |
token / apiUrl | $SEEKRIT_TOKEN / $SEEKRIT_API_URL | Credential and API base URL |
app / env / branch / org | — | Which environment to read, for the CLI source. A token already names one |
with | — | { groupSlug: envSlug } overrides — pull a different slice of a composed group |
interpolate | true | Expand ${OTHER_SECRET} references |
override | false | Overwrite variables already in process.env |
optional | false | Warn and continue instead of failing when resolving fails |
apply | — | "serve" or "build" to limit the plugin to one of them |
log | true | Print the one-line startup summary |
onLoad | — | Called once with { names, exposed, source, command, mode } — names only |
cliPath | seekrit | The CLI executable to shell out to |
If it didn't work
| Symptom | Cause |
|---|---|
import.meta.env.VITE_X is undefined | The name isn't prefixed with your envPrefix, or expose doesn't include it. The startup line lists exactly what went into the bundle |
| A value is right in the dashboard, stale in the app | Something outranks seekrit: a shell variable you exported, or a value Vite already inlined. override: true beats the former; restart the dev server for the latter — import.meta.env is compiled in, so a changed secret needs a restart, not an HMR update |
| The dev server hangs at startup | The CLI is prompting for a passphrase on a terminal you can't see (an editor-run task). Set SEEKRIT_PASSPHRASE, or use a service token |
the seekrit CLI is not on PATH | No SEEKRIT_TOKEN was set, so the plugin fell back to the CLI. Install it (npm i -g @seekrit/cli), set a token, or pass cliPath |
| The build fails with a resolve error | By design — a bundle whose config silently resolved to nothing is worse than a failed build. optional: true if the values really are optional |
A PUBLIC_-prefixed name reached the browser anyway | Your framework published it, not the plugin. Align Vite's envPrefix with the framework's public prefix — see the callout above |
Zero-knowledge, still
The plugin resolves through the same read path as every other client: the API returns ciphertext and a data key wrapped to your token, and decryption happens in the Vite process. Nothing about running inside a build tool changes what the server can see — see the encryption model.
What running inside a build tool does change is who can read the plaintext
afterwards: every value is in the environment of a process that runs plugin code,
test code, and postinstall scripts. That is the same boundary as a .env file
minus the file, which is the honest description of what this replaces.