Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .bumpy/encrypted-env-blob.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"varlock": minor
"@varlock/nextjs-integration": patch
"@varlock/vite-integration": patch
---

add _VARLOCK_ENV_KEY support to encrypt env blob in build output
5 changes: 0 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@
- This monorepo uses **bumpy** (`@varlock/bumpy`) for version management
- Changeset files live in `.bumpy/` and are created with `bunx @varlock/bumpy add` (or `bun run bumpy:add`)
- Standard bump types: `major`, `minor`, `patch`
- **Isolated bump types**: `minor-isolated` and `patch-isolated` are natively supported
- These suppress dependency propagation — the package itself gets bumped but dependents are **not** automatically bumped
- Use **`minor-isolated`** for minor bumps that don't affect the library API consumed by dependents (e.g., CLI-only features in `varlock` that plugins/integrations don't depend on). This is the most common use case — because all packages are still on `0.x`, `^0.y.z` ranges treat minor bumps as out-of-range, which would otherwise cascade bumps to all dependents.
- `patch-isolated` exists but is rarely needed — patch bumps on `0.x` stay within `^` ranges and don't cascade
- `major-isolated` is intentionally **not** supported (major bumps must propagate to keep semver ranges valid)
- Non-interactive changeset creation (for CI/AI): `bumpy add --packages "pkg:minor" --message "description" --name "changeset-name"`

## Linting
Expand Down
26 changes: 26 additions & 0 deletions framework-tests/frameworks/nextjs/nextjs-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,32 @@ export function defineNextjsTests(nextVersion: number, testDir: string) {
],
});

nextEnv.describeScenario('encrypted env blob with _VARLOCK_ENV_KEY', {
command: buildCommand,
env: { _VARLOCK_ENV_KEY: '846a4cbdf4fefeff0da38d8f3766ffe50d8db12f8ce32849bb1e1a60ecb4ba0d' },
templateFiles: {
'app/page.tsx': 'pages/basic-page.tsx',
},
fileAssertions: [
{
description: 'runtime files contain encrypted blob (varlock:v1: prefix) instead of plaintext',
fileGlob: '.next/server/**/*runtime*.js',
shouldContain: ['varlock:v1:'],
shouldNotContain: ['super-secret-var'],
},
{
description: 'prerendered HTML still has correct values (build uses plaintext env)',
fileGlob: '.next/**/*.html',
shouldContain: [
'next-prefixed-public-var',
'unprefixed-public-var',
'sensitive-var-available',
],
shouldNotContain: ['super-secret-value'],
},
],
});

nextEnv.describeScenario('leaky edge page', {
command: buildCommand,
templateFiles: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import { varlockVitePlugin } from '@varlock/vite-integration';

export default defineConfig({
plugins: [varlockVitePlugin({ ssrInjectMode: 'resolved-env' })],
});
31 changes: 31 additions & 0 deletions framework-tests/frameworks/vite/vite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,37 @@ describe('Vite', () => {
});
});

// ---- Encrypted env blob ----

describe('encrypted env blob', () => {
viteEnv.describeScenario('SSR build with _VARLOCK_ENV_KEY encrypts the blob', {
command: 'vite build --ssr src/ssr-entry.ts',
env: { _VARLOCK_ENV_KEY: '846a4cbdf4fefeff0da38d8f3766ffe50d8db12f8ce32849bb1e1a60ecb4ba0d' },
templateFiles: {
'vite.config.ts': 'vite-configs/vite.config.resolved-env.ts',
'index.html': 'html/basic.html',
'src/ssr-entry.ts': 'pages/ssr-entry.ts',
},
fileAssertions: [
{
description: 'SSR output contains encrypted blob (varlock:v1: prefix)',
fileGlob: 'dist/*.js',
shouldContain: ['varlock:v1:'],
},
{
description: 'SSR output does not contain plaintext secret',
fileGlob: 'dist/*.js',
shouldNotContain: ['super-secret-value'],
},
{
description: 'public vars are still statically replaced',
fileGlob: 'dist/*.js',
shouldContain: ['public-test-value'],
},
],
});
});

// ---- Dev server ----

