Back to blog

Get your Node.js secrets out of .env

Vlad Vidican 3 min read

If you prefer to watch a video, here is the whole tutorial in under a minute:

Most Node.js apps keep their secrets in a .env file: a database password, an API key, in plain text, in a file that gets copied to every laptop that runs the app. This tutorial moves them into an encrypted Valt0 vault you commit with your code.

You need Node.js 20.6 or later and Valt0 installed (valt0 status checks that its service is running). The examples use pg and stripe, but the steps are the same for any library that takes a secret.

1. Before: secrets in .env

Here is a typical .env. Two of its lines are settings, and two are secrets:

.env · before
PORT=3000
LOG_LEVEL=info
DATABASE_URL="postgresql://app:example-password@localhost:5432/app"
STRIPE_KEY=sk_test_xxxxxxxxxxxx

The database password is tucked inside the URL, between the colon and the @, and the Stripe key is right there. The app reads both straight from the environment:

app.mjs · before
import pg from 'pg';
import Stripe from 'stripe';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const stripe = new Stripe(process.env.STRIPE_KEY);

2. Move the secrets into a vault

Create a vault for development. Valt0 prints its key once, so put it in your password manager now: without it nobody can open the vault, you included.

Terminal
valt0 init dev

Then store each secret under the name it has in .env. Valt0 asks for the key, then the value: paste it without any quotes it has in the file.

Terminal
valt0 store dev:DATABASE_URL

Do the same for STRIPE_KEY, and for any other secret you have.

The vault was imported into the local Valt0 service when you created it, and that service is what your app will talk to. All the app needs from you is the vault's public id:

Terminal
valt0 info dev

3. After: the app asks the vault

This is the part that makes the whole thing work: the app never sees the vault key. It opens the vault by its id, which is public, and asks the local service for each secret. Install the client:

Terminal
npm install @valt0/client

In .env, the two secrets give way to the vault's id, the one valt0 info printed. It is safe there:

.env · after
PORT=3000
LOG_LEVEL=info
APP_VAULT_ID=your-vault-id

And app.mjs opens the vault once, then asks it for each secret by name where it sets up the library that needs it:

app.mjs · after
import pg from 'pg';
import Stripe from 'stripe';
import { openVault } from '@valt0/client';

const vault = await openVault({ id: process.env.APP_VAULT_ID, name: 'app' });

const dbUrl = await vault.secret('DATABASE_URL');
export const pool = new pg.Pool({ connectionString: dbUrl.reveal() });
dbUrl.destroy();

const stripeKey = await vault.secret('STRIPE_KEY');
export const stripe = new Stripe(stripeKey.reveal());
stripeKey.destroy();

reveal() hands the library the value, and destroy() wipes the client's copy straight after. Anywhere else, the secret object prints as [REDACTED], so logging it by mistake gives nothing away. If the service can't be reached or the vault can't be loaded, openVault throws, and the app stops at startup instead of running without its secrets.

4. What changes from here

  • New teammates clone the repo and get one key from you instead of a file of passwords, plus the public id for their own .env. They run valt0 import dev, and valt0 import dev --force after pulling a vault that changed.
  • New secrets go the same way: store each one and read it with await vault.secret('NAME').
  • Changing a value is valt0 store dev:STRIPE_KEY --force and a restart. That changes the vault, not the provider: rotate the key or password there first.
  • Production gets its own vault, with the same secret names and its own APP_VAULT_ID, so a leaked dev key leaves it alone. The Introduction explains why, Deployment covers servers, and From a container covers containers on your own computer.

Create your first vault.

$ valt0 init prod

Install