● David R. Longnecker
Chapter 16 · Architecture & Practice

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 it
import { 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:

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:

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 salt
import { 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 blocks, and its defaults are tunable

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.

Encrypt flow: plaintext, key, and random IV combine into ciphertext and an auth tag. Hash flow: password and random salt combine into a one-way hash that is never reversed. Encrypt (reversible) plaintext key random IV ciphertext + auth tag decrypt with same key + IV Hash (one-way) password random salt one-way hash (scrypt/bcrypt) never reversed
Encryption is a two-way door with a key. Hashing is a one-way door: only verification (re-hash and compare) is possible, never recovery.
Never do this

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:

Key idea

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.

Try it

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.