Documentation

Drop-in replacement for dotenv. One key in your .env, all your secrets encrypted in the cloud.

Why vault-env?

Your .env file has 30 secrets. Every developer has a copy. Every server has a copy. When you rotate a key, you update it everywhere manually. If someone's laptop gets stolen, every secret is compromised.

dotenvvault-env
Secrets in .envAll of themJust VAULT_KEY
Laptop stolenEverything exposedOne key to rotate
Rotate an API keyUpdate every serverPush once, all servers get it
Version historyNoneFull history + rollback
Device controlNoneApprove/revoke individual machines
Code changes to migrateZero

Quick Start

1. Install

terminal
# Node.js
npm install vault-env

# Python
pip install vault-env

2. Create a project

terminal
npx vault-env init --name my-app

This creates the project on the vault server, generates your VAULT_KEY, writes it to .env, and registers your machine as the first device (auto-approved).

3. Push your secrets

terminal
npx vault-env push --env production

Reads your .env file, encrypts every key-value pair (except VAULT_KEY itself), and uploads the encrypted blob. The server never sees the plaintext.

4. Replace dotenv

your-app
# Node.js
- require('dotenv').config()
+ require('vault-env').config()

# Python
- from dotenv import load_dotenv
- load_dotenv()
+ from vaultdotenv import load_vault
+ load_vault()

Done. If there's no VAULT_KEY, it falls back to plain dotenv behavior — so the migration is completely safe.

How It Works

architecture
┌──────────────────────────────────┐
│          Your Application        │
│                                  │
│  require('vault-env').config()   │
│         or load_vault()          │
└──────────────┬───────────────────┘
               │
               │ 1. Read VAULT_KEY from .env or environment
               │ 2. Derive auth key via HKDF
               │ 3. Sign request with HMAC-SHA256
               ▼
┌──────────────────────────────────┐
│      api.vaultdotenv.io          │
│     (Cloudflare Worker + D1)     │
│                                  │
│  • Validates HMAC signature      │
│  • Validates device hash         │
│  • Returns encrypted blob        │
│  • Logs access in audit table    │
│  • NEVER sees the decryption key │
└──────────────┬───────────────────┘
               │
               │ 4. Encrypted blob returned
               ▼
┌──────────────────────────────────┐
│         Client (local)           │
│                                  │
│  • Derives decryption key from   │
│    VAULT_KEY + device secret     │
│  • Decrypts with AES-256-GCM    │
│  • Injects into process.env     │
│    (or os.environ in Python)     │
│  • Caches encrypted blob locally │
└──────────────────────────────────┘

The server is a dumb encrypted storage layer. All cryptography happens on the client. A full database breach reveals nothing — the blobs are encrypted with keys the server has never seen.

Node.js Client

terminal
npm install vault-env

Basic Usage

app.js
// Async (recommended) — pulls fresh secrets from the vault
await require('vault-env').config();

// Options
await require('vault-env').config({
  path: '.env',               // Path to .env file (default: .env)
  environment: 'production',  // Environment name (default: NODE_ENV or 'development')
  vaultUrl: 'https://...',    // Vault server URL (default: api.vaultdotenv.io)
  override: false,            // Override existing env vars (default: false)
  cache: true,                // Cache secrets locally for offline fallback (default: true)
});

Synchronous Mode

script.js
// Sync — reads from local encrypted cache only (no network call)
// Useful for scripts or tools that can't be async
require('vault-env').configSync();

configSync() tries the local .vault-cache file first. If no cache exists, it falls back to the plain .env file.

Python Client

terminal
pip install vault-env

Basic Usage

config.py
from vaultdotenv import load_vault

# Pulls secrets from vault and injects into os.environ
load_vault()

# With options
load_vault(
    path=".env",               # Path to .env file
    environment="production",  # Environment name
    vault_url="https://...",   # Vault server URL
    override=False,            # Override existing env vars
    cache=True,                # Enable local cache fallback
)

Synchronous / Cache-Only Mode

config.py
from vaultdotenv import load_vault_sync

# Reads from local encrypted cache only — no network call
load_vault_sync()

Integration Example

config.py
# Safe fallback — works even without vault-env installed
try:
    from vaultdotenv import load_vault
    load_vault()
