March 12, 2026 (1mo ago)
Written by Temps Team
Last updated March 12, 2026 (1mo ago)
Your database password is probably stored in plaintext right now. Not in your code — you know better than that. In your deployment platform's database. The place where you paste DATABASE_URL, STRIPE_SECRET_KEY, and JWT_SECRET during project setup? That value likely sits unencrypted in a PostgreSQL row, readable by anyone with database access.
According to IBM's Cost of a Data Breach Report (2024), compromised credentials were the most common initial attack vector, responsible for 16% of breaches — with an average cost of $4.81 million per incident. Many of those credentials came from environment variables stored in plaintext.
This guide explains what encryption at rest means, how AES-256-GCM works under the hood, and how to implement it yourself. We'll also show how Temps handles it automatically so you don't have to think about it.
TL;DR: Most deployment platforms store environment variables as plaintext in their database. If that database leaks, every API key and password is exposed instantly. Encrypting env vars at rest with AES-256-GCM ensures secrets remain unreadable even after a breach. Compromised credentials cause 16% of all data breaches.
According to the IBM Cost of a Data Breach Report, stolen credentials cost organizations $4.81 million on average per breach. The root cause is often simpler than you'd expect: environment variables stored as plain text in a database that someone shouldn't have accessed.
Here's what happens when you set an environment variable in most platforms:
DATABASE_URL=postgres://user:password@host/db in the dashboardThe encryption in transit (HTTPS) protects the value while it's moving. But once it lands in the database? It sits there, readable, waiting.
Think about all the ways someone could read that database:
In 2023, CircleCI disclosed a security incident where an attacker gained access to customer environment variables stored in the platform. Customers had to rotate every secret. Heroku experienced a similar breach in 2022 when attackers accessed their database through compromised OAuth tokens.
These aren't hypothetical scenarios. They're exactly what happens when secrets sit unencrypted in a database that gets compromised.
A typical project's environment variables might include:
| Variable | What an Attacker Gets |
|---|---|
DATABASE_URL | Full read/write access to your production database |
STRIPE_SECRET_KEY | Ability to issue refunds, read payment data |
JWT_SECRET | Ability to forge authentication tokens for any user |
AWS_SECRET_ACCESS_KEY | Access to your S3 buckets, Lambda functions, everything |
SMTP_PASSWORD | Ability to send emails as your domain |
GITHUB_TOKEN | Read/write access to your source code |
One breached database table. Every secret for every project, exposed at once. And you won't know about it until the platform sends a disclosure email days or weeks later.
Encryption at rest protects data while it's stored on disk or in a database — not while it's moving over the network. According to NIST Special Publication 800-111 (2007, updated guidance), encryption at rest is a fundamental control for protecting sensitive data stored on media that could be physically or logically accessed by unauthorized parties.
Here's the distinction that matters:
When environment variables are encrypted at rest, the database stores gibberish. A DATABASE_URL value might look like this in the actual database row:
# Plaintext (what most platforms store)
postgres://admin:s3cret_passw0rd@db.example.com:5432/myapp
# Encrypted at rest (what the database actually contains)
nG7xK2mP8qR+4vB... (base64-encoded AES-256-GCM ciphertext)
Even if someone dumps the entire database, they get nothing useful. The values are meaningless without the encryption key — and that key isn't stored in the database.
The encrypted values get decrypted only at specific moments:
The decryption key stays in memory on the server process. It never touches the database. So a database-only breach — which is the most common kind — yields nothing.
But wait, isn't the whole disk encrypted anyway? Maybe. Full-disk encryption (like LUKS or AWS EBS encryption) protects against physical theft of the storage device. It doesn't protect against someone who has database access through the application layer. Application-level encryption at rest is an additional layer that protects secrets from logical access, not just physical access.
AES-256-GCM is the encryption standard used by AWS, Google Cloud, and most serious cryptographic implementations. NIST approved AES as a federal standard in 2001 (NIST FIPS 197), and the GCM mode was standardized in 2007 (NIST SP 800-38D). It's not exotic or experimental — it's the workhorse of modern symmetric encryption.
Let's break it down into plain language.
AES stands for Advanced Encryption Standard. The "256" means the key is 256 bits long — that's 32 bytes. Same key encrypts and decrypts the data (symmetric encryption).
How strong is a 256-bit key? There are 2^256 possible keys. That's roughly 1.15 x 10^77 combinations. If every atom in the observable universe were a computer trying one billion keys per second, it would still take longer than the age of the universe to try them all. Brute-forcing AES-256 isn't a realistic threat.
AES by itself is a block cipher — it encrypts 16 bytes at a time. GCM (Galois/Counter Mode) is how you use AES to encrypt data of any length. But it does something critical that simpler modes don't: it provides authenticated encryption.
That means GCM gives you two guarantees:
If an attacker modifies even a single bit of the ciphertext, decryption fails. This prevents subtle attacks where someone alters an encrypted value without knowing what it contains.
Every encryption operation uses a unique 12-byte nonce (also called an initialization vector or IV). The nonce ensures that encrypting the same plaintext twice produces completely different ciphertext.
Why does this matter? Without a unique nonce, an attacker watching encrypted values could tell when two environment variables have the same value. That leaks information. With a random nonce per encryption, identical inputs are indistinguishable.
encrypt("my-secret", key, nonce_1) → "aG9sZG1l..."
encrypt("my-secret", key, nonce_2) → "xPq7bK2n..."
Same plaintext, same key, different nonce — completely different output. The nonce is stored alongside the ciphertext (it's not secret), and it's used during decryption to recover the original value.
[IMAGE: Diagram showing AES-256-GCM encrypt/decrypt flow with key, nonce, plaintext, ciphertext, and authentication tag -- search terms: encryption flow diagram symmetric key]
Envelope encryption is the pattern used by AWS KMS, Google Cloud KMS, and Azure Key Vault to manage encryption keys at scale. According to the AWS KMS FAQ, AWS processes trillions of API requests per month using this pattern. The core idea: never use your master key directly to encrypt data.
Instead of encrypting every environment variable with the same master key, you add a layer:
┌─────────────────────────────────────────────┐
│ Master Key (KEK) │
│ Stored in secure key store │
└──────────┬──────────────┬───────────────────┘
│ │
encrypts DEK encrypts DEK
│ │
┌─────▼─────┐ ┌────▼──────┐
│ DEK #1 │ │ DEK #2 │
│ (unique) │ │ (unique) │
└─────┬─────┘ └─────┬─────┘
│ │
encrypts data encrypts data
│ │
┌─────▼─────┐ ┌────▼──────┐
│ Secret 1 │ │ Secret 2 │
│ DATABASE_ │ │ STRIPE_ │
│ URL │ │ SECRET_KEY│
└───────────┘ └───────────┘
Three reasons:
Key rotation becomes trivial. When you rotate the master key, you only re-encrypt the data keys — not all the data. If you have 10,000 environment variables, rotating with envelope encryption means re-encrypting a few hundred small data keys. Without it, you'd re-encrypt all 10,000 values.
Blast radius is limited. If a single data key is compromised, only the data encrypted with that specific key is exposed. The master key and all other data keys remain safe.
Performance scales better. The master key can live in a hardware security module (HSM) or a remote KMS. Only the small data keys need to be fetched and decrypted. The actual data encryption happens locally with the decrypted data key, keeping things fast.
# Encrypting a new environment variable
def store_env_var(key, value, master_key):
# 1. Generate a unique data key
data_key = generate_random_256_bit_key()
# 2. Encrypt the value with the data key
nonce = generate_random_nonce(12)
encrypted_value = aes_256_gcm_encrypt(value, data_key, nonce)
# 3. Encrypt the data key with the master key
key_nonce = generate_random_nonce(12)
encrypted_data_key = aes_256_gcm_encrypt(data_key, master_key, key_nonce)
# 4. Store both — the master key is NOT stored in the database
database.insert({
"key": key,
"encrypted_value": nonce + encrypted_value,
"encrypted_data_key": key_nonce + encrypted_data_key
})
# Decrypting at deploy time
def read_env_var(key, master_key):
row = database.get(key)
# 1. Decrypt the data key using the master key
data_key = aes_256_gcm_decrypt(
row["encrypted_data_key"], master_key
)
# 2. Decrypt the value using the data key
value = aes_256_gcm_decrypt(
row["encrypted_value"], data_key
)
return value
You don't need a fancy platform to encrypt environment variables. The Node.js crypto module and Python's cryptography library both support AES-256-GCM natively. According to Snyk's State of Open Source Security (2023), 77% of organizations experienced at least one supply chain attack — making secret protection a non-optional practice.
The following implementation patterns are based on the actual encryption module used in the Temps codebase, simplified for standalone use.
Your master key should be a cryptographically random 256-bit (32-byte) value. Never derive it from a simple password without a proper key derivation function.
// Node.js — generate and save a master key
const crypto = require('crypto');
const fs = require('fs');
const masterKey = crypto.randomBytes(32);
fs.writeFileSync('/etc/myapp/encryption_key', masterKey.toString('hex'));
fs.chmodSync('/etc/myapp/encryption_key', 0o600);
console.log('Master key generated and saved');
# Python — generate and save a master key
import os
master_key = os.urandom(32)
with open('/etc/myapp/encryption_key', 'wb') as f:
f.write(master_key.hex().encode())
os.chmod('/etc/myapp/encryption_key', 0o600)
Store this key file outside your application directory. Set permissions to 600 (owner read/write only). Never commit it to version control. Never store it in the same database as your encrypted values.
// Node.js — encrypt an environment variable value
function encryptValue(plaintext, masterKey) {
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, nonce);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
// Store nonce + authTag + ciphertext together
const combined = Buffer.concat([nonce, authTag, encrypted]);
return combined.toString('base64');
}
# Python — encrypt an environment variable value
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64
import os
def encrypt_value(plaintext: str, master_key: bytes) -> str:
nonce = os.urandom(12)
aesgcm = AESGCM(master_key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
# nonce + ciphertext (includes auth tag)
return base64.b64encode(nonce + ciphertext).decode()
// Node.js — decrypt an environment variable value
function decryptValue(encoded, masterKey) {
const data = Buffer.from(encoded, 'base64');
const nonce = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, nonce);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return decrypted.toString('utf8');
}
# Python — decrypt an environment variable value
def decrypt_value(encoded: str, master_key: bytes) -> str:
data = base64.b64decode(encoded)
nonce = data[:12]
ciphertext = data[12:]
aesgcm = AESGCM(master_key)
return aesgcm.decrypt(nonce, ciphertext, None).decode()
Key rotation means generating a new master key and re-encrypting all values. Here's a safe approach:
async function rotateKeys(oldKey, newKey, db) {
const rows = await db.query('SELECT id, key, value FROM env_vars');
for (const row of rows) {
const plaintext = decryptValue(row.value, oldKey);
const reEncrypted = encryptValue(plaintext, newKey);
await db.query(
'UPDATE env_vars SET value = $1 WHERE id = $2',
[reEncrypted, row.id]
);
}
// Only after ALL values are re-encrypted:
saveNewKey(newKey);
}
Do this in a transaction. If anything fails mid-rotation, roll back and keep the old key. Secrets exist in plaintext in memory during rotation, so run this on a trusted machine — not in a CI pipeline.
We've found that the biggest mistake teams make isn't skipping encryption entirely — it's storing the encryption key in the same database or backup as the encrypted values, defeating the purpose completely.
Temps uses AES-256-GCM encryption for all environment variables by default — no configuration, no setup flags, no paid tier. According to a 2024 Verizon DBIR analysis, 68% of breaches involved a human element including credential theft. Automatic encryption removes the possibility of forgetting to protect secrets.
Here's what actually happens under the hood.
When you run temps setup, the platform generates a cryptographically random 256-bit key:
curl -fsSL https://temps.sh/deploy.sh | bash
# During setup, the encryption key is auto-generated:
# → Created encryption key at ~/.temps/encryption_key
The key is a 64-character hex string (32 bytes) generated using the operating system's cryptographic random number generator (OsRng). It's written to ~/.temps/encryption_key with restricted file permissions. The database never sees this key.
Most open-source deployment platforms treat encryption as an enterprise feature or a configuration option. We've found that this creates a false sense of security — developers assume their secrets are protected when they aren't. Making encryption the default, with zero configuration, eliminates this gap entirely.
When you set an environment variable through the Temps dashboard or CLI, the EnvVarService encrypts the value before the database INSERT:
User sets DATABASE_URL=postgres://...
│
▼
EnvVarService.encrypt_value()
│ AES-256-GCM with random 12-byte nonce
▼
Database stores: nonce + ciphertext (base64)
│
▼ (at deploy time)
EnvVarService.decrypt_value()
│ Reads nonce from stored data, decrypts
▼
Container receives: postgres://...
The is_encrypted flag on each row provides backward compatibility. Environment variables created before encryption was added are served as-is. New variables are always encrypted.
If you query the env_vars table directly, here's what you'll see:
SELECT key, value, is_encrypted FROM env_vars WHERE project_id = 1;
| key | value | is_encrypted |
|---|---|---|
| DATABASE_URL | nG7xK2mP8qR+4vB1jL... | true |
| NODE_ENV | production | false |
| STRIPE_KEY | aH0pWm3nQ9sT+7uX... | true |
The NODE_ENV value is plaintext only because it was set before the encryption migration. Non-sensitive values can stay unencrypted without risk. But every new variable — sensitive or not — gets encrypted automatically.
Most deployment platforms don't document their secret storage implementation. Verizon's 2024 Data Breach Investigations Report found that credential theft was involved in 68% of breaches, yet most platforms treat env var encryption as an afterthought — if they address it at all.
| Platform | Encryption at Rest | Envelope Encryption | Audit Log | Verifiable |
|---|---|---|---|---|
| Vercel | Unknown (closed-source) | Unknown | Pro plan only | No |
| Railway | Not documented | No | No | No |
| Render | Not documented | No | Team plan | No |
| Coolify | No | No | No | Yes (open-source) |
| Dokploy | No | No | No | Yes (open-source) |
| Temps | AES-256-GCM | Yes | Yes | Yes (open-source) |
"Not documented" is a meaningful answer. If a platform encrypts secrets at rest, they have every incentive to say so. Silence usually means they don't.
What makes this comparison harder is that closed-source platforms can't be independently audited. You're trusting their security documentation — if it exists. With open-source platforms, anyone can inspect the encryption implementation, the key management, and the database schema.
Is it possible that Vercel encrypts env vars at rest? Sure. But you can't verify it. And their security documentation focuses on encryption in transit and access controls, not encryption at rest for stored secrets.
No — and confusing the two is a common and dangerous mistake. Base64 is an encoding format, not encryption. It converts binary data to ASCII text for transport. Anyone can decode base64 without a key: echo "cGFzc3dvcmQ=" | base64 -d outputs password instantly. If your platform base64-encodes environment variables, your secrets are effectively plaintext. Real encryption requires a cryptographic key; without it, the data is unreadable.
Your encrypted environment variables become permanently unrecoverable. There's no backdoor, no reset mechanism, no support ticket that fixes this. The entire point of AES-256 encryption is that data is inaccessible without the key. Back up your ~/.temps/encryption_key file to a secure, offline location immediately after setup. Consider storing a copy in a hardware security module or a physical safe. Losing this key is equivalent to losing every secret it protects.
Yes. CI/CD systems are a frequent target — the 2023 CircleCI breach exposed customer secrets stored in the platform. Use your CI provider's built-in secret management (GitHub Actions secrets, GitLab CI variables) rather than hardcoding values in pipeline files. These systems typically encrypt secrets at rest, though verification depends on the provider. For maximum control, inject secrets at runtime from an external vault like HashiCorp Vault or AWS Secrets Manager.
Negligible. AES-256-GCM runs at roughly 4-5 GB/s on modern CPUs with AES-NI hardware acceleration (Intel AES-NI documentation). A typical environment variable is under 500 bytes. Encrypting or decrypting a few dozen env vars at deploy time adds less than a millisecond of total overhead. You'll never notice it, and it'll never be the bottleneck in your deployment pipeline.
Industry standards like PCI DSS 4.0 recommend rotating cryptographic keys at least annually, or immediately after a suspected compromise. For most teams, annual rotation strikes the right balance between security and operational overhead. Temps supports key rotation by decrypting all values with the old key and re-encrypting with the new one in a single transaction.
Environment variable encryption isn't a nice-to-have. It's a baseline security control that most platforms skip. The 16% of breaches that start with stolen credentials don't happen because encryption is hard — they happen because platforms don't bother implementing it by default.
You have two options. Roll your own encryption using the code examples in this guide, manage key files, handle rotation, and maintain backward compatibility. Or install a platform that does it for you from the first temps setup.
curl -fsSL https://temps.sh/install.sh | bash
Every environment variable encrypted at rest. AES-256-GCM. Zero configuration. Your secrets stay secret even if the database doesn't.
For more on Temps security architecture, see the security documentation. For environment variable management, check the environment variables documentation.