describe('dev server', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/integrations/nextjs/src/turbopack-runtime-inject.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { encryptEnvBlobSync } from 'varlock/encrypt-env';

function debug(...args: Array<any>) {
if (!process.env.DEBUG_VARLOCK_NEXT_INTEGRATION) return;
Expand All @@ -25,6 +26,11 @@ export function injectVarlockInitIntoTurbopackRuntime(nextDirPath: string) {
return;
}

let envPayload = rawEnv;
if (process.env._VARLOCK_ENV_KEY) {
envPayload = encryptEnvBlobSync(rawEnv, process.env._VARLOCK_ENV_KEY);
}

// Find turbopack runtime files ([turbopack]_runtime.js) and edge-wrapper files.
// Node.js SSR/build uses [turbopack]_runtime.js, while edge runtime uses
// edge-wrapper JS files (no [turbopack]_runtime.js exists for edge).
Expand Down Expand Up @@ -58,7 +64,7 @@ export function injectVarlockInitIntoTurbopackRuntime(nextDirPath: string) {
// Load both init bundles — server (full, node:zlib/node:http) and edge (no node builtins)
const initServerSrc = fs.readFileSync(require.resolve('varlock/init-server'), 'utf8');
const initEdgeSrc = fs.readFileSync(require.resolve('varlock/init-edge'), 'utf8');
const envInline = `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(rawEnv)};`;
const envInline = `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(envPayload)};`;

// The CJS init bundles use `exports.X = ...` at the end, so we must provide
// a dummy `exports` object when wrapping in an IIFE to avoid ReferenceError.
Expand Down
9 changes: 7 additions & 2 deletions packages/integrations/nextjs/src/webpack-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { patchGlobalServerResponse } from 'varlock/patch-server-response';

import { type SerializedEnvGraph } from 'varlock';
import { encryptEnvBlobSync } from 'varlock/encrypt-env';
import type { NextConfig } from 'next';

const WEBPACK_PLUGIN_NAME = 'VarlockNextWebpackPlugin';
Expand Down Expand Up @@ -173,8 +174,12 @@ export function createWebpackConfigFn(
// inline the resolved env so it's baked into the build
// this removes the need for a .env.production.local file on platforms like Vercel
const rawEnv = process.env.__VARLOCK_ENV;
const envInline = rawEnv
? `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(rawEnv)};`
let envPayload = rawEnv;
if (rawEnv && process.env._VARLOCK_ENV_KEY) {
envPayload = encryptEnvBlobSync(rawEnv, process.env._VARLOCK_ENV_KEY);
}
const envInline = envPayload
? `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(envPayload)};`
: '';

const updatedSourceStr = [
Expand Down
9 changes: 8 additions & 1 deletion packages/integrations/vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { patchGlobalServerResponse } from 'varlock/patch-server-response';
import { patchGlobalResponse } from 'varlock/patch-response';
import { createDebug, type SerializedEnvGraph } from 'varlock';
import { execSyncVarlock } from 'varlock/exec-sync-varlock';
import { encryptEnvBlobSync } from 'varlock/encrypt-env';

import { createReplacerTransformFn, SUPPORTED_FILES } from './transform';

Expand Down Expand Up @@ -274,7 +275,13 @@ See https://varlock.dev/integrations/vite/ for more details.
);
} else {
if (ssrInjectMode === 'resolved-env') {
injectCode.push(`globalThis.__varlockLoadedEnv = ${JSON.stringify(varlockLoadedEnv)};`);
const serialized = JSON.stringify(varlockLoadedEnv);
if (process.env._VARLOCK_ENV_KEY) {
const encrypted = encryptEnvBlobSync(serialized, process.env._VARLOCK_ENV_KEY);
injectCode.push(`globalThis.__varlockLoadedEnv = ${JSON.stringify(encrypted)};`);
} else {
injectCode.push(`globalThis.__varlockLoadedEnv = ${JSON.stringify(varlockLoadedEnv)};`);
}
}

// inject custom entry code from integrations
Expand Down
45 changes: 45 additions & 0 deletions packages/varlock-website/src/content/docs/integrations/nextjs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,51 @@ varlock run -- node .next/standalone/server.js
```


---

## Encrypting the env blob ||encrypting-the-env-blob||

When deploying to platforms like Vercel, varlock injects the fully resolved env data into your build output so it's available at runtime without needing the CLI or filesystem access. By default, this blob is plaintext JSON — meaning anyone with access to the build artifact can read your secrets.

To encrypt this blob, set the `_VARLOCK_ENV_KEY` environment variable with a 256-bit hex key. When present at build time, the blob is encrypted with AES-256-GCM before being injected. At runtime, the init bundle decrypts it using the same key from the runtime environment.

<Steps>

1. **Generate and set the key on Vercel**

This one-liner generates a key and sets it as a sensitive env var on Vercel for all environments:

```bash
varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY production preview development --sensitive
```

Or generate and set it manually on your platform — the key must be available at both **build time** (for encryption) and **runtime** (for decryption):

<ExecCommandWidget command="varlock generate-key" showBinary={false} />

3. **Optionally add it to your `.env.local` for local builds**

If you want local builds to also encrypt the blob (e.g., to test the flow), add the key to `.env.local`:

```env title=".env.local"
_VARLOCK_ENV_KEY=your-64-char-hex-key-here
```

</Steps>

:::tip
You can define `_VARLOCK_ENV_KEY` in your `.env.schema` to enable validation (e.g., required in production). It will automatically be excluded from the injected blob and from type generation — it's infrastructure, not application config.

```env-spec title=".env.schema"
# @sensitive
_VARLOCK_ENV_KEY=
```
:::

:::note
The encryption key is **never baked into the build**. It must always come from the runtime environment (e.g., a Vercel environment variable). The encrypted blob is useless without it.
:::

---
## Troubleshooting

Expand Down
53 changes: 52 additions & 1 deletion packages/varlock-website/src/content/docs/integrations/vite.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ varlockVitePlugin({ ssrInjectMode: 'auto-load' })

- `init-only` - injects varlock initialization code, but does not load the env vars. You must still boot your app via `varlock run` in this mode.
- `auto-load` - injects `import 'varlock/auto-load';` to load your resolved env via the varlock CLI
- `resolved-env` - injects the fully resolved env data into your built code. This is useful in environments like Vercel/Cloudflare/etc where you have no control over your build command, and limited access to use CLI commands or the filesystem
- `resolved-env` - injects the fully resolved env data into your built code. This is useful in environments like Vercel/Cloudflare/etc where you have no control over your build command, and limited access to use CLI commands or the filesystem. See the [encrypting the env blob](#encrypting-the-env-blob) section for more information.

**If not specified, we will attempt to infer the correct mode based on the presence of other vite plugins and environment variables, which give us hints about how your application will be run.**
Otherwise defaulting to `init-only`.
Expand Down Expand Up @@ -241,6 +241,57 @@ All non-sensitive items are bundled at build time via `ENV`, while `import.meta.



---

## Encrypting the env blob ||encrypting-the-env-blob||

When using `ssrInjectMode: 'resolved-env'`, varlock injects the fully resolved env data into your SSR build output. By default, this blob is plaintext JSON — meaning anyone with access to the build artifact can read your secrets.

To encrypt this blob, set the `_VARLOCK_ENV_KEY` environment variable with a 256-bit hex key. When present at build time, the blob is encrypted with AES-256-GCM before being injected. At runtime, it's decrypted using the same key from the runtime environment.

<Steps>

1. **Generate and set the key on your platform**

For Vercel, this one-liner generates a key and sets it as a sensitive env var for all environments:

```bash
varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY production preview development --sensitive
```

Or generate and set it manually — the key must be available at both **build time** (for encryption) and **runtime** (for decryption):

<ExecCommandWidget command="varlock generate-key" showBinary={false} />

2. **Ensure you're using `resolved-env` mode**

Encryption only applies when the blob is inlined into the build:

```ts title="vite.config.ts"
varlockVitePlugin({ ssrInjectMode: 'resolved-env' })
```

3. **Optionally add it to your `.env.local` for local builds**

```env title=".env.local"
_VARLOCK_ENV_KEY=your-64-char-hex-key-here
```

</Steps>

:::tip
You can define `_VARLOCK_ENV_KEY` in your `.env.schema` to enable validation. It will automatically be excluded from the injected blob and from type generation.

```env-spec title=".env.schema"
# @sensitive
_VARLOCK_ENV_KEY=
```
:::

:::note
The encryption key is **never baked into the build**. It must always come from the runtime environment. The encrypted blob is useless without it.
:::

---

## Reference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ You can also temporarily opt out by setting the `VARLOCK_TELEMETRY_DISABLED` env
</div>

<div>
### `varlock generate-key` ||generate-key||

Generates a random 256-bit encryption key for use with `_VARLOCK_ENV_KEY`. This key is used to encrypt the resolved env blob that gets baked into your build output on certain frameworks/platforms.

```bash
varlock generate-key
```

See the [Next.js](/integrations/nextjs/#encrypting-the-env-blob) and [Vite](/integrations/vite/#encrypting-the-env-blob) integration docs for setup instructions.

### `varlock help` ||help||

Displays general help information, alias for `varlock --help`
Expand Down
5 changes: 5 additions & 0 deletions packages/varlock/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@
"types": "./dist/runtime/init-edge.d.cts",
"default": "./dist/runtime/init-edge.cjs"
},
"./encrypt-env": {
"ts-src": "./src/runtime/crypto.ts",
"types": "./dist/runtime/crypto.d.ts",
"default": "./dist/runtime/crypto.js"
},
"./exec-sync-varlock": {
"ts-src": "./src/lib/exec-sync-varlock.ts",
"types": "./dist/lib/exec-sync-varlock.d.ts",
Expand Down
2 changes: 2 additions & 0 deletions packages/varlock/src/cli/cli-executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { commandSpec as explainCommandSpec } from './commands/explain.command';
import { commandSpec as scanCommandSpec } from './commands/scan.command';
import { commandSpec as typegenCommandSpec } from './commands/typegen.command';
import { commandSpec as installPluginCommandSpec } from './commands/install-plugin.command';
import { commandSpec as generateKeyCommandSpec } from './commands/generate-key.command';
// import { commandSpec as loginCommandSpec } from './commands/login.command';
// import { commandSpec as pluginCommandSpec } from './commands/plugin.command';

Expand Down Expand Up @@ -60,6 +61,7 @@ subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () =>
subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command')));
subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => await import('./commands/typegen.command')));
subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command')));
subCommands.set('generate-key', buildLazyCommand(generateKeyCommandSpec, async () => await import('./commands/generate-key.command')));
// subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command')));
// subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command')));

Expand Down
24 changes: 24 additions & 0 deletions packages/varlock/src/cli/commands/generate-key.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { define } from 'gunshi';

import { generateEncryptionKeyHex } from '../../runtime/crypto';
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';

export const commandSpec = define({
name: 'generate-key',
description: 'Generate an encryption key for encrypting the env blob in deployments',
args: {},
});

export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async () => {
const key = generateEncryptionKeyHex();

console.log('');
console.log('Generated _VARLOCK_ENV_KEY:');
console.log('');
console.log(` ${key}`);
console.log('');
console.log('Set this as an environment variable on your deployment platform (e.g., Vercel, Cloudflare).');
console.log('When _VARLOCK_ENV_KEY is present at build time, the resolved env blob will be');
console.log('encrypted before being injected into the build output, and decrypted at runtime.');
console.log('');
};
3 changes: 3 additions & 0 deletions packages/varlock/src/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,9 @@ export class EnvGraph {
});
}
for (const itemKey of this.sortedConfigKeys) {
// _VARLOCK_ENV_KEY is used to encrypt/decrypt the blob itself — including it
// would be redundant (the runtime already has it via process.env) and wasteful.
if (itemKey === '_VARLOCK_ENV_KEY') continue;
const item = this.configSchema[itemKey];
serializedGraph.config[itemKey] = {
value: item.resolvedValue,
Expand Down
2 changes: 2 additions & 0 deletions packages/varlock/src/env-graph/lib/type-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ export async function generateTypes(graph: EnvGraph, lang: string, typesPath: st
// Skip items that exist only in env-specific sources
const items: Array<TypeGenItemInfo> = [];
for (const itemKey of graph.sortedConfigKeys) {
// _VARLOCK_ENV_KEY is infrastructure — not accessed via ENV proxy
if (itemKey === '_VARLOCK_ENV_KEY') continue;
const configItem = graph.configSchema[itemKey];
if (!configItem.defsForTypeGeneration.length) continue;
items.push(await configItem.getTypeGenInfo());
Expand Down
Loading
Loading