except Exception:
    from dotenv import load_dotenv
    load_dotenv()

CLI Reference

All commands read VAULT_KEY from the environment variable or from the .env file in the current directory.

terminal
# Initialize a new project
npx vault-env init [--name my-project]

# Push secrets to vault
npx vault-env push [--env production] [--file .env.production]

# Pull secrets from vault
npx vault-env pull [--env staging] [--output .env.staging]

# List version history
npx vault-env versions [--env production]

# Rollback to a previous version
npx vault-env rollback --version 3 [--env production]

# Device management
npx vault-env register-device [--name "CI Server"]
npx vault-env approve-device --id <device-uuid>
npx vault-env list-devices
npx vault-env revoke-device --id <device-uuid>

Global Options

FlagDescriptionDefault
--env <name>Environment nameNODE_ENV or development
--url <url>Vault server URLhttps://api.vaultdotenv.io
--file <path>Source .env file (push only).env
--output <path>Output file (pull only)stdout
--name <name>Project or device namedirectory name / hostname
--id <uuid>Device ID (approve/revoke)

Device Management

Devices add a second layer of security. Every machine that accesses your secrets must be registered and approved.

  1. Register — A new machine runs vault-env register-device. This generates a 256-bit device secret, stores it locally at ~/.vault/<projectId>.key, and sends the SHA-256 hash to the server.
  2. Approve — The project owner runs vault-env approve-device --id <uuid>.
  3. Access — On every pull/push, the client sends the device hash. Unregistered or revoked devices get a 403.
  4. Revoke — If a machine is compromised, revoke it. That machine can no longer access secrets.

Dual-Key Encryption

The device secret isn't just for authentication — it's used in the encryption itself:

key derivation
key_material = HMAC-SHA256(vault_key, device_secret)
encryption_key = HKDF(key_material, salt="vault-encrypt-v1")

Stealing just the VAULT_KEY is not enough. Stealing just the device secret is not enough. Both are required — defense in depth.

Device Secret Storage

LocationUsed by
~/.vault/<projectId>.keyLocal development machines (permissions: 0600)
VAULT_DEVICE_SECRET env varCI/CD pipelines and servers

Environments

Every project comes with default environments: development, staging, and production. Custom environments are created automatically on first push.

terminal
# Push to different environments
npx vault-env push --env development
npx vault-env push --env staging
npx vault-env push --env production

# Pull from a specific environment
npx vault-env pull --env staging

Environment Resolution

The client determines the environment from (in order):

  1. The --env CLI flag
  2. NODE_ENV environment variable (Node.js)
  3. ENVIRONMENT environment variable (Python)
  4. Default: development

Hot Reload

Both clients support watching for secret changes and automatically updating the environment — no restart required.

Node.js

app.js
const vault = require('vault-env');

await vault.config();

// Start watching (polls every 30s by default)
vault.watch({
  interval: 30000,
  environment: 'production',
  onChange(changed, allSecrets) {
    console.log('Secrets updated:', Object.keys(changed));
    // Reconnect services, refresh configs, etc.
  },
  onError(err) {
    console.error('Watch error:', err);
  },
});

// Stop watching when done
vault.unwatch();

Python

app.py
import vaultdotenv

vaultdotenv.load_vault()

vaultdotenv.watch(
    interval=30.0,  # seconds
    on_change=lambda changed, all_secrets: print("Updated:", list(changed.keys())),
    on_error=lambda err: print(f"Error: {err}"),
)

# Stop watching
vaultdotenv.unwatch()

How Watching Works

  1. Polls /api/v1/secrets/current-version at the configured interval (lightweight — no secrets transferred)
  2. If the version number changed, does a full pull
  3. Diffs the new secrets against process.env / os.environ
  4. Updates changed values in-place
  5. Calls onChange with the diff

The watcher runs on a background thread (Python) or unref'd timer (Node.js) — it won't keep your process alive.

CI/CD & Servers

For non-interactive environments, pass the vault key and device secret as environment variables:

examples
# Docker
docker run -e VAULT_KEY=vk_... -e VAULT_DEVICE_SECRET=abc123... -e ENVIRONMENT=production myapp

