Secrets & Encryption
A weather widget that stores a user's API key and a login form that stores a user's password look similar on the surface — both are "saving a secret" — but they need completely different protection. One has to be read back later; the other must never be read back at all. Confusing these two is one of the most common, most serious mistakes in application security.
Two kinds of secret #
Ask one question about any secret you're storing: will the application ever need the original value back?
Yes — encrypt it
An API key for a weather widget has to be sent back to the weather provider on every request. The app needs the real value again, so it must be reversible. This calls for encryption: scrambled with a key, unscrambled with the same key, only ever decrypted in memory when actually needed.
No — hash it
A login password never needs to be read back — the app only ever needs to check "does this match what was set." That calls for hashing: a one-way transformation with no key to decrypt it back with, because there's nothing to decrypt. Even the app itself can't recover the original password from the hash.
Encrypting secrets you need back #
Storing an API key in plain text in the database means anyone who reads the database — a backup file, a leaked disk image, an over-privileged admin — reads every user's credentials directly. Encryption at rest means the value stored on disk is unreadable without a separate key that isn't stored next to the data.
Node's built-in crypto module supports AES-256-GCM, an symmetric cipher (the same key encrypts and decrypts) that also authenticates the data:
encrypting an API key before saving itimport { randomBytes, createCipheriv } from "node:crypto";
function encryptSecret(plaintext: string, key: Buffer) {
const iv = randomBytes(12); // unique every time, even for the same key
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return { ciphertext, iv, authTag };
}
decrypting it back only when actually needed
import { createDecipheriv } from "node:crypto";
function decryptSecret(
ciphertext: Buffer,
key: Buffer,
iv: Buffer,
authTag: Buffer
) {
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(), // throws if authTag doesn't match — tampering detected
]).toString("utf8");
}
Three details matter here, and each one is load-bearing:
- The IV (initialization vector) is random and different on every single encryption, even when encrypting the exact same plaintext with the exact same key. Reuse an IV with the same key in GCM mode and you don't just weaken the encryption — you can catastrophically break it, potentially exposing the key itself.
- The auth tag is what makes this authenticated encryption. It's not just
obfuscation — if a single byte of the ciphertext is altered after encryption (by corruption or
by an attacker),
decipher.final()throws instead of silently returning garbage. Plain, unauthenticated encryption can't tell you that. - The ciphertext, IV, and auth tag all need to be stored together (IV and auth tag aren't secret, just necessary), but the key never lives in the same place as the data it protects.
Where does the key itself come from? #
encryptSecret and decryptSecret above both just take a key: Buffer
parameter and trust it's already correct. That's deliberate — key management is a separate concern
from the encryption itself — but leaving it unaddressed after just telling you to never hardcode a
secret would skip the one place a key most commonly does get hardcoded. A few concrete rules:
- Generate it once, outside your source code.
randomBytes(32).toString("base64")produces a key suitable for AES-256. Generate it as a one-time setup step, not inside the application. - Load it from the environment, not a file in the repository. An environment
variable (
process.env.SECRET_KEY), a secrets manager, or a mounted file outside the repo are all reasonable; a file checked into version control is not, even in a private repository — history doesn't forget. - Plan for rotation before you need it. If a key is ever compromised, or you simply want to rotate it on a schedule, every value encrypted with the old key becomes unreadable under a new one. The practical fix mirrors the numbered-migration pattern from Chapter 10: decrypt everything with the old key, re-encrypt it with the new one, in a controlled one-time pass — not by discarding the old key first and hoping.
Hashing passwords you never read back #
A password should never be encrypted, because encryption implies someone, somewhere, can decrypt it — and nobody, not even your own server, should ever be able to recover a user's actual password. Instead you hash it: run it through a one-way function and store only the output.
A general-purpose hash like SHA-256 is deliberately fast, which is exactly wrong for passwords — fast means an attacker with a stolen hash can try billions of guesses per second. Password hashing needs a slow, purpose-built algorithm like scrypt or bcrypt, combined with a random salt per password so two users with the same password don't end up with the same stored hash:
hashing a password with a random saltimport { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
function hashPassword(password: string) {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, 64);
return { salt, hash };
}
function verifyPassword(password: string, salt: Buffer, storedHash: Buffer) {
const candidateHash = scryptSync(password, salt, 64);
return timingSafeEqual(candidateHash, storedHash);
}
Verification uses timingSafeEqual instead of === or Buffer.equals
on purpose. A naive byte-by-byte comparison returns as soon as it finds the first mismatched byte, which
means a wrong guess that happens to match more leading bytes takes microseconds longer to reject than
a totally wrong guess. That timing difference is measurable over enough attempts and is called a
timing
attack. timingSafeEqual always takes the same amount of time no matter where (or
whether) the values differ, so nothing about how long the check took leaks any information. One
constraint worth knowing if you reuse this pattern elsewhere: it throws if the two buffers aren't the
same length, rather than safely reporting "not equal." That's never an issue here, since
scryptSync(..., 64) always produces a fixed 64-byte output on both sides — but a
version comparing variable-length input would need to check lengths match before calling it.
scryptSync is synchronous and deliberately CPU-heavy — that expense is the
whole point, since it's what makes brute-forcing slow for an attacker too. But "synchronous and
CPU-heavy" on Node's single thread also means it blocks everything else the process is doing for
the duration of the call, the same single-thread cost model from
Chapter 12. A login endpoint under real load needs this
accounted for, either by tuning scrypt's cost parameters (Node's docs cover the N,
r, and p options) to balance security against throughput, or by offloading
the call to a worker thread. This example uses the library defaults, which are a reasonable starting
point but not tuned for any particular deployment.
Never encrypt a password (it implies someone can recover the original). Never hash an API key you need to send back to a third-party service (a hash can't be reversed, so you'd have no way to retrieve the real key). Matching the wrong technique to the wrong kind of secret is a design bug, not an implementation detail.
Basic secret hygiene #
Encryption and hashing don't help if the secret leaks somewhere else entirely. A short, practical checklist:
- Never log secrets. A stray
console.log(apiKey)during debugging can end up in a log file that far more people can read than the database ever could. - Never commit secrets. A real API key or encryption key checked into version
control is compromised the moment it's pushed, even if the commit is later reverted — git
history keeps it. Use environment variables or a dedicated secrets file that's excluded via
.gitignore. - Restrict file permissions on key material. A file holding your app's master encryption key should be readable only by the account that runs the app, not world-readable on disk.
Encryption answers "how do I keep this confidential while still being able to use it later." Hashing answers "how do I verify this without ever needing to know it." Every secret in your app is one or the other — deciding which, early, saves you from a much harder migration later.
List every secret a small chat app might store: a third-party API token for a bot integration, a user's login password, a session cookie value, an admin's two-factor recovery code. For each one, decide: encrypt (reversible) or hash (one-way) — and say why in one sentence.