You know the email. "An unauthorized party may have accessed customer environment variables. Please rotate your credentials."
Then there goes your afternoon: Stripe keys, database passwords, OpenAI keys, webhook secrets, and that one thing you added to the dashboard two years ago and forgot about.
After one of these breaches (there have been so many I honestly can't remember which one) I saw a comment on Hacker News that said roughly:
Encrypt the values before you give them to the provider. Hardcode the decryption key in your source. Decrypt at runtime.
My first thought was that's dumb. You don't hardcode keys in source code. Everybody knows that.
About ten seconds later: wait... that actually works.
Just this year:
Both times the attacker landed in the provider's control plane, the database and services that hold your config. Whatever plaintext was sitting there walked out the door.
Marking things as "secret" helps, if you remembered to tick the box and if the provider's secret storage is actually walled off from whatever got popped. Either way, you're trusting one company with both the lock and the key.
The whole trick is making sure the provider never holds both halves.
STRIPE_SECRET_KEY=enc:v1:9f2c...flowchart LR
subgraph Laptop["Your machine"]
P[Plaintext secret] -->|encrypt with ENV_KEY| C[Ciphertext]
end
C -->|paste into dashboard| CP[(Provider control plane)]
K[ENV_KEY hardcoded in source] --> R
CP -->|injected as env var| R[Runtime process]
R -->|decrypt in memory| S[Plaintext secret]If someone dumps the control plane, they get a pile of enc:v1:... strings that are useless without the key. You read the incident email over coffee instead of frantically opening twelve dashboards.
I've been calling it last-mile decryption, since the secret stays encrypted until the very last hop: your running process.[3]
About 40 lines of Node, no dependencies. The built-in crypto module and AES-256-GCM do all the work.
Generate a key once:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Hardcode it. Yes, really.
// Yes, this is on purpose. Ciphertext lives on the host, the key lives here.
module.exports.ENV_KEY = 'PASTE_THE_BASE64_KEY_HERE'A little script to encrypt values on your machine:
const crypto = require('crypto')
const { ENV_KEY } = require('../src/env-key')
function encrypt(plaintext) {
const key = Buffer.from(ENV_KEY, 'base64')
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const data = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `enc:v1:${Buffer.concat([iv, tag, data]).toString('base64')}`
}
// usage: node scripts/encrypt-env.js "sk_live_abc123"
console.log(encrypt(process.argv[2]))Paste the output into Vercel, Railway, Fly, wherever. Still mark it as sensitive if you can. It's free.
And a helper to decrypt at runtime:
const crypto = require('crypto')
const { ENV_KEY } = require('./env-key')
function decrypt(value) {
if (!value || !value.startsWith('enc:v1:')) {
return value // plaintext passthrough for local dev / non-secret config
}
const raw = Buffer.from(value.slice('enc:v1:'.length), 'base64')
const iv = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const data = raw.subarray(28)
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(ENV_KEY, 'base64'), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
module.exports.env = (name) => decrypt(process.env[name])Then swap process.env.X for env('X'):
const { env } = require('./env')
const stripe = require('stripe')(env('STRIPE_SECRET_KEY'))The enc:v1: prefix means plain values like your local .env or NODE_ENV pass right through, and the version number gives you a way to rotate keys later. GCM is authenticated too, so if someone tampers with a value in the dashboard it throws instead of quietly handing you garbage.
This is a speed bump, not a vault. It helps when the provider's env var store, admin tooling, or logs leak and the attacker doesn't also have your code. It also helps when a teammate screenshots the settings page.
It doesn't help if:
env-key.js from server code.So you've turned one point of failure into two. On incident day, that's the difference between "rotate everything right now" and "was it just the control plane? Cool, I'll rotate when I get around to it."
Rotating means a code deploy, which is kind of the point. Add a v2 key, teach decrypt about enc:v2:, re-encrypt your values, deploy, and delete v1 once nothing uses it.
If you're on AWS or GCP and already have a KMS, use envelope encryption with an IAM role instead. It's strictly better. This hack is for the very common "I'm on a PaaS and I just don't want my Stripe key sitting in plaintext in someone else's database" situation.
Pick your three scariest env vars (payment key, database URL, whatever LLM key is burning money), encrypt them, and deploy. You'll be glad you did when the next email shows up.