Skip to content
back to writing

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.

#cryptography#security#typescript#node.js#backend

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:

  1. No salt → identical passwords produce identical hashes; rainbow tables crack common passwords instantly.
  2. Blazing fast → a modern GPU computes billions of SHA-256 hashes per second, so brute-forcing weak passwords is trivial.
  3. 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 scryptSync off the event loop for high throughput — use the async crypto.scrypt callback/promise form so hashing doesn’t block other requests. Sync is 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.
MD5, SHA-1 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.com cannot impersonate bank.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).
  • keyIdwhich 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 Hi0x48 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 of createCipheriv.
  • decipher.setAuthTag(Buffer.from(authtag, "hex")) — supplies the 16-byte GCM authentication tag that final() will verify. Buffer.from(authtag, "hex") converts the hex string into raw bytes, because setAuthTag needs bytes (§20). setAuthTag must be called before final().
  • 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 during final().
  • 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.authTag format means: the app concatenates ciphertextHex + "." + authTagHex for 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.CONFIG get 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:

  1. 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.authTag or a JSON envelope per §19). (Highest priority.)
  2. 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.
  3. Verify key/IV provenance — confirm global.CONFIG is fed from a secret manager, and the key is 32 random bytes.
  4. 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.