seekrit
Docs/Vite plugin

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 readWhat it gets
import.meta.env in client coderesolved names matching envPrefix (VITE_ by default)
process.env in vite.config.ts, plugins, SSR, Vitestevery resolved name
A shell variable you set yourselfwins — seekrit does not overwrite it (override: true flips that)
A .env file in the projectloses 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_URL to client code, because that is never the thing you meant.
  • A prefixed name is public whatever it holds. VITE_STRIPE_SECRET_KEY in 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.

caution

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_TOKEN if 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 seekrit CLI, which can resolve from a seekrit login session. A teammate who has run seekrit login clones the repo, runs npm run dev, and gets the environment — no token to mint, hand around, or leave in a dotfile. They will be asked for their passphrase, unless SEEKRIT_PASSPHRASE is 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
note

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:

FrameworkNotes
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
AstroSame shape — set envPrefix: "PUBLIC_" to match Astro's public prefix
NuxtAdd 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 RouterWorks as-is. Loaders read process.env on the server, so most secrets need no prefix at all
VitestPicks 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, HistoireAny tool that resolves a Vite config gets the same treatment
caution

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.

UseWhen
seekrit run -- viteYou control the command. Nothing to install in the project, works for any tool, not just Vite
The pluginYou 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

OptionDefaultWhat 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_URLCredential and API base URL
app / env / branch / orgWhich environment to read, for the CLI source. A token already names one
with{ groupSlug: envSlug } overrides — pull a different slice of a composed group
interpolatetrueExpand ${OTHER_SECRET} references
overridefalseOverwrite variables already in process.env
optionalfalseWarn and continue instead of failing when resolving fails
apply"serve" or "build" to limit the plugin to one of them
logtruePrint the one-line startup summary
onLoadCalled once with { names, exposed, source, command, mode } — names only
cliPathseekritThe CLI executable to shell out to

If it didn't work

SymptomCause
import.meta.env.VITE_X is undefinedThe 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 appSomething 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 startupThe 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 PATHNo 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 errorBy 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 anywayYour 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.