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.
| dotenv | vault-env | |
|---|---|---|
| Secrets in .env | All of them | Just VAULT_KEY |
| Laptop stolen | Everything exposed | One key to rotate |
| Rotate an API key | Update every server | Push once, all servers get it |
| Version history | None | Full history + rollback |
| Device control | None | Approve/revoke individual machines |
| Code changes to migrate | — | Zero |
Quick Start
1. Install
# Node.js npm install vault-env # Python pip install vault-env
2. Create a project
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
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
# 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
┌──────────────────────────────────┐
│ 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
npm install vault-env
Basic Usage
// 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
// 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
pip install vault-env
Basic Usage
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
from vaultdotenv import load_vault_sync # Reads from local encrypted cache only — no network call load_vault_sync()
Integration Example
# 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.
# 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
| Flag | Description | Default |
|---|---|---|
| --env <name> | Environment name | NODE_ENV or development |
| --url <url> | Vault server URL | https://api.vaultdotenv.io |
| --file <path> | Source .env file (push only) | .env |
| --output <path> | Output file (pull only) | stdout |
| --name <name> | Project or device name | directory 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.
- 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. - Approve — The project owner runs
vault-env approve-device --id <uuid>. - Access — On every pull/push, the client sends the device hash. Unregistered or revoked devices get a 403.
- 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_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
| Location | Used by |
|---|---|
| ~/.vault/<projectId>.key | Local development machines (permissions: 0600) |
| VAULT_DEVICE_SECRET env var | CI/CD pipelines and servers |
Environments
Every project comes with default environments: development, staging, and production. Custom environments are created automatically on first push.
# 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):
- The
--envCLI flag NODE_ENVenvironment variable (Node.js)ENVIRONMENTenvironment variable (Python)- Default:
development
Hot Reload
Both clients support watching for secret changes and automatically updating the environment — no restart required.
Node.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
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
- Polls
/api/v1/secrets/current-versionat the configured interval (lightweight — no secrets transferred) - If the version number changed, does a full pull
- Diffs the new secrets against
process.env/os.environ - Updates changed values in-place
- Calls
onChangewith 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:
# 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=productionSetting Up a Server
- Register a device:
npx vault-env register-device --name "production-server" - Approve it:
npx vault-env approve-device --id <uuid> - Push secrets using the server's device secret:
npx vault-env push --env production - Copy the device secret to the server as
VAULT_DEVICE_SECRET - Deploy with just two env vars:
VAULT_KEYandVAULT_DEVICE_SECRET
Offline Fallback
- After every successful pull, secrets are cached to
.vault-cache - The cache is encrypted with the same key (vault key + device secret) — not readable without both
- If the server is down, the client decrypts and uses the cache
- A warning is printed:
[vault-env] Remote fetch failed, using cached secrets
.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
| Scenario | Impact |
|---|---|
| Server database breached | Attacker 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 leaked | Attacker has device secret but not vault key. Cannot decrypt. |
| Both vault key AND device secret leaked | Attacker can pull and decrypt. Revoke the device, rotate the vault key. |
| Network MITM | HMAC signatures prevent replay attacks. Secrets are encrypted end-to-end. HTTPS provides transport security. |
Encryption Details
Key Derivation
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)
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)
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
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.
{ "project_name": "my-app" }{ "project_id": "uuid", "environments": ["development", "staging", "production"] }POST /project/set-key
Set the auth key hash for a project. One-time operation.
{ "project_id": "uuid", "auth_key_hash": "hex" }{ "ok": true }POST /secrets/push
Push encrypted secrets. Authenticated.
{ "project_id": "uuid", "environment": "production", "secrets": "<base64>", "device_hash": "hex" }{ "version": 1 }POST /secrets/pull
Pull encrypted secrets. Authenticated.
{ "project_id": "uuid", "environment": "production", "device_hash": "hex" }{ "secrets": "<base64>", "version": 1 }POST /secrets/current-version
Lightweight version check. No secrets transferred. Authenticated.
{ "project_id": "uuid", "environment": "production" }{ "version": 1, "updated_at": "2026-03-23T10:54:10.872Z" }POST /secrets/versions
List version history. Authenticated.
{ "project_id": "uuid", "environment": "production" }{ "versions": [{ "version": 1, "created_at": "...", "changed_keys": [...] }] }POST /secrets/rollback
Rollback to a previous version. Authenticated.
{ "project_id": "uuid", "environment": "production", "version": 1 }{ "version": 3 }POST /devices/register
Register a new device. Authenticated.
{ "project_id": "uuid", "device_name": "my-laptop", "device_hash": "hex" }{ "device_id": "uuid", "status": "pending" }POST /devices/approve
Approve a pending device. Authenticated.
{ "project_id": "uuid", "device_id": "uuid" }{ "device_id": "uuid", "status": "approved" }POST /devices/list
List all devices for a project. Authenticated.
{ "project_id": "uuid" }{ "devices": [{ "id": "uuid", "device_name": "...", "status": "approved" }] }POST /devices/revoke
Revoke a device. Authenticated.
{ "project_id": "uuid", "device_id": "uuid" }{ "device_id": "uuid", "status": "revoked" }GET /health
Health check. No authentication.
{ "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
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.js | Python | |
|---|---|---|
| Dependencies | Zero (Node.js built-in crypto) | cryptography, httpx |
| Module format | CommonJS | Standard package |
| CLI | Built-in (npx vault-env) | — |
| Hot reload | Unref'd timer | Background 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