# GitHub Actions
env:
  VAULT_KEY: ${{ secrets.VAULT_KEY }}
  VAULT_DEVICE_SECRET: ${{ secrets.VAULT_DEVICE_SECRET }}

# Any CI/CD
export VAULT_KEY=vk_...
export VAULT_DEVICE_SECRET=abc123...
export ENVIRONMENT=production

Setting Up a Server

  1. Register a device: npx vault-env register-device --name "production-server"
  2. Approve it: npx vault-env approve-device --id <uuid>
  3. Push secrets using the server's device secret: npx vault-env push --env production
  4. Copy the device secret to the server as VAULT_DEVICE_SECRET
  5. Deploy with just two env vars: VAULT_KEY and VAULT_DEVICE_SECRET

Offline Fallback

  1. After every successful pull, secrets are cached to .vault-cache
  2. The cache is encrypted with the same key (vault key + device secret) — not readable without both
  3. If the server is down, the client decrypts and uses the cache
  4. A warning is printed: [vault-env] Remote fetch failed, using cached secrets
.gitignore
.vault-cache

Security Model

What the server knows

  • Project metadata (name, UUID, created date)
  • Environment names
  • Encrypted blobs (opaque — server cannot decrypt)
  • Device hashes (SHA-256 of device secrets — server never sees raw secrets)
  • Auth key hash (derived via HKDF — server never sees the vault key)
  • Audit log (who accessed what, from which IP, when)

What the server does NOT know

  • Your vault key
  • Your device secrets
  • Your decrypted secrets
  • The encryption key

Threat Model

ScenarioImpact
Server database breachedAttacker gets encrypted blobs. Useless without vault key + device secret.
.env file leaked (with VAULT_KEY)Attacker has vault key but not device secret. Cannot decrypt. Cannot pull (device hash check fails).
~/.vault/ directory leakedAttacker has device secret but not vault key. Cannot decrypt.
Both vault key AND device secret leakedAttacker can pull and decrypt. Revoke the device, rotate the vault key.
Network MITMHMAC signatures prevent replay attacks. Secrets are encrypted end-to-end. HTTPS provides transport security.

Encryption Details

Key Derivation

crypto
Input Key Material (IKM):
  If device secret exists:  HMAC-SHA256(vault_key, device_secret)
  If no device secret:      vault_key (raw bytes)

Derived Keys:
  Encryption key = HKDF-SHA256(IKM, salt="vault-encrypt-v1", info="", length=32)
  Auth key       = HKDF-SHA256(IKM, salt="vault-auth-v1",    info="", length=32)
  Device hash    = SHA-256(device_secret)

Encryption (Push)

crypto
1. Derive 256-bit encryption key
2. Generate random 96-bit IV
3. Encrypt: AES-256-GCM(key, iv, plaintext_json)
4. Pack: base64(iv || auth_tag || ciphertext)
5. Send packed blob to server

Decryption (Pull)

crypto
1. Derive 256-bit encryption key (same derivation)
2. Unpack: base64 decode → iv (12 bytes) || auth_tag (16 bytes) || ciphertext
3. Decrypt: AES-256-GCM(key, iv, ciphertext, auth_tag)
4. Parse JSON → key-value pairs

Request Signing

crypto
1. Derive auth key: HKDF(vault_key, salt="vault-auth-v1")
2. Compute: HMAC-SHA256(auth_key, request_body + timestamp)
3. Header: X-Vault-Signature: v=<timestamp>,d=<hex_digest>
4. Server verifies against stored auth_key_hash (max age: 5 minutes)

API Reference

All endpoints are at https://api.vaultdotenv.io/api/v1/. All requests use POST with JSON bodies. Authenticated endpoints require the X-Vault-Signature header.

POST /project/create

Create a new project. No authentication required.

request
{ "project_name": "my-app" }
response
{ "project_id": "uuid", "environments": ["development", "staging", "production"] }

POST /project/set-key

Set the auth key hash for a project. One-time operation.

request
{ "project_id": "uuid", "auth_key_hash": "hex" }
response
{ "ok": true }

POST /secrets/push

Push encrypted secrets. Authenticated.

