Backend Cryptography & Encryption: A Practical Guide
A practical, runnable backend cryptography reference for Node.js/TypeScript developers. Covers AES-256-GCM, password hashing, HMAC, RSA, Ed25519, TLS, key management, and common mistakes.
A working reference for backend developers who know JavaScript/TypeScript and backend systems, but not cryptography. Every code example is real, runnable Node.js + TypeScript using the built-in crypto module. All examples in this guide were tested on Node.js v22.
Goal: understand enough to read, review, implement, and debug backend crypto code — not to become a cryptographer. When you finish, you should never have to guess what aes-256-gcm, an “auth tag”, or a “nonce” means in a code review again.
The Big Picture
Before any code, get the vocabulary straight. These words get used loosely in conversation, but in crypto they mean very specific, different things.
| Term | What it does | Reversible? | Needs a secret? | One-line intuition |
|---|---|---|---|---|
| Encryption | Turns readable data into unreadable ciphertext | Yes (with the key) | Yes | “Lock the data so only key-holders can read it” |
| Decryption | Turns ciphertext back into plaintext | — | Yes | “Unlock it with the key” |
| Hashing | Maps data to a fixed-size fingerprint | No (one-way) | No (plain hash) | “Fingerprint the data; can’t rebuild data from it” |
| Encoding | Re-represents bytes in another alphabet | Yes (anyone) | No | “Change the format, not the secrecy” |
| Signing | Proves who created data and that it’s unchanged | Verify, not reverse | Private key to sign | “Seal it so anyone can check who sealed it” |
| Authentication | Confirms an identity or the origin of a message | — | Usually | “Is this really from who it claims?” |
| Integrity | Guarantees data was not modified | — | Usually | “Has this been tampered with?” |
| Confidentiality | Guarantees data can’t be read by outsiders | — | Yes | “Can anyone else read this?” |
A few of these are goals, and the others are tools that achieve them:
GOALS TOOLS THAT PROVIDE THEM
───── ───────────────────────
Confidentiality ............ Encryption (AES-GCM, RSA-OAEP)
Integrity ............ Hashes, MACs, signatures, GCM auth tag
Authentication ............ HMAC, digital signatures, TLS
Non-repudiation ............ Digital signatures (Ed25519, RSA-PSS)
When to use each
Password storage → Argon2id / scrypt / bcrypt (§8)
Data encryption → AES-256-GCM (§4)
Public-key encryption → RSA-OAEP / hybrid encryption (§12, §15)
Digital signatures → Ed25519 / RSA-PSS / ECDSA (§13, §14)
Transport security → TLS / HTTPS (§16)
Message authentication → HMAC-SHA256 (§10)
Encoding (NOT secrecy) → Base64 / Hex (§20)
⚠️ Base64 and Hex are NOT encryption
This is the single most common misconception, so it comes first.
Encoding (Base64/Hex): "hello" → aGVsbG8= ← reversible by ANYONE, no key
Encryption: "hello" → 9f3a1c... ← reversible only WITH the key
aGVsbG8= looks scrambled, but anyone can decode it back to hello with a one-liner — no secret required. Base64 and Hex change how bytes are written down; they add zero confidentiality. If you ever see Base64 being used “to hide” a secret, that’s a bug, not security (see Mistake 3).
Symmetric Encryption
Symmetric encryption means the same secret key encrypts and decrypts. Sender and receiver must both hold that one key.
SAME KEY used both ways
┌──────────────┐
│ │
plaintext ──encrypt──▶ ciphertext ──decrypt──▶ plaintext
│ │
key ───────────┘
Core vocabulary
| Term | Meaning |
|---|---|
| Key | The shared secret. For AES-256 it’s 32 random bytes. Whoever has it can decrypt. |
| Plaintext | The readable input data (the thing you want to protect). |
| Ciphertext | The scrambled output. Should look like random noise. |
| IV (Initialization Vector) | A per-message random-ish value that makes identical plaintexts produce different ciphertexts. |
| Nonce | “Number used once.” In modern modes like GCM, the IV is the nonce — a value that must be unique per key. (More in §5.) |
| Authentication tag | A short value (16 bytes in GCM) that proves the ciphertext wasn’t tampered with. (More in §6.) |
| Encryption mode | The scheme that turns a block cipher (AES) into something that can encrypt real messages: CBC, CTR, GCM, etc. (More in §3.) |
Mental model — encryption direction
plaintext
+
secret key
+
nonce / IV
↓
encryption
↓
ciphertext + authentication tag
Mental model — decryption direction
ciphertext
+
secret key
+
nonce / IV
+
authentication tag
↓
decryption
↓
plaintext (only if the tag verifies — otherwise it FAILS loudly)
Notice the asymmetry that trips people up: the nonce/IV and the auth tag are not secret and travel alongside the ciphertext. Only the key is secret. You store or transmit nonce + ciphertext + tag together, and keep the key somewhere safe.
AES
AES (Advanced Encryption Standard) is the workhorse symmetric cipher. It’s fast, hardware-accelerated on modern CPUs, and trusted for everything from disk encryption to TLS.
AES is a block cipher: it encrypts data in fixed 128-bit (16-byte) blocks. That block size is the same for every AES variant — what changes is the key size.
| Variant | Key size | Block size | Notes |
|---|---|---|---|
| AES-128 | 128 bits (16 bytes) | 128 bits | Strong; fine for most uses |
| AES-192 | 192 bits (24 bytes) | 128 bits | Rarely used |
| AES-256 | 256 bits (32 bytes) | 128 bits | Default choice for new systems |
⚠️ “AES-256” does not mean your plaintext is 256 bits
The “256” refers to the key length, not the data length. You can encrypt a 3-character password or a 3-gigabyte file with AES-256 — the key is 256 bits in both cases. AES processes your data 16 bytes at a time regardless of how big it is.
AES-256 ──▶ the KEY is 256 bits
the DATA can be any size
each BLOCK processed is 128 bits
AES modes: CBC vs CTR vs GCM
Raw AES only knows how to scramble one 16-byte block. A mode of operation defines how to chain those blocks together to encrypt real-world messages and how to handle randomness. The mode matters enormously for security.
| Mode | Confidentiality | Built-in integrity? | Shape | Verdict |
|---|---|---|---|---|
| CBC | Yes | ❌ No | Block chaining, needs padding | Legacy; safe only if you add a separate MAC. Padding bugs cause real attacks (padding oracles). |
| CTR | Yes | ❌ No | Turns AES into a stream cipher | Fast, but tampering is undetectable without a separate MAC. |
| GCM | Yes | ✅ Yes | CTR + built-in authentication | Preferred for new application-level encryption. |
CBC: encrypts, but a flipped bit in ciphertext silently corrupts plaintext → no alarm
CTR: encrypts, but an attacker can flip specific bits in plaintext → no alarm
GCM: encrypts AND authenticates → any tampering makes decryption FAIL loudly
Why AES-GCM is preferred
GCM (Galois/Counter Mode) gives you AEAD: Authenticated Encryption with Associated Data. In one operation it provides:
- Confidentiality — the data is encrypted (via CTR internally).
- Integrity + authenticity — a 16-byte authentication tag detects any modification.
With CBC or CTR you must remember to separately compute and verify a MAC, and it’s easy to get that wrong (“encrypt-then-MAC” order matters, comparison must be timing-safe, etc.). GCM bundles it correctly for you. Fewer moving parts = fewer ways to introduce a vulnerability. That’s why the rest of this guide treats AES-256-GCM as the default.
AES-256-GCM Deep Dive
This is the section to internalize. Most backend “encrypt this field / token / secret” tasks are AES-256-GCM.
AES-256-GCM
│
├── 256-bit key (32 secret bytes — the ONLY secret)
├── nonce / IV (12 bytes, unique per message, NOT secret)
├── plaintext (your data, any length)
├── ciphertext (scrambled output, same length as plaintext)
└── authentication tag (16 bytes proving nothing was tampered with)
What the authentication tag actually does
The tag is a cryptographic checksum computed over the ciphertext, the nonce, and any AAD, using the key. Because it depends on the secret key, an attacker can’t recompute a valid tag after changing the data. On decryption, GCM recomputes the tag and compares. If they don’t match — even by one bit — decryption throws and returns nothing. This is what makes GCM tamper-evident.
What happens when things are wrong
┌─────────────────────────┬───────────────────────────────────────────┐
│ What you change │ Result on decryption │
├─────────────────────────┼───────────────────────────────────────────┤
│ ciphertext modified │ tag mismatch → decipher.final() THROWS │
│ authentication tag │ tag mismatch → decipher.final() THROWS │
│ modified │ │
│ wrong key │ tag mismatch → THROWS (no garbage output) │
│ wrong nonce │ tag mismatch → THROWS │
└─────────────────────────┴───────────────────────────────────────────┘
The important property: GCM never hands you silently-corrupted plaintext. Either you get the exact original bytes, or you get an exception. Contrast that with CBC/CTR, where a wrong key or tampered ciphertext just yields garbage that your app might process as if it were real.
Attacker flips one bit of ciphertext
│
▼
GCM recomputes tag over tampered data
│
recomputed tag ≠ stored tag
│
▼
decipher.final() ✗ THROWS
(you get nothing, not garbage)
Complete Node.js + TypeScript example
// GOOD: AES-256-GCM done correctly — fresh key + fresh nonce, tag verified.
import crypto from "crypto";
interface EncryptedPayload {
nonce: string; // hex — not secret, stored alongside ciphertext
ciphertext: string; // hex
authTag: string; // hex — 16 bytes
}
const ALGO = "aes-256-gcm";
// In real code the key comes from a KMS / secret manager (see §18), NOT from source.
function generateKey(): Buffer {
return crypto.randomBytes(32); // 256-bit key
}
function encrypt(plaintext: string, key: Buffer): EncryptedPayload {
// A FRESH random nonce for EVERY encryption. 12 bytes is standard for GCM.
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGO, key, nonce);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag(); // must be read AFTER final()
return {
nonce: nonce.toString("hex"),
ciphertext: ciphertext.toString("hex"),
authTag: authTag.toString("hex"),
};
}
function decrypt(payload: EncryptedPayload, key: Buffer): string {
const nonce = Buffer.from(payload.nonce, "hex");
const ciphertext = Buffer.from(payload.ciphertext, "hex");
const authTag = Buffer.from(payload.authTag, "hex");
const decipher = crypto.createDecipheriv(ALGO, key, nonce);
decipher.setAuthTag(authTag); // tell GCM which tag to verify against
// If the tag doesn't verify, .final() throws — we let it.
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plaintext.toString("utf8");
}
// --- Demo ---
const key = generateKey();
const encrypted = encrypt("my-super-secret-value", key);
console.log("Encrypted:", encrypted);
const roundtrip = decrypt(encrypted, key);
console.log("Decrypted:", roundtrip); // → my-super-secret-value
// Now tamper with the ciphertext and watch it fail:
const tampered: EncryptedPayload = {
...encrypted,
ciphertext: encrypted.ciphertext.replace(/^../, "00"), // flip first byte
};
try {
decrypt(tampered, key);
console.log("ERROR: tampering was NOT detected"); // never reached
} catch (err) {
console.log("Tampering detected — decryption refused:", (err as Error).message);
}
Running this prints the round-tripped plaintext, then confirms that modifying the ciphertext causes decryption to throw rather than return corrupted data. That thrown error is the auth tag doing its job.
IV vs Nonce
Developers constantly mix these up. Here’s the clean version.
- An IV (Initialization Vector) is an extra input, alongside the key, that randomizes encryption so the same plaintext doesn’t always produce the same ciphertext. Without it, encrypting “yes” twice would give identical ciphertext, leaking that the two messages are equal.
- A nonce means “number used once.” In AES-GCM, the IV serves as a nonce: its critical requirement is uniqueness per key, not unpredictability.
In GCM the terms are used interchangeably (createCipheriv’s third argument), but “nonce” better captures the rule that matters: never reuse it with the same key.
Why GCM uses a 12-byte nonce
GCM is defined to work most efficiently and safely with a 96-bit (12-byte) nonce. Other lengths are technically allowed but get internally hashed, which is slower and, historically, has more edge cases. Use 12 bytes. This is why every example here uses crypto.randomBytes(12).
Why the nonce doesn’t need to be secret
The nonce is an input to a public algorithm; its security value comes purely from uniqueness, not secrecy. Knowing the nonce tells an attacker nothing useful as long as they don’t have the key. That’s why it’s completely normal — and expected — to store the nonce right next to the ciphertext:
stored blob = [ nonce (12B) ][ ciphertext (N B) ][ authTag (16B) ]
not secret the data not secret
The rule: unique nonce per key
key = secret ✅ GOOD — every message gets a fresh nonce
message 1 → nonce A → ciphertext A
message 2 → nonce B → ciphertext B
message 3 → nonce C → ciphertext C
key = secret ❌ BAD — same nonce reused with same key
message 1 → nonce A
message 2 → nonce A ← CATASTROPHIC
WHY nonce reuse is dangerous (not just “don’t”)
GCM (like all counter-based modes) works by generating a pseudorandom keystream from key + nonce, then XORing it with your plaintext. The keystream depends only on the key and nonce — not on the plaintext.
So if you reuse (key, nonce) for two messages:
ciphertext1 = plaintext1 XOR keystream
ciphertext2 = plaintext2 XOR keystream (SAME keystream!)
ciphertext1 XOR ciphertext2 = plaintext1 XOR plaintext2
The keystream cancels out, and an attacker who captures both ciphertexts learns plaintext1 XOR plaintext2 — a direct relationship between your two secrets, with no key required. With known or guessable structure (JSON, English, fixed headers), this often unravels the plaintexts entirely.
Worse, for GCM specifically, nonce reuse also leaks the internal authentication key H, which lets an attacker forge valid authentication tags — defeating the integrity protection too. So a single nonce reuse can break both confidentiality and authenticity. That’s why “generate a fresh nonce every time” is non-negotiable, and why hardcoding a static IV (a very common bug — see §23) is so serious.
Authentication Tags
Why encryption alone doesn’t guarantee integrity
Confidentiality (can’t read it) and integrity (can’t change it undetected) are separate properties. Plain CTR-mode encryption gives you confidentiality but not integrity: an attacker who can’t read your ciphertext can still flip bits in it, and in CTR each flipped ciphertext bit flips the corresponding plaintext bit predictably. You’d decrypt attacker-controlled changes without noticing. Encryption ≠ tamper-proofing.
How GCM uses the authentication tag
GCM adds a MAC (Message Authentication Code) computed over the ciphertext (plus nonce and AAD) using the key. That MAC is the 16-byte authentication tag. Because producing a valid tag requires the key, an attacker can’t forge one. On decryption, GCM recomputes and compares; mismatch → failure. This is why decipher.final() can throw.
Why decipher.final() can throw
final() is where GCM performs the tag comparison. If the recomputed tag doesn’t equal the tag you set via setAuthTag(), it raises an error like “Unsupported state or unable to authenticate data.” Never swallow this error. A thrown final() means: wrong key, wrong nonce, tampered ciphertext, tampered tag, or wrong/missing AAD — all cases where you must reject the data.
Line-by-line
// Assume: key (Buffer, 32B), nonce (Buffer, 12B), ciphertext (Buffer), authTag (Buffer, 16B)
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(authTag);
// ↑ Hands GCM the tag it should expect. Must be called BEFORE final().
// This is the value final() will compare its own computation against.
const part1 = decipher.update(ciphertext);
// ↑ Feeds ciphertext in and returns decrypted bytes as they stream.
// You can call update() multiple times for chunked data.
const part2 = decipher.final();
// ↑ Finishes decryption AND verifies the tag.
// Tag matches → returns any final bytes.
// Tag mismatch → THROWS. Do not catch-and-ignore; treat as tampering.
const plaintext = Buffer.concat([part1, part2]).toString("utf8");
⚠️ Why you must not ignore authentication failures
// BAD — swallowing the failure defeats the entire point of GCM
try {
plaintext = decipher.final();
} catch {
plaintext = ""; // silently continue with tampered/garbage data — NEVER do this
}
If you catch the exception and proceed, you’ve thrown away integrity: your app now trusts data that may have been forged or corrupted. Let it throw, log it, and reject the request.
Additional Authenticated Data (AAD)
Sometimes you want to authenticate metadata without encrypting it. Example: a database row storing an encrypted user secret, where the userId and a version field are stored in the clear (you need to query on them) but must not be swappable by an attacker.
AAD (Additional Authenticated Data) is data that is fed into GCM’s tag computation but not encrypted. It becomes part of what the tag protects:
encrypted data = user secret ← confidential (in ciphertext)
AAD = userId + version ← cleartext, but AUTHENTICATED
authentication tag covers: ciphertext + nonce + AAD
Now if an attacker takes user A’s encrypted blob and tries to attach it to user B’s row (changing the userId), decryption fails — because the AAD (userId) no longer matches what the tag was computed over. AAD binds the ciphertext to its context.
Node.js example with AAD
// GOOD: bind ciphertext to userId + version using AAD.
import crypto from "crypto";
function encryptWithAAD(plaintext: string, key: Buffer, aad: string) {
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
cipher.setAAD(Buffer.from(aad, "utf8")); // set BEFORE update()
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
nonce: nonce.toString("hex"),
ciphertext: ciphertext.toString("hex"),
authTag: cipher.getAuthTag().toString("hex"),
aad, // stored/known separately, in the clear
};
}
function decryptWithAAD(
payload: { nonce: string; ciphertext: string; authTag: string },
key: Buffer,
aad: string,
): string {
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(payload.nonce, "hex"));
decipher.setAAD(Buffer.from(aad, "utf8")); // MUST match encryption's AAD
decipher.setAuthTag(Buffer.from(payload.authTag, "hex"));
return Buffer.concat([
decipher.update(Buffer.from(payload.ciphertext, "hex")),
decipher.final(),
]).toString("utf8");
}
const key = crypto.randomBytes(32);
const blob = encryptWithAAD("card-number-1234", key, "user:42|v1");
console.log(decryptWithAAD(blob, key, "user:42|v1")); // → card-number-1234
// Attacker changes the associated context:
try {
decryptWithAAD(blob, key, "user:99|v1"); // wrong userId in AAD
} catch {
console.log("AAD mismatch — ciphertext rejected"); // this branch runs
}
Key point: if the AAD passed at decryption doesn’t exactly match the AAD used at encryption, final() throws — same mechanism as ciphertext tampering.
Password Hashing
Why you should NOT encrypt passwords
Encryption is reversible by design — anyone with the key can recover the original password. That means the key becomes a single point of catastrophic failure: leak it, and every password is exposed in plaintext. You also fundamentally don’t need to recover a password: at login you only need to check whether the user typed the same one. That’s a job for a one-way function.
Encryption (reversible — WRONG for passwords):
password → ciphertext → password ← key leak = all passwords exposed
Password hashing (one-way — CORRECT):
password → hash → (cannot practically reverse)
login: hash the attempt, compare to stored hash
The building blocks
| Term | Meaning |
|---|---|
| Salt | A unique random value per password, stored alongside the hash. Ensures two users with the same password get different hashes, and defeats precomputed “rainbow table” attacks. |
| Pepper | A secret value added to all passwords, stored separately from the DB (e.g. in a secret manager). If only the DB leaks, the pepper is still missing. Optional, defense-in-depth. |
| Work factor / cost | A tunable parameter making the hash deliberately slow (CPU and/or memory). Slow hashing barely affects one legit login but massively slows an attacker guessing billions of candidates. |
The algorithms — use a password hash, not a plain hash
| Algorithm | Type | Notes |
|---|---|---|
| Argon2id | Memory-hard | Modern first choice. Winner of the Password Hashing Competition. Resists GPU/ASIC cracking via memory cost. Node: use the argon2 npm package. |
| scrypt | Memory-hard | Excellent and built into Node.js (crypto.scrypt). Great default when you don’t want a dependency. |
| bcrypt | CPU-hard | Battle-tested, still fine. Not memory-hard, and truncates inputs past ~72 bytes. Node: bcrypt npm package. |
⚠️ Why SHA-256(password) is NOT acceptable
BAD: storedHash = SHA-256(password)
SHA-256 is a general-purpose hash built to be fast — exactly the wrong property here. Three fatal problems:
- No salt → identical passwords produce identical hashes; rainbow tables crack common passwords instantly.
- Blazing fast → a modern GPU computes billions of SHA-256 hashes per second, so brute-forcing weak passwords is trivial.
- No work factor → you can’t slow it down.
Password hashes (Argon2id/scrypt/bcrypt) are intentionally slow and salted. That deliberate slowness is a feature, not a flaw.
Practical example (scrypt, built-in)
// GOOD: password hashing with Node's built-in scrypt (no dependencies).
import crypto from "crypto";
const SCRYPT_PARAMS = { N: 2 ** 15, r: 8, p: 1 }; // cost params; N is the CPU/memory factor
const KEYLEN = 64;
function hashPassword(password: string): string {
const salt = crypto.randomBytes(16); // unique per password
const derived = crypto.scryptSync(password, salt, KEYLEN, SCRYPT_PARAMS);
// Store salt + hash together (params could also be embedded for future tuning).
return `scrypt$${salt.toString("hex")}$${derived.toString("hex")}`;
}
function verifyPassword(password: string, stored: string): boolean {
const [, saltHex, hashHex] = stored.split("$");
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const actual = crypto.scryptSync(password, salt, expected.length, SCRYPT_PARAMS);
// Timing-safe comparison (see §10) — avoids leaking info via response time.
return crypto.timingSafeEqual(actual, expected);
}
const stored = hashPassword("correct horse battery staple");
console.log(verifyPassword("correct horse battery staple", stored)); // true
console.log(verifyPassword("wrong password", stored)); // false
For a new production system, Argon2id is the recommended default:
// GOOD: Argon2id via the `argon2` npm package (npm i argon2).
import argon2 from "argon2";
async function hash(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id }); // salt + params embedded in output
}
async function verify(password: string, stored: string): Promise<boolean> {
return argon2.verify(stored, password); // handles salt/params internally
}
Note: for a real service, run
scryptSyncoff the event loop for high throughput — use the asynccrypto.scryptcallback/promise form so hashing doesn’t block other requests.Syncis used above only for readability.
Hashing
A cryptographic hash maps any input to a fixed-size fingerprint (a “digest”). Unlike password hashes, these are fast — which is exactly what you want for integrity checks, not for passwords.
| Hash | Output size | Use |
|---|---|---|
| SHA-256 | 256 bits (32 bytes) | The default general-purpose hash. |
| SHA-512 | 512 bits (64 bytes) | Larger digest; sometimes faster on 64-bit CPUs. |
| SHA-3 | Variable | Newer standard with a different internal design; a good hedge, less common in practice. |
| — | Broken. Never use for security — collisions are practical. |
Key properties
- Preimage resistance — given a hash, you can’t feasibly find an input that produces it. (Can’t reverse the fingerprint.)
- Collision resistance — you can’t feasibly find two different inputs with the same hash.
- Avalanche effect — changing one bit of input flips ~half the output bits, so hashes look totally unrelated:
SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e...
SHA-256("hellp") = 7f1f2d3c9a8b... ← one letter changed → completely different
Practical backend uses
File integrity → hash a file; re-hash later; compare to detect changes
Content addressing → name/store a blob by its hash (Git, IPFS, S3 dedup)
Checksums → verify a download wasn't corrupted
Digital signatures → you sign the HASH of a message, not the whole message (§14)
HMAC → keyed hashing for message authentication (§10)
import crypto from "crypto";
const digest = crypto.createHash("sha256").update("file contents").digest("hex");
console.log(digest); // 64 hex chars
⚠️ Distinguish these three — they are NOT interchangeable
SHA-256 → fast, unkeyed fingerprint. Integrity/checksums. NOT for passwords.
HMAC-SHA256 → SHA-256 + a SECRET KEY. Proves authenticity, not just integrity (§10).
Password hashing → Argon2id/scrypt/bcrypt: SLOW + SALTED. The ONLY correct choice for passwords (§8).
Using SHA-256 where you needed HMAC (no authenticity) or where you needed a password hash (too fast, no salt) are both classic, serious bugs.
HMAC
HMAC (Hash-based Message Authentication Code) combines a hash with a secret key to prove that a message (a) wasn’t modified and (b) came from someone who holds the shared secret.
HMAC(secret, message) → tag
Plain hash: anyone can compute SHA-256(message) → integrity only
HMAC: only secret-holders can compute the tag → integrity + authenticity
Where backends use HMAC
- Webhook verification — Stripe, GitHub, Slack, etc. sign each webhook so you can confirm it truly came from them and wasn’t forged or replayed with modifications.
- API request signing — sign requests with a shared secret so the server can verify the caller and that the payload is intact (AWS SigV4 works on this principle).
- Internal service authentication — lightweight authenticity between your own services sharing a secret.
Webhook verification flow
Webhook sender
↓
payload + secret
↓
HMAC-SHA256
↓
signature ──────────────► HTTP request (payload + signature header)
↓
your backend
↓
recompute HMAC(secret, payload)
↓
compare signatures (timing-safe!)
↓
match → trust mismatch → reject (401)
Node.js example — verifying a webhook
// GOOD: HMAC webhook verification with a timing-safe comparison.
import crypto from "crypto";
function verifyWebhook(rawBody: string, receivedSig: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(receivedSig, "hex");
const b = Buffer.from(expected, "hex");
// Lengths must match before timingSafeEqual, or it throws.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
⚠️ Why === is the wrong way to compare signatures
// BAD — leaks information through timing.
return receivedSig === expected;
Normal string comparison (===) short-circuits at the first differing character. An attacker measuring response times can learn the correct signature one character at a time (a timing attack), eventually forging a valid signature. crypto.timingSafeEqual() always compares the full length in constant time, so response time reveals nothing about where a mismatch occurred. Use it for any comparison of secrets, MACs, or tokens.
Public-Key Cryptography
Asymmetric (public-key) cryptography uses a key pair instead of one shared secret:
Public key → can be shared freely (published, put in certificates, emailed)
Private key → must remain secret (never leaves the owner)
The two keys are mathematically linked, and each direction enables a different capability:
ENCRYPTION (confidentiality):
Anyone encrypts WITH your PUBLIC key
Only YOU decrypt WITH your PRIVATE key
→ "anyone can lock a box only you can open"
SIGNING (authenticity):
YOU sign WITH your PRIVATE key
Anyone verifies WITH your PUBLIC key
→ "only you can seal it; anyone can check the seal"
This solves the hardest problem in symmetric crypto: key distribution. You can publish your public key to the whole world without weakening anything, so two parties who’ve never met can communicate securely (this is what TLS bootstraps — §16).
The two families you’ll meet
- RSA — the classic. Based on the difficulty of factoring huge numbers. Bigger keys (2048–4096 bits), slower, still widely deployed. (§12)
- ECC (Elliptic-Curve Cryptography) — modern. Based on elliptic-curve math. Much smaller keys for equivalent strength (a 256-bit ECC key ≈ a 3072-bit RSA key), faster. Ed25519 and ECDSA are ECC signature schemes; ECDHE is ECC key exchange. (§13)
We deliberately skip the number theory. For backend work you need to know which primitive to pick and how to call it correctly — not how to prove it’s secure.
RSA
RSA can do both encryption and signatures, but the padding scheme matters enormously, and the two use cases need different padding. This is why writing just “RSA” in a design doc is not specific enough.
RSA-OAEP → encryption (Optimal Asymmetric Encryption Padding)
RSA-PSS → signatures (Probabilistic Signature Scheme)
Why old padding is dangerous
Legacy RSA padding (PKCS#1 v1.5 for encryption) is vulnerable to real attacks (e.g. Bleichenbacher padding-oracle attacks) that can let an attacker decrypt data or forge signatures over time. Always prefer OAEP for encryption and PSS for signatures. If you see raw RSA_PKCS1_PADDING used for encryption in a review, flag it.
RSA-OAEP encryption
// GOOD: RSA-OAEP with SHA-256.
import crypto from "crypto";
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 3072, // 2048 minimum today; 3072+ preferred for new systems
});
const ciphertext = crypto.publicEncrypt(
{ key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
Buffer.from("small secret"),
);
const plaintext = crypto.privateDecrypt(
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
ciphertext,
);
console.log(plaintext.toString()); // → small secret
RSA can only encrypt data smaller than the key size (a 3072-bit key encrypts at most a few hundred bytes). To encrypt anything larger, you use hybrid encryption — §15.
RSA-PSS signatures
// GOOD: RSA-PSS signature.
const message = Buffer.from("transfer $100 to Bob");
const signature = crypto.sign("sha256", message, {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
});
const ok = crypto.verify("sha256", message, {
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
}, signature);
console.log(ok); // true
Ed25519
Ed25519 is a modern elliptic-curve signature scheme, and it’s the default choice for new systems that need digital signatures. Why it’s preferred over RSA/ECDSA for signing:
- Small and fast — 32-byte public keys, 64-byte signatures, quick to sign and verify.
- Hard to misuse — no padding options to get wrong, deterministic (doesn’t depend on a good random number generator at signing time the way ECDSA does — a bad RNG has leaked ECDSA private keys in the wild).
- Strong, well-audited — widely deployed in SSH, TLS, signing systems, and cryptocurrencies.
private key
↓
sign(message)
↓
signature
public key
+
message
+
signature
↓
verify() → true / false
Node.js example
// GOOD: Ed25519 signing and verification.
import crypto from "crypto";
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const message = Buffer.from("release build #4291 approved");
// For Ed25519, the algorithm argument is null (the scheme fixes the hash internally).
const signature = crypto.sign(null, message, privateKey);
const valid = crypto.verify(null, message, publicKey, signature);
console.log("valid:", valid); // true
// Any change to the message invalidates the signature:
const tampered = Buffer.from("release build #4292 approved");
console.log("tampered valid:", crypto.verify(null, tampered, publicKey, signature)); // false
Notice the null where RSA needed a hash name and padding — Ed25519 has no knobs to misconfigure. That simplicity is a security feature.
Digital Signatures
A digital signature proves who created a message and that it hasn’t changed, using a private/public key pair. Contrast it with the other primitives:
| Primitive | Secret needed | Who can verify? | Provides |
|---|---|---|---|
| Encryption | Key (sym) or public key (asym) | Holder of decryption key | Confidentiality |
| Hashing | None | Anyone | Integrity (only if the hash itself is trusted) |
| HMAC | Shared secret | Anyone with the same secret | Integrity + authenticity (shared — either party could produce it) |
| Signing | Private key (to sign) | Anyone with the public key | Integrity + authenticity + non-repudiation |
The crucial difference between HMAC and signatures:
HMAC: both sides share ONE secret → either side could have made the tag
→ good for authenticity, but NOT proof of a single author
Signature: only the private-key holder can sign; the world can verify with the public key
→ proof that THIS specific holder authored it → non-repudiation
What signatures provide
- Authenticity — confirms the holder of the private key created it.
- Integrity — any change to the message breaks verification.
- Non-repudiation — the signer can’t later credibly deny signing it, with caveats: it only proves the private key signed it. If the key was stolen, shared, or the signing system was compromised, “non-repudiation” weakens. It’s a cryptographic property, not an absolute legal guarantee.
Real-world backend examples
JWT signing → server signs tokens (e.g. EdDSA/Ed25519 or RS256/RSA-PSS);
services verify with the public key without holding the private one
Software releases → sign build artifacts / packages so clients verify authenticity
Webhooks → some providers sign with a private key (verify with published public key)
Document / audit → tamper-evident, attributable records
Signature vs HMAC for JWTs: HMAC (
HS256) means every verifier also holds the signing secret — fine within one service, risky across many. Asymmetric signing (EdDSA,RS256) lets you distribute only the public key for verification while the private signing key stays in one place. Prefer asymmetric when multiple services must verify.
Hybrid Encryption
RSA can’t encrypt large data (§12) — it’s limited to less than the key size and is slow. So real systems almost never encrypt payloads directly with RSA. Instead they combine the strengths of both worlds: fast symmetric encryption for the data, public-key encryption for the key. That’s hybrid encryption, and it’s how encrypted email (PGP), TLS, and most “encrypt for a recipient’s public key” features actually work.
Random AES key
↓
Encrypt large data with AES-256-GCM ← fast, handles any size
↓
Encrypt (wrap) the AES key with recipient's public key (RSA-OAEP) ← only a tiny 32-byte key
↓
Send:
encrypted (wrapped) AES key
nonce
ciphertext
authentication tag
The recipient uses their private key to unwrap the AES key, then uses that AES key to decrypt the bulk data. Only someone with the private key can recover the AES key, and therefore the data.
Conceptual Node.js example
// GOOD: hybrid encryption — AES-256-GCM for data, RSA-OAEP to wrap the AES key.
import crypto from "crypto";
interface HybridBlob {
wrappedKey: string; // AES key encrypted with recipient's PUBLIC key (hex)
nonce: string; // hex
ciphertext: string; // hex
authTag: string; // hex
}
function hybridEncrypt(plaintext: string, recipientPublicKey: crypto.KeyObject): HybridBlob {
const aesKey = crypto.randomBytes(32); // fresh per-message symmetric key
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, nonce);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const wrappedKey = crypto.publicEncrypt(
{ key: recipientPublicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
aesKey,
);
return {
wrappedKey: wrappedKey.toString("hex"),
nonce: nonce.toString("hex"),
ciphertext: ciphertext.toString("hex"),
authTag: authTag.toString("hex"),
};
}
function hybridDecrypt(blob: HybridBlob, recipientPrivateKey: crypto.KeyObject): string {
const aesKey = crypto.privateDecrypt(
{ key: recipientPrivateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
Buffer.from(blob.wrappedKey, "hex"),
);
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, Buffer.from(blob.nonce, "hex"));
decipher.setAuthTag(Buffer.from(blob.authTag, "hex"));
return Buffer.concat([
decipher.update(Buffer.from(blob.ciphertext, "hex")),
decipher.final(),
]).toString("utf8");
}
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 3072 });
const blob = hybridEncrypt("a very long payload that RSA alone could never encrypt...", publicKey);
console.log(hybridDecrypt(blob, privateKey)); // → the original payload
TLS / HTTPS
HTTPS is just HTTP running inside a TLS-encrypted tunnel. TLS (Transport Layer Security) is what puts the padlock in the browser and encrypts data in transit between client and server.
Client
↓
TLS handshake ← agree on versions/ciphers, verify server's certificate,
↓ and establish shared session keys
keys established
↓
encrypted communication ← everything below is now confidential + integrity-protected
↓
HTTP ← your normal requests/responses ride inside the tunnel
How the primitives fit together
TLS is a combination of nearly everything in this guide:
Certificates → bind a server's identity to its public key, vouched for by a CA (§17)
RSA / ECDSA → the certificate's signature scheme; proves the cert is authentic
ECDHE → Elliptic-Curve Diffie-Hellman (Ephemeral): the client and server derive a
shared secret over the public network without ever transmitting it →
gives "forward secrecy" (past sessions stay safe even if the long-term key leaks)
AES(-GCM) → the fast symmetric cipher that encrypts the actual bulk traffic
So TLS is essentially hybrid encryption (§15) plus identity verification (§17): asymmetric crypto authenticates the server and negotiates a shared key, then symmetric AES-GCM does the heavy lifting.
Why you normally don’t hand-roll HTTPS encryption
For ordinary client↔server traffic, TLS already gives you confidentiality, integrity, and server authentication — implemented, audited, and maintained by experts. Adding your own application-layer encryption on top of HTTPS is usually unnecessary and often worse (easy to misuse, and it can create a false sense of security). You terminate TLS at your server/load balancer and let the platform handle it.
Application-level encryption (the AES-GCM you write yourself) is for data at rest — encrypting fields in a database, files in storage, secrets in a queue — not for re-encrypting what TLS already protects on the wire.
Certificates
A TLS certificate is a signed document that says “this public key belongs to this hostname,” vouched for by a trusted third party.
- Certificate Authority (CA) — an organization (Let’s Encrypt, DigiCert, etc.) that your OS/browser already trusts. The CA verifies you control a domain, then signs a certificate binding your public key to that domain.
- Public key inside the certificate — the server’s public key travels in the cert. During the handshake the client uses it to verify the server and help establish session keys.
- Certificate validation — the client checks the CA’s signature (using the CA’s public key it already trusts), forming a chain of trust up to a root CA.
- Expiration — certs are valid only for a limited window (often ~90 days now). Expired certs are rejected — a very common production outage. Automate renewal.
- Hostname verification — the client confirms the cert was issued for exactly the hostname it connected to. A valid cert for
evil.comcannot impersonatebank.com.
Real-world example
Your browser connects to https://example.com
↓
example.com sends its certificate
↓
Certificate says: "public key XYZ belongs to example.com, signed by Let's Encrypt"
↓
Browser checks: • Is Let's Encrypt in my trusted roots? ✔
• Is the CA's signature on this cert valid? ✔
• Does the cert say "example.com"? ✔
• Is it still within its validity dates? ✔
↓
All pass → padlock shown, handshake proceeds
Any fail → browser BLOCKS with a security warning
This is why a self-signed or expired certificate throws a browser warning: the chain of trust or validity check failed.
Key Management
Where your keys live matters more than which algorithm you picked. A perfect AES-256-GCM implementation with a leaked key protects nothing. This is the section that separates code that merely runs from code that’s actually secure.
⚠️ Why this is dangerous
// BAD: hardcoded secret in source code
const KEY = "my-secret-key";
Problems: it’s in your git history forever (even if deleted later), visible to everyone with repo access, copied into build artifacts and logs, and impossible to rotate without a code change and redeploy. Hardcoded secrets are one of the most common causes of real-world breaches.
// ALSO PROBLEMATIC: a static secret pulled from an insecure config source
global.CONFIG.PWDSecretKey;
This looks better because the value isn’t a literal, but if global.CONFIG is populated from a checked-in config file, a world-readable file, or an unencrypted source, it has all the same problems — plus it’s a mutable global that any code can read or overwrite. The source of the secret is what matters, not whether it’s assigned to a variable.
Where keys should come from (roughly best → acceptable)
BEST Hardware / managed KMS
→ AWS KMS, GCP KMS, Azure Key Vault: the key never leaves the service;
you send data to be encrypted/decrypted, or fetch short-lived data keys
GOOD Secret managers
→ HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager:
secrets stored encrypted, access-controlled, audited, rotatable
OK Environment variables injected at deploy time from a secret manager
→ not committed to source; process-scoped
BAD Hardcoded literals, checked-in config files, world-readable files
Concepts
| Concept | Meaning |
|---|---|
| KMS (Key Management Service) | Managed service that stores keys and performs crypto; the raw key never leaves it. |
| Envelope encryption | KMS gives you a data key to encrypt bulk data locally; KMS stores/wraps the key that protects that data key. (Same idea as hybrid encryption, applied to key management.) |
| Key rotation | Periodically switching to a new key so a single leaked key exposes less. (§19) |
| Key versioning | Tagging each key with an ID/version so you know which key encrypted which data. (§19) |
| Key separation | Using different keys for different purposes (passwords vs payments vs sessions). A leak of one doesn’t compromise the others. |
A better architecture
┌─────────────────────────────┐
App │ KMS / Secret Manager │
│ │ (keys never leave here) │
│ "encrypt └─────────────────────────────┘
│ this" ▲ │
▼ │ ▼
request a data key ────┘ returns: plaintext data key (used briefly in memory)
+ encrypted data key (stored next to ciphertext)
│
▼
encrypt data locally with AES-256-GCM using the data key,
then discard the plaintext data key from memory
Key rules of thumb: keys come from a managed service at runtime, are never logged, never committed, are scoped by purpose, and are rotatable without a code change.
Key Rotation
Key rotation means periodically replacing an encryption key with a new one, so that a compromised key exposes only a limited window of data. The challenge: old data was encrypted with the old key. If you just swap keys, you can no longer decrypt anything encrypted before the swap.
The solution is to record which key encrypted each blob, so decryption can look up the right one.
old data → encrypted with key v1 (keep v1 available for decryption)
new data → encrypted with key v2 (encrypt new writes with v2)
Self-describing ciphertext
Store metadata with the ciphertext so any reader knows how to decrypt it:
{
"version": 1,
"keyId": "payments-key-v3",
"nonce": "9f8a7b6c5d4e3f2a1b0c9d8e",
"ciphertext": "5a3f...",
"authTag": "1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f"
}
version— the format version of this envelope (lets you evolve the structure later).keyId— which key was used. On decryption you fetch that specific key from your KMS/secret manager.nonce,ciphertext,authTag— the usual AES-GCM outputs.
How decryption picks the key
read blob
↓
look at keyId → "payments-key-v3"
↓
fetch key v3 from KMS / secret manager
↓
decrypt with that key
A rotation then looks like: start encrypting new writes with v4, keep v3 (and older) available for reads, and optionally re-encrypt old data lazily (on next access) or in a background job until nothing references the retired key — at which point you can safely destroy it.
// Sketch: keyId-driven decryption
const keyring: Record<string, Buffer> = loadKeysFromKms(); // { "payments-key-v3": <Buffer>, ... }
function decryptEnvelope(blob: { keyId: string; nonce: string; ciphertext: string; authTag: string }) {
const key = keyring[blob.keyId];
if (!key) throw new Error(`Unknown keyId: ${blob.keyId}`);
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(blob.nonce, "hex"));
decipher.setAuthTag(Buffer.from(blob.authTag, "hex"));
return Buffer.concat([
decipher.update(Buffer.from(blob.ciphertext, "hex")),
decipher.final(),
]).toString("utf8");
}
Encoding
Encoding changes how bytes are written, not how secret they are. You’ll use it constantly in crypto code because keys, nonces, and ciphertext are raw bytes that need a text representation to store in JSON, DBs, or headers.
| Encoding | What it is | Example ("Hi" → ) |
|---|---|---|
| UTF-8 | How text characters map to bytes | Hi → 0x48 0x69 |
| Hex | Each byte as 2 hex digits (0–9, a–f) | 4869 |
| Base64 | 3 bytes → 4 ASCII chars (uses + / =) |
SGk= |
| Base64URL | Base64 with URL-safe chars (- _, no padding) |
SGk — safe in URLs, JWTs |
const buf = Buffer.from("Hi", "utf8");
buf.toString("hex"); // "4869"
buf.toString("base64"); // "SGk="
buf.toString("base64url"); // "SGk"
⚠️ Encoding is not encryption
hex ≠ encryption → 4869 decodes to "Hi" with ZERO secret
base64 ≠ encryption → SGk= decodes to "Hi" with ZERO secret
Anyone can reverse either instantly. Encoding is about transport/format; encryption is about secrecy. Never rely on Base64 to hide anything.
Why crypto APIs use Buffer.from(value, "hex" | "base64")
Crypto functions consume and produce raw bytes (Buffer). But you store/transmit them as text. So the pattern is: encode to text when storing, decode back to bytes when using.
// Storing: bytes → text
const nonceText = nonce.toString("hex");
// Using: text → bytes
const nonceBytes = Buffer.from(nonceText, "hex");
If you ever pass a hex string where a function expects raw bytes (or forget the "hex" argument), you’ll silently use the wrong data — a subtle and common bug. Always be explicit about the encoding.
Randomness
Cryptography lives or dies on unpredictable random values. Keys, nonces, salts, and tokens must be impossible to guess. This requires a CSPRNG (Cryptographically Secure Pseudo-Random Number Generator), which crypto provides.
⚠️ Math.random() is NOT cryptographically secure
Math.random() → fast, but PREDICTABLE. Designed for simulations/shuffles,
NOT security. Its output can be predicted from prior values.
NEVER use for keys, tokens, salts, nonces, session IDs.
crypto.randomBytes() → cryptographically secure. Unpredictable. USE THIS.
Using Math.random() to generate a password-reset token or session ID is a real, exploited vulnerability class — an attacker who can predict the RNG can forge valid tokens.
The secure tools
import crypto from "crypto";
crypto.randomBytes(32); // 32 secure random bytes (e.g. an AES-256 key)
crypto.randomBytes(12); // a GCM nonce
crypto.randomUUID(); // a random v4 UUID (secure), e.g. request/correlation IDs
crypto.randomBytes(32).toString("base64url"); // a URL-safe random token
Where secure randomness is required
Encryption keys → guessable key = no security at all
Nonces / IVs → must be unpredictable/unique (§5)
Salts → must be unique per password (§8)
Tokens → password-reset, email-verification, API tokens
Session identifiers → guessable session ID = account takeover
Rule of thumb: if a value protects something, it comes from crypto, never from Math.random().
Common Backend Cryptography Mistakes
Each mistake below shows the BAD version and the GOOD fix, with the why.
Mistake 1 — Reusing an AES-GCM nonce
// BAD: fixed nonce reused for every message
const nonce = Buffer.alloc(12, 0); // same every time
// GOOD: fresh random nonce per encryption
const nonce = crypto.randomBytes(12);
Why: reusing (key, nonce) leaks plaintext1 XOR plaintext2 and, in GCM, enables tag forgery — breaking both confidentiality and integrity (§5).
Mistake 2 — Hardcoding encryption keys
// BAD
const KEY = "0123456789abcdef0123456789abcdef";
// GOOD: from a secret manager / KMS at runtime
const KEY = await loadKeyFromKms("app-data-key");
Why: hardcoded keys live in git history and build artifacts forever and can’t be rotated (§18).
Mistake 3 — Using Base64 as “encryption”
// BAD: this hides nothing
const "encrypted" = Buffer.from(secret).toString("base64");
// GOOD: actually encrypt
const encrypted = encrypt(secret, key); // AES-256-GCM (§4)
Why: Base64 is reversible by anyone with no key (§20).
Mistake 4 — Using SHA-256 directly for passwords
// BAD
const stored = crypto.createHash("sha256").update(password).digest("hex");
// GOOD
const stored = await argon2.hash(password, { type: argon2.argon2id });
Why: SHA-256 is unsalted and far too fast; GPUs crack it at billions/sec (§8).
Mistake 5 — Encrypting passwords unnecessarily
// BAD: reversible password storage
const stored = encrypt(password, key); // recoverable → key leak exposes all passwords
// GOOD: one-way hash
const stored = await argon2.hash(password, { type: argon2.argon2id });
Why: you never need to recover a password, only to verify it. One-way hashing removes the key as a single point of failure (§8).
Mistake 6 — Ignoring authentication-tag failures
// BAD
try { pt = decipher.final(); } catch { pt = ""; } // swallow → trust tampered data
// GOOD
pt = decipher.final(); // let it throw; reject the request on failure
Why: a thrown final() is GCM telling you the data was tampered with or the key/nonce is wrong (§6).
Mistake 7 — Using ECB mode
// BAD: ECB leaks patterns; identical blocks → identical ciphertext
crypto.createCipheriv("aes-256-ecb", key, null);
// GOOD
crypto.createCipheriv("aes-256-gcm", key, crypto.randomBytes(12));
Why: ECB encrypts each block independently, so structure in the plaintext shows through in the ciphertext (the infamous “ECB penguin”). Never use ECB.
Mistake 8 — Predictable IVs/nonces
// BAD: counter that resets on restart, or timestamp
const nonce = Buffer.from(String(Date.now()).padStart(12, "0"));
// GOOD
const nonce = crypto.randomBytes(12);
Why: predictable nonces risk collisions/reuse and weaken some modes (§5, §21).
Mistake 9 — Weak random number generators
// BAD
const token = Math.random().toString(36).slice(2);
// GOOD
const token = crypto.randomBytes(32).toString("base64url");
Why: Math.random() is predictable; guessable tokens = account takeover (§21).
Mistake 10 — Rolling your own cryptographic algorithm
// BAD: home-made "encryption"
const enc = [...data].map((c) => c ^ 42).join("");
// GOOD: use vetted primitives
const enc = encrypt(data, key); // AES-256-GCM
Why: cryptography is famously easy to get subtly, catastrophically wrong. Use standard, audited primitives. Never invent your own.
Mistake 11 — Using RSA directly for large data
// BAD: RSA can't encrypt data larger than the key; throws or requires chunking
crypto.publicEncrypt(pub, largeBuffer);
// GOOD: hybrid encryption
const blob = hybridEncrypt(largeData, pub); // AES for data, RSA wraps the AES key (§15)
Why: RSA is size-limited and slow; hybrid encryption is the standard pattern.
Mistake 12 — Comparing signatures with ordinary comparison
// BAD
if (receivedSig === expectedSig) { /* ... */ }
// GOOD
if (crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expectedSig))) { /* ... */ }
Why: === short-circuits and leaks the correct value one character at a time via timing (§10).
Analyze This Real Code
Here’s a real snippet to review — the kind you’d meet in a code review. We’ll first explain what it does line by line, then critique it.
import crypto from "crypto";
export const decrypt = (encryptedPassword, authtag) => {
let PWDSecretKey = global.CONFIG.PWDSecretKey;
let PWDiv = global.CONFIG.PWDiv;
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
PWDSecretKey,
PWDiv
);
decipher.setAuthTag(Buffer.from(authtag, "hex"));
let decrypted = decipher.update(
encryptedPassword,
"hex",
"utf8"
);
decrypted += decipher.final("utf8");
return decrypted;
};
export const getcipherauth = (encryptedtext) => {
const parts = encryptedtext.split(".");
if (parts.length !== 2)
throw new Error("Invalid format: expected 'cipher.authTag'");
return {
cipher: parts[0],
auth: parts[1],
};
};
What every line does
import crypto from "crypto";— pulls in Node’s built-in crypto module.let PWDSecretKey = global.CONFIG.PWDSecretKey;— reads the AES-256 key from a global config object.let PWDiv = global.CONFIG.PWDiv;— reads the IV/nonce from the same global config.crypto.createDecipheriv("aes-256-gcm", PWDSecretKey, PWDiv)— creates a decryption object configured for AES-256-GCM, using the given key and IV. (createDecipheriv= “create decipher with IV.”) This is the decryption counterpart ofcreateCipheriv.decipher.setAuthTag(Buffer.from(authtag, "hex"))— supplies the 16-byte GCM authentication tag thatfinal()will verify.Buffer.from(authtag, "hex")converts the hex string into raw bytes, becausesetAuthTagneeds bytes (§20).setAuthTagmust be called beforefinal().decipher.update(encryptedPassword, "hex", "utf8")— feeds the ciphertext (interpreted as hex input) and returns the decrypted output as a utf8 string.decrypted += decipher.final("utf8")— finishes decryption and verifies the auth tag. If the tag doesn’t match, this throws (§6). Otherwise it appends any final decrypted bytes.return decrypted;— returns the recovered plaintext.getcipherauth— splits a stored string of the form"<cipherHex>.<authTagHex>"on the.separator, validates there are exactly two parts, and returns{ cipher, auth }. So the storage format packs ciphertext and tag into one string joined by a dot.
Concept recap in this code
- What AES-256-GCM is doing here: authenticated decryption — recovering plaintext and confirming it wasn’t tampered with.
- The key:
PWDSecretKey(must be 32 bytes for AES-256). - The IV:
PWDiv(should be the 12-byte GCM nonce). - The authentication tag:
authtag, verified duringfinal(). - Why
setAuthTag()exists: GCM needs to know the expected tag to compare against. - What
update()does: streams ciphertext in, plaintext out. - What
final()does: completes decryption and performs the tag check. - Why
final()can throw: tag mismatch (tampering, wrong key, wrong nonce, wrong tag). - What
Buffer.from(..., "hex")does: converts hex text back into raw bytes. - What the
cipher.authTagformat means: the app concatenatesciphertextHex + "." + authTagHexfor storage and splits it back apart on read.
Critical analysis
1. Static IV reuse — the biggest red flag. 🚩
PWDiv comes from a single global config value, which strongly implies the same IV is used for every encryption with the same key. As established in §5, reusing a (key, nonce) pair in GCM is catastrophic: it leaks relationships between plaintexts and can enable auth-tag forgery. A GCM nonce must be freshly generated per encryption and stored alongside the ciphertext — not fixed in config. This is almost certainly a real vulnerability. (We can only see decrypt; confirm by checking how encrypt produces the IV — if it reuses global.CONFIG.PWDiv, the bug is confirmed.)
2. Is encryption even appropriate for passwords? ❌
The naming (PWDSecretKey, encryptedPassword) suggests this decrypts passwords. If these are user account passwords, they should be hashed with Argon2id/scrypt/bcrypt, not encrypted (§8). Reversible password storage means a key leak exposes every password in plaintext. However, verify the use case first: if these are credentials this service must replay to a third party (e.g. stored SMTP/API passwords the backend needs in cleartext to authenticate elsewhere), then reversible encryption is legitimate — you genuinely need the original value back. The fix depends entirely on which case this is.
3. Key & IV management — needs verification, not assumption.
Per the task: do not assume PWDSecretKey/PWDiv are insecure without evidence. What to verify:
- Where does
global.CONFIGget populated? If from a KMS/secret manager injected at runtime → acceptable. If from a checked-in config file or hardcoded defaults → serious problem (§18). - Is the key 32 bytes of high-entropy random data, or a short human-typed string? A weak/short key undermines AES-256 regardless of the algorithm.
- Is the IV truly static (the real concern) versus merely stored in config as a default? Confirm against the encrypt path.
4. Input validation.
encryptedPassword and authtag are used directly with no validation. Malformed hex, wrong-length tag, or undefined will cause opaque throws. Validate: tag is 16 bytes, inputs are valid hex, key is 32 bytes, IV is 12 bytes.
5. Authentication-tag handling.
Good news: the code does not swallow the final() exception, so tampering will surface as a thrown error (§6). But callers must be checked — if some caller wraps this in try/catch and continues, the integrity guarantee is lost. Also ensure the thrown error isn’t logged with the plaintext or key.
6. Encoding.
Hex is used consistently for ciphertext and tag, and Buffer.from(..., "hex") is correct. No issue here — just note that the .-delimited format assumes neither part ever contains a . (hex never does, so it’s safe).
7. Error handling & information leakage. Thrown errors should be caught at the boundary and turned into a generic failure to the client — never leak whether decryption failed due to a bad tag vs. bad format, and never log the key, IV, or plaintext.
8. Does HTTPS make this unnecessary? ❌ No. This is encryption at rest (protecting stored data), while TLS/HTTPS is encryption in transit (§16). They protect different things. TLS won’t help if your database is dumped. So HTTPS does not make this code redundant — but it also means you shouldn’t add transport encryption here; keep this focused on protecting stored data (or, per point 2, switch to hashing if these are login passwords).
9. TypeScript / robustness nits.
Parameters are untyped (encryptedPassword, authtag) despite the .ts context; add types. let is used where const suffices. global.CONFIG as a mutable global is fragile — prefer an injected, typed config/secret provider.
Summary of priority fixes:
- Stop reusing a static IV — generate a fresh 12-byte nonce per encryption and store it with the ciphertext (change the format to
nonce.cipher.authTagor a JSON envelope per §19). (Highest priority.) - Decide hashing vs. encryption — if these are user login passwords, switch to Argon2id/scrypt. If they’re third-party credentials the service must replay, keep encryption but fix the IV.
- Verify key/IV provenance — confirm
global.CONFIGis fed from a secret manager, and the key is 32 random bytes. - Add input validation, typing, and boundary-level error handling.
Practical Decision Guide
“What Should I Use?”
| Requirement | Recommended primitive | Caveat |
|---|---|---|
| Store user login passwords | Argon2id (or scrypt/bcrypt) | Never encrypt; never plain SHA-256 (§8). |
| Encrypt application data (at rest) | AES-256-GCM | Fresh 12-byte nonce per message; store nonce+tag with ciphertext (§4). |
| Authenticate messages/webhooks | HMAC-SHA256 | Compare with timingSafeEqual (§10). |
| Sign with private/public keys | Ed25519 | Default for new signing; simple and misuse-resistant (§13). |
| Public-key encryption | RSA-OAEP | Only for small data (like a key); use hybrid for anything bigger (§12, §15). |
| RSA signatures | RSA-PSS | Prefer over legacy PKCS#1 v1.5 (§12). Or just use Ed25519. |
| Secure network communication | TLS (HTTPS) | Let the platform handle it; don’t hand-roll (§16). |
| Generate keys/nonces/tokens | crypto.randomBytes |
Never Math.random() (§21). |
| Encode binary as text | Base64 / Hex | Encoding, not encryption (§20). |
| Hash general data / checksums | SHA-256 / SHA-512 | Not for passwords; add a key (HMAC) if you need authenticity (§9). |
| Encrypt large data for a recipient’s public key | Hybrid encryption | AES-GCM for data + RSA-OAEP to wrap the AES key (§15). |
| Store secrets/keys | KMS / secret manager | Not source code, not checked-in config (§18). |
Backend Developer Cheat Sheet
Algorithms I should know
AES-256-GCM Symmetric authenticated encryption. Default for encrypting data at rest.
SHA-256 Fast general-purpose hash. Checksums, content addressing, signing input.
HMAC-SHA256 Keyed hash. Message/webhook authentication with a shared secret.
Argon2id Memory-hard password hash. First choice for storing passwords.
scrypt Memory-hard password hash. Built into Node; great no-dependency choice.
RSA-OAEP Public-key encryption (small data / key wrapping).
RSA-PSS Public-key signatures (modern RSA padding).
Ed25519 Modern elliptic-curve signatures. Default for new signing systems.
ECDHE Ephemeral key exchange; gives forward secrecy (used inside TLS).
Concepts I should know
Key The secret that unlocks encryption/decryption.
Nonce "Number used once"; unique-per-key value for GCM (12 bytes).
IV Initialization Vector; randomizes encryption (the nonce in GCM).
Salt Unique random value per password; defeats rainbow tables.
Pepper Secret added to all passwords, stored apart from the DB.
Ciphertext The encrypted, unreadable output.
Authentication Tag 16-byte GCM value proving the ciphertext wasn't tampered with.
AAD Extra data that's authenticated but not encrypted.
Hash One-way fixed-size fingerprint of data.
HMAC Keyed hash proving integrity + authenticity.
Signature Private-key seal anyone can verify with the public key.
Public Key Shareable half of a key pair; encrypts / verifies.
Private Key Secret half of a key pair; decrypts / signs.
Certificate CA-signed binding of a public key to a hostname.
TLS Transport encryption (HTTPS); protects data in transit.
KMS Managed key service; keys never leave it.
Key Rotation Periodically switching keys; track which key encrypted what.
Learning Order
Follow this sequence — each step builds on the previous. For each, the note says what you should be able to do before moving on.
1. Encoding → Explain why Base64/Hex aren't encryption; convert bytes↔text confidently.
2. Hashing → Know SHA-256 is one-way and fast; understand preimage/collision resistance.
3. Password hashing → Explain why passwords are hashed (Argon2id/scrypt), not encrypted or SHA-256'd.
4. Symmetric encryption→ Understand key/plaintext/ciphertext and that one key does both directions.
5. AES-GCM → Encrypt/decrypt with AES-256-GCM; know why GCM (authenticated) is preferred.
6. IV / nonce → Explain why a fresh unique nonce per key is mandatory, and why reuse is fatal.
7. Authentication tags → Explain why final() throws and why you must never ignore it.
8. HMAC → Verify a webhook signature with a timing-safe comparison.
9. Digital signatures → Explain how signing differs from HMAC (non-repudiation via key pairs).
10. Public-key crypto → Explain public vs private keys and the encrypt-with-public / sign-with-private split.
11. Hybrid encryption → Explain why big data uses AES + an RSA-wrapped key, not RSA directly.
12. TLS → Explain HTTPS as hybrid encryption + identity, and why you don't hand-roll it.
13. Certificates → Explain the CA chain of trust, expiration, and hostname verification.
14. Key management → Explain why keys belong in a KMS/secret manager, never in source.
15. Key rotation → Design self-describing ciphertext (keyId + nonce + tag) that survives rotation.
Final reminders
- Never invent your own crypto. Use vetted primitives via
crypto. - The key is the whole game. Great algorithms with a leaked or hardcoded key protect nothing.
- Fresh nonce, every time. For GCM this is non-negotiable.
- Passwords are hashed, not encrypted. Argon2id/scrypt with a salt.
- Let auth failures throw. A thrown
final()means “reject this.” - Encoding is not encryption. Base64/Hex hide nothing.
- When unsure, prefer the boring, standard, well-audited option.