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:#fff

SHA-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:#fff
const 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);
}

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)
InstallationNot needednpm install bcrypt
Memory-hardnessYes (stronger)Limited
Customization parametersMore flexibleOnly saltRounds
RecommendationFor new projectsFor 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 timingSafeEqual when 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.
  • timingSafeEqual for 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.

← Previous: Math   Next: URL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact