# 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:

```bash
npm install @seekrit/sdk
```

```ts
// 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](#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_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:

```ts
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.

> **Warning:** 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](/docs/guides/agent-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](/docs/guides/service-tokens)
  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:

```ts
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:

```bash
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](/docs/guides/sdks), wrap it with [`seekrit run`](/docs/guides/cli), or push the values into the platform's own environment with [third-party sync](/docs/guides/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 |

> **Warning:** **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`:

```ts
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](/docs/guides/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](/docs/concepts/encryption).

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.
