Crypto #
Cryptographic security is the foundation of almost every system handling sensitive data — storing passwords, verifying data integrity, securing communication, or generating unpredictable tokens. Node.js provides the built-in crypto module that implements industry-standard cryptographic algorithms without needing external libraries. Understanding when to use hashing, HMAC, symmetric encryption, or password hashing — and more importantly, when not to use each — is a skill that directly impacts the security of the applications you build.
The Node.js crypto Module #
import crypto from "crypto";
// or import specific functions
import {
createHash,
createHmac,
createCipheriv,
createDecipheriv,
randomBytes,
randomUUID,
scrypt,
timingSafeEqual,
} from "crypto";
The Node.js crypto module uses OpenSSL underneath — all algorithms available in the system’s OpenSSL installation can be used. To see the list of available algorithms:
// list all available hash algorithms
const algoritmHash = crypto.getHashes();
console.log(algoritmHash);
// ["md5", "sha1", "sha256", "sha384", "sha512", "sha3-256", ...]
// list all available cipher algorithms
const algoritmCipher = crypto.getCiphers();
console.log(algoritmCipher);
// ["aes-128-cbc", "aes-256-gcm", "chacha20-poly1305", ...]
Hashing #
A hash is a one-way function — it converts data of any length into a fixed-length string. It can’t be reversed. It’s used to verify data integrity, not for encryption.
flowchart LR
A["Original data\n'Hello World'"] --> B[Hash Function\nSHA-256]
B --> C["Hash\na591a6d10b..."]
D["Changed data\n'Hello World!'"] --> B
B --> E["Different hash\n2d31a09f3c..."]
style C fill:#16a34a,color:#fff
style E fill:#dc2626,color:#fffSHA-256 and SHA-512 #
SHA-256 and SHA-512 are the recommended hash algorithms for general use — file checksums, data fingerprints, deterministic IDs.
// hash a string with SHA-256
function hashSHA256(data: string): string {
return crypto.createHash("sha256").update(data, "utf-8").digest("hex");
}
// hash a string with SHA-512
function hashSHA512(data: string): string {
return crypto.createHash("sha512").update(data, "utf-8").digest("hex");
}
console.log(hashSHA256("Hello, World!"));
// "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986d"
// a hash always produces the same output for the same input
console.log(hashSHA256("Hello, World!") === hashSHA256("Hello, World!")); // true
// one different character produces a very different hash (avalanche effect)
console.log(hashSHA256("Hello, World!"));
// "dffd6021..."
console.log(hashSHA256("Hello, World?")); // question mark, not exclamation mark
// "f2d9e458..." — completely different
// hashes can be in different formats
function hashDenganFormat(
data: string,
algoritma: string = "sha256",
format: "hex" | "base64" | "base64url" = "hex"
): string {
return crypto.createHash(algoritma).update(data, "utf-8").digest(format);
}
console.log(hashDenganFormat("data", "sha256", "hex"));
// "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
console.log(hashDenganFormat("data", "sha256", "base64"));
// "Om6weQ85rIfJTzhWst0sXREOaBFgImGpqSPTuyrty7c="
// hash a Buffer (for binary files)
function hashBuffer(data: Buffer, algoritma: string = "sha256"): string {
return crypto.createHash(algoritma).update(data).digest("hex");
}
// hash a file as a stream — for large files
import { createReadStream } from "fs";
async function hashFile(filePath: string, algoritma: string = "sha256"): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(algoritma);
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
}
// verify the integrity of a downloaded file
async function verifikasiFile(
filePath: string,
hashYangDiharapkan: string,
algoritma: string = "sha256"
): Promise<boolean> {
const hashAktual = await hashFile(filePath, algoritma);
// CORRECT: use timingSafeEqual for hash comparison
// prevents timing attacks
const bufA = Buffer.from(hashAktual, "hex");
const bufB = Buffer.from(hashYangDiharapkan, "hex");
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
Don’t use MD5 or SHA-1 for security purposes — both are considered insecure and vulnerable to collision attacks. Use SHA-256 or higher. MD5 is still acceptable for non-security purposes like simple checksums or cache keys, but not for verifying the authenticity of data from untrusted sources.
HMAC — Hashing with a Secret Key #
HMAC (Hash-based Message Authentication Code) combines hashing with a secret key. It’s used to verify that a message hasn’t been modified and comes from the party holding the key — unlike a regular hash which only verifies integrity without authentication.
sequenceDiagram
participant Pengirim
participant Penerima
Pengirim->>Pengirim: HMAC(message + secret_key)
Pengirim->>Penerima: message + signature
Penerima->>Penerima: HMAC(message + secret_key)
Penerima->>Penerima: compare signatures
alt Signature matches
Penerima->>Penerima: ✓ message is authentic and unmodified
else Signature differs
Penerima->>Penerima: ✗ message modified or not from a legitimate sender
end// create an HMAC
function buatHMAC(data: string, kunci: string, algoritma: string = "sha256"): string {
return crypto.createHmac(algoritma, kunci).update(data, "utf-8").digest("hex");
}
// verify an HMAC — MUST use timingSafeEqual
function verifikasiHMAC(
data: string,
kunci: string,
hmacYangDiterima: string,
algoritma: string = "sha256"
): boolean {
const hmacYangDihitung = buatHMAC(data, kunci, algoritma);
// ANTI-PATTERN: plain string comparison — vulnerable to timing attacks
// return hmacYangDihitung === hmacYangDiterima; ✗
// CORRECT: timingSafeEqual — comparison time is always constant
const bufA = Buffer.from(hmacYangDihitung, "hex");
const bufB = Buffer.from(hmacYangDiterima, "hex");
// lengths must match first before timingSafeEqual
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB); // ✓
}
const kunci = process.env.HMAC_SECRET_KEY!;
const payload = JSON.stringify({ userId: 123, action: "transfer", jumlah: 500000 });
const signature = buatHMAC(payload, kunci);
console.log("Signature:", signature);
const valid = verifikasiHMAC(payload, kunci, signature);
console.log("Valid:", valid); // true
// modified payload
const payloadDimodifikasi = JSON.stringify({ userId: 123, action: "transfer", jumlah: 999999 });
const validDimodifikasi = verifikasiHMAC(payloadDimodifikasi, kunci, signature);
console.log("Valid (modified):", validDimodifikasi); // false
Signed URLs — URLs That Can’t Be Forged #
// create a URL with an automatically expiring signature
function buatSignedURL(
basePath: string,
params: Record<string, string>,
kunci: string,
ttlDetik: number = 3600
): string {
const kedaluwarsa = Math.floor(Date.now() / 1000) + ttlDetik;
const semuaParam = { ...params, expires: String(kedaluwarsa) };
// sort the parameters so the signature is deterministic
const queryString = Object.entries(semuaParam)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join("&");
const dataUntukSign = `${basePath}?${queryString}`;
const signature = buatHMAC(dataUntukSign, kunci, "sha256").slice(0, 32);
return `${dataUntukSign}&sig=${signature}`;
}
function verifikasiSignedURL(url: string, kunci: string): boolean {
const [pathDanQuery, sigPart] = url.split("&sig=");
if (!sigPart) return false;
// check expiration
const params = new URLSearchParams(pathDanQuery.split("?")[1]);
const expires = Number(params.get("expires"));
if (Date.now() / 1000 > expires) return false; // already expired
// verify the signature
const signatureYangDiharapkan = buatHMAC(pathDanQuery, kunci, "sha256").slice(0, 32);
const bufA = Buffer.from(sigPart);
const bufB = Buffer.from(signatureYangDiharapkan);
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
Symmetric Encryption — AES #
Encryption differs from hashing — encrypted data can be decrypted back with the same key. AES-256-GCM is the recommended encryption mode because it provides encryption plus integrity authentication (authenticated encryption).
flowchart LR
A["Plaintext\n'secret data'"] -- "Key + IV" --> B[AES-256-GCM]
B --> C["Ciphertext\n+ Auth Tag"]
C -- "Key + IV\n+ Auth Tag" --> D[Decryption]
D --> E["Plaintext\n'secret data'"]
style A fill:#374151,color:#fff
style E fill:#374151,color:#fff
style C fill:#1e40af,color:#fffconst ALGORITMA = "aes-256-gcm";
const PANJANG_KUNCI = 32; // 256 bits
const PANJANG_IV = 16; // 128 bits — the standard size for GCM
interface HasilEnkripsi {
ciphertext: string; // encrypted data in hex
iv: string; // initialization vector in hex
authTag: string; // authentication tag in hex
}
// encrypt data
function enkripsi(plaintext: string, kunci: Buffer): HasilEnkripsi {
// the IV must be unique for every encryption — never reuse the same IV
const iv = crypto.randomBytes(PANJANG_IV);
const cipher = crypto.createCipheriv(ALGORITMA, kunci, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf-8"),
cipher.final(),
]);
// get the authentication tag — important for verification during decryption
const authTag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString("hex"),
iv: iv.toString("hex"),
authTag: authTag.toString("hex"),
};
}
// decrypt data
function dekripsi(hasil: HasilEnkripsi, kunci: Buffer): string {
const decipher = crypto.createDecipheriv(
ALGORITMA,
kunci,
Buffer.from(hasil.iv, "hex")
);
// set the authentication tag before decryption — GCM will verify integrity
decipher.setAuthTag(Buffer.from(hasil.authTag, "hex"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(hasil.ciphertext, "hex")),
decipher.final(), // throws an error if the authTag doesn't match
]);
return decrypted.toString("utf-8");
}
// derive a key from a password using scrypt
async function deriveKunci(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, PANJANG_KUNCI, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
}
// end-to-end example usage
async function contohEnkripsiDekripsi(): Promise<void> {
const password = "password-rahasia-yang-kuat";
const salt = crypto.randomBytes(32); // store the salt alongside the encrypted data
const kunci = await deriveKunci(password, salt);
const dataRahasia = JSON.stringify({
kartuKredit: "4111-1111-1111-1111",
cvv: "123",
kedaluwarsa: "12/26",
});
// encrypt
const terenkripsi = enkripsi(dataRahasia, kunci);
console.log("Encrypted:", terenkripsi);
// decrypt
const terdekripsi = dekripsi(terenkripsi, kunci);
console.log("Decrypted:", terdekripsi);
}
Never reuse an IV (Initialization Vector) with the same key. Every encryption operation must use a new, randomly generated IV. Using the same IV twice with the same key destroys encryption security completely — an attacker can recover the plaintext by XORing the two ciphertexts.
Secure Random Tokens #
For tokens used in security contexts — session tokens, password reset links, OTPs, API keys — always use crypto.randomBytes(), not Math.random().
// ANTI-PATTERN: tokens from Math.random() — not cryptographically secure
function tokenTidakAman(): string {
return Math.random().toString(36).slice(2); // ✗ can be predicted
}
// CORRECT: tokens from crypto.randomBytes()
function generateToken(panjangBytes: number = 32): string {
return crypto.randomBytes(panjangBytes).toString("hex");
// 32 bytes = 64 hex characters = 256 bits of entropy
}
function generateTokenBase64URL(panjangBytes: number = 32): string {
return crypto.randomBytes(panjangBytes).toString("base64url");
// base64url is URL-safe — no +, /, = characters
}
// UUID v4 — universal unique identifier
function generateUUID(): string {
return crypto.randomUUID();
// "550e8400-e29b-41d4-a716-446655440000"
}
// a numeric OTP with N digits
function generateOTP(panjang: number = 6): string {
// randomBytes produces values 0-255 per byte
// for a numeric OTP, take modulo 10 per digit
const bytes = crypto.randomBytes(panjang);
return Array.from(bytes)
.map((b) => b % 10)
.join("");
}
console.log(generateOTP(6)); // e.g. "847261"
console.log(generateOTP(8)); // e.g. "37291046"
// API keys with a readable format: prefix + token
function generateAPIKey(prefix: string = "sk"): string {
const token = crypto.randomBytes(24).toString("base64url");
return `${prefix}_${token}`;
}
console.log(generateAPIKey("sk")); // e.g. "sk_a3mK9pLx..."
console.log(generateAPIKey("pk")); // e.g. "pk_7nRt2yBq..."
// tokens with an expiration
interface TokenDenganExpiry {
token: string;
kedaluwarsa: Date;
}
function generateTokenDenganExpiry(ttlMenit: number = 60): TokenDenganExpiry {
const token = generateToken(32);
const kedaluwarsa = new Date(Date.now() + ttlMenit * 60 * 1000);
return { token, kedaluwarsa };
}
// store the token hash in the database, not the original token
// so if the database leaks, the tokens can't be used directly
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
async function buatTokenResetPassword(userId: string): Promise<string> {
const { token, kedaluwarsa } = generateTokenDenganExpiry(30); // 30 minutes
const hashTokenReset = hashToken(token);
// store the hash in the database
await db.simpan("password_reset_tokens", {
userId,
tokenHash: hashTokenReset,
kedaluwarsa,
});
// return the original token to send to the user's email
return token;
}
async function verifikasiTokenReset(token: string): Promise<string | null> {
const hashTokenInput = hashToken(token);
const record = await db.cari("password_reset_tokens", {
tokenHash: hashTokenInput,
kedaluwarsa: { $gt: new Date() }, // not yet expired
});
return record?.userId ?? null;
}
// placeholder for db — implement according to the database in use
const db = {
simpan: async (_collection: string, _data: unknown) => {},
cari: async (_collection: string, _query: unknown) => null as any,
};
Password Hashing — scrypt and bcrypt #
Passwords must not be stored as plaintext, and they must not be hashed with plain SHA-256. Use algorithms designed specifically for passwords — deliberately slow by design to slow down brute force attacks.
scrypt — Built into Node.js #
interface HasilHashPassword {
hash: string; // hash in hex
salt: string; // salt in hex
}
// hash a new password
async function hashPassword(password: string): Promise<HasilHashPassword> {
const salt = crypto.randomBytes(32); // a unique salt per password
return new Promise((resolve, reject) => {
crypto.scrypt(
password,
salt,
64, // output length in bytes
{
N: 16384, // cost factor — larger is slower (default: 16384)
r: 8, // block size
p: 1, // parallelization factor
},
(err, derivedKey) => {
if (err) reject(err);
else resolve({
hash: derivedKey.toString("hex"),
salt: salt.toString("hex"),
});
}
);
});
}
// verify a password
async function verifikasiPassword(
passwordInput: string,
hashTersimpan: string,
saltTersimpan: string
): Promise<boolean> {
const salt = Buffer.from(saltTersimpan, "hex");
return new Promise((resolve, reject) => {
crypto.scrypt(passwordInput, salt, 64, { N: 16384, r: 8, p: 1 }, (err, derivedKey) => {
if (err) reject(err);
else {
const hashInput = derivedKey;
const hashDB = Buffer.from(hashTersimpan, "hex");
// timingSafeEqual — prevents timing attacks
if (hashInput.length !== hashDB.length) {
resolve(false);
} else {
resolve(crypto.timingSafeEqual(hashInput, hashDB));
}
}
});
});
}
// example registration and login flow
async function registrasi(email: string, password: string): Promise<void> {
const { hash, salt } = await hashPassword(password);
await db.simpan("users", { email, passwordHash: hash, passwordSalt: salt });
console.log("User created successfully");
}
async function login(email: string, passwordInput: string): Promise<boolean> {
const user = await db.cari("users", { email });
if (!user) return false;
return verifikasiPassword(passwordInput, user.passwordHash, user.passwordSalt);
}
bcrypt — A Popular Library #
For projects already using bcrypt, here’s the correct pattern:
import bcrypt from "bcrypt";
// DON'T install it unless needed — Node.js's built-in scrypt is sufficient
// npm install bcrypt
// npm install --save-dev @types/bcrypt
const SALT_ROUNDS = 12; // 10–14 is the commonly used range
async function hashPasswordBcrypt(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
// bcrypt manages the salt internally — no manual generation needed
}
async function verifikasiPasswordBcrypt(
passwordInput: string,
hashTersimpan: string
): Promise<boolean> {
return bcrypt.compare(passwordInput, hashTersimpan);
// bcrypt.compare is timing-safe internally
}
| scrypt (built-in) | bcrypt (library) | |
|---|---|---|
| Installation | Not needed | npm install bcrypt |
| Memory-hardness | Yes (stronger) | Limited |
| Customization parameters | More flexible | Only saltRounds |
| Recommendation | For new projects | For projects already using bcrypt |
Timing-Safe Comparison #
A plain string comparison (===) returns false immediately when it finds a different character — this leaks timing information to attackers about how many characters already matched.
// ANTI-PATTERN: plain string comparison for security values
function verifikasiTokenTidakAman(tokenInput: string, tokenSah: string): boolean {
return tokenInput === tokenSah; // ✗ vulnerable to timing attacks
}
// CORRECT: timingSafeEqual — always compares the entire string
function verifikasiTokenAman(tokenInput: string, tokenSah: string): boolean {
// convert to Buffers first — timingSafeEqual only accepts Buffers
const bufInput = Buffer.from(tokenInput, "utf-8");
const bufSah = Buffer.from(tokenSah, "utf-8");
// lengths must match — if not, return false but keep the time constant
if (bufInput.length !== bufSah.length) {
// XOR with itself so there's still an operation that takes constant time
crypto.timingSafeEqual(bufSah, bufSah);
return false;
}
return crypto.timingSafeEqual(bufInput, bufSah); // ✓
}
When to Use What #
Hash (SHA-256, SHA-512):
✓ Checksum / file integrity verification
✓ Data fingerprints for cache keys or deterministic IDs
✓ Storing token hashes in the database (after the token is sent to the user)
✗ NOT for passwords — no salt and too fast
✗ NOT for data that needs to be decrypted back
HMAC:
✓ Verifying that data is unmodified AND comes from a specific party
✓ Signed URLs, webhook signatures, JWT signatures
✓ Session tokens not stored in the database (stateless)
✗ NOT for passwords
Symmetric Encryption (AES-256-GCM):
✓ Data that needs to be decrypted — credit card numbers, medical data, secret configs
✓ File or database field encryption
✗ NOT for passwords — there's no reason to decrypt a password
✗ Don't use ECB or CBC without authentication
Password Hashing (scrypt, bcrypt):
✓ The ONLY choice for storing passwords
✓ Hashing PINs or passphrases
✗ NOT for non-password data — too slow
Secure Random Tokens (crypto.randomBytes):
✓ Session tokens, API keys, password reset links, OTPs
✗ NEVER use Math.random() for security purposes
Summary #
- SHA-256 for general hashing — checksums, fingerprints, cache keys. Avoid MD5 and SHA-1 in security contexts because they’re already collision-vulnerable.
- HMAC for message authentication — proves the data is unmodified and comes from the party holding the secret key. Always use
timingSafeEqualwhen comparing HMACs.- AES-256-GCM for encryption — the only recommended encryption mode because it combines encryption with integrity authentication. Always generate a new IV for every encryption operation.
- scrypt or bcrypt for passwords — never store passwords as plaintext or plain SHA hashes. scrypt is already available in Node.js without additional installation.
crypto.randomBytes()for all security tokens — sessions, API keys, OTPs, reset links.Math.random()isn’t cryptographic and can be predicted.- Store token hashes in the database — don’t store the original token. If the database leaks, the hash can’t be used directly because attackers don’t know the original token.
timingSafeEqualfor all security value comparisons — prevents timing attacks that attackers could use to guess values character by character.- Derive keys from passwords with scrypt — don’t use the password directly as an AES encryption key. Always use a key derivation function with a unique salt.