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.

The breaches keep coming

Just this year:

  • Vercel, April 2026. An attacker got in through a compromised third-party AI tool a Vercel employee was using and read customer env vars that weren't marked "sensitive."[1]
  • Railway, October 2026. Reports say someone got into the systems that store customer env vars, in some cases with decrypted values.[2]

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.

Split the ciphertext from the key

The whole trick is making sure the provider never holds both halves.

  • The provider stores STRIPE_SECRET_KEY=enc:v1:9f2c...
  • Your source code holds the key.
  • At runtime your app has both, decrypts in memory, and gets on with its life.
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]

The code

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.

What it does and doesn't cover

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:

  • They get everything. If the attacker owns the whole platform, you're toast no matter what.
  • The host builds from your repo. This is the big caveat. If Vercel or Railway builds from your GitHub repo, the key is sitting in their build artifacts. Those usually live somewhere else with different access than the env var store, so it still helps. But what you really get is "the key isn't stored next to the ciphertext," not "the provider doesn't have the key."
  • Your repo leaks. The key is now exactly as sensitive as your source. Public repo? This does nothing.
  • The key ships to the browser. Only import 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 the key

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.


Notes

Footnotes

  1. ^ See coverage from Trunk, Bastion, and Prowler. Variables marked "sensitive" were reportedly not exposed. 

  2. ^ So far I've mostly seen this in secondary reporting (like this Spanish-language summary). Check Railway's own status page for the details. 

  3. ^ I don't think there's an agreed-upon name for this exact setup. Close cousins: application-layer encryption, envelope encryption, and tools like SOPS, Sealed Secrets, and dotenvx, which put the encrypted values in the repo and the key in an env var. This is that, flipped. Got a better name? Let me know.