request
{ "project_id": "uuid", "environment": "production", "secrets": "<base64>", "device_hash": "hex" }
response
{ "version": 1 }

POST /secrets/pull

Pull encrypted secrets. Authenticated.

request
{ "project_id": "uuid", "environment": "production", "device_hash": "hex" }
response
{ "secrets": "<base64>", "version": 1 }

POST /secrets/current-version

Lightweight version check. No secrets transferred. Authenticated.

request
{ "project_id": "uuid", "environment": "production" }
response
{ "version": 1, "updated_at": "2026-03-23T10:54:10.872Z" }

POST /secrets/versions

List version history. Authenticated.

request
{ "project_id": "uuid", "environment": "production" }
response
{ "versions": [{ "version": 1, "created_at": "...", "changed_keys": [...] }] }

POST /secrets/rollback

Rollback to a previous version. Authenticated.

request
{ "project_id": "uuid", "environment": "production", "version": 1 }
response
{ "version": 3 }

POST /devices/register

Register a new device. Authenticated.

request
{ "project_id": "uuid", "device_name": "my-laptop", "device_hash": "hex" }
response
{ "device_id": "uuid", "status": "pending" }

POST /devices/approve

Approve a pending device. Authenticated.

request
{ "project_id": "uuid", "device_id": "uuid" }
response
{ "device_id": "uuid", "status": "approved" }

POST /devices/list

List all devices for a project. Authenticated.

request
{ "project_id": "uuid" }
response
{ "devices": [{ "id": "uuid", "device_name": "...", "status": "approved" }] }

POST /devices/revoke

Revoke a device. Authenticated.

request
{ "project_id": "uuid", "device_id": "uuid" }
response
{ "device_id": "uuid", "status": "revoked" }

GET /health

Health check. No authentication.

response
{ "status": "ok", "ts": 1711187650872 }

Architecture

Server

  • Runtime: Cloudflare Worker (edge, globally distributed)
  • Database: Cloudflare D1 (SQLite at the edge)
  • Domain: api.vaultdotenv.io

Database Schema

schema
projects
  id          TEXT (UUID, primary key)
  name        TEXT
  key_hash    TEXT (hex-encoded auth key hash)
  created_at  DATETIME

environments
  id          TEXT (UUID, primary key)
  project_id  TEXT (FK → projects)
  name        TEXT (development/staging/production/custom)
  created_at  DATETIME

secret_versions
  id              INTEGER (auto-increment)
  environment_id  TEXT (FK → environments)
  version         INTEGER
  encrypted_blob  TEXT (base64-encoded AES-256-GCM ciphertext)
  changed_keys    TEXT (JSON array of key names — not values)
  created_at      DATETIME

devices
  id            TEXT (UUID, primary key)
  project_id    TEXT (FK → projects)
  device_name   TEXT
  device_hash   TEXT (SHA-256 of device secret)
  status        TEXT (pending/approved/revoked)
  created_at    DATETIME
  approved_at   DATETIME
  last_seen_at  DATETIME

audit_log
  id              INTEGER (auto-increment)
  project_id      TEXT
  environment_id  TEXT
  action          TEXT (pull/push/rollback/device_register)
  ip              TEXT
  user_agent      TEXT
  created_at      DATETIME

Clients

Node.jsPython
DependenciesZero (Node.js built-in crypto)cryptography, httpx
Module formatCommonJSStandard package
CLIBuilt-in (npx vault-env)
Hot reloadUnref'd timerBackground thread

Troubleshooting

Device not registered. Run: vault-env register-device

Your machine isn't registered for this project. Run: npx vault-env register-device Then ask the project owner to approve it.

Device not yet approved

Your device is registered but pending approval. The project owner needs to run: npx vault-env list-devices npx vault-env approve-device --id <uuid>

Failed to fetch secrets and no cache available

The vault server is unreachable and there's no local cache. Make sure you can reach api.vaultdotenv.io and run a successful pull first to populate the cache.

VAULT_KEY not found

Set the VAULT_KEY environment variable, or make sure your .env file contains VAULT_KEY=vk_...

Decryption fails after registering a new device

Secrets are encrypted with a specific device's secret. After registering a new device, re-push secrets using the new device's secret.

MIT License