Cryptography and Security

By Syncfusion®, Inc. · Last updated

What node:crypto covers and how it compares with Web Crypto, hashing with createHash and hashing files with streams, hashing versus encryption, weak algorithms, HMAC, secure random values with randomBytes, randomInt and randomUUID, password hashing with salts, scrypt, pbkdf2, cost settings and timingSafeEqual, AES-256-GCM encryption with IVs and auth tags, key derivation, asymmetric keys and signatures, secure coding against path traversal, command injection, prototype pollution, ReDoS, eval and vm, decompression bombs and supply chain attacks, and the Node.js permission model.

Cryptography and Security

Cryptography = maths that keeps data secret and proves it was not changed.

Security = writing code so attackers cannot misuse it.

  • node:crypto gives you hashing, signing, random values and encryption.
  • Most attacks on Node.js apps come from small coding mistakes, not broken maths.
  • This lesson covers both: the tools, and the mistakes to avoid.

Simple lesson: use proven crypto tools, and never trust input from outside.

A Real-World Example

Think of the exam paper store room.

  • Papers go into sealed envelopes. A broken seal shows someone opened it. That is a signature.
  • The store room has a lock. Only teachers with keys get in. That is encryption.
  • The principal never writes the door code on the notice board. That is keeping secrets out of code.
  • A visitor's note saying "let me in, I'm a teacher" is checked, not trusted. That is input validation.

What You Will Learn

In this lesson, you will learn:

  1. What node:crypto covers, and Web Crypto
  2. Hashing data and files, and weak algorithms
  3. HMAC signatures and secure random values
  4. Password hashing with salts, scrypt, pbkdf2 and timingSafeEqual
  5. AES-256-GCM encryption, IVs and auth tags
  6. Asymmetric keys and signatures (advanced)
  7. Path traversal, command injection and prototype pollution
  8. ReDoS, eval and vm, decompression bombs and supply chain risks
  9. The permission model

The crypto Module

  • Hash = a fixed-size fingerprint of data. Same input, same fingerprint. You cannot turn it back.
  • HMAC = a hash mixed with a secret key. Proves who made the data.
  • Random values = numbers and ids that nobody can guess.
  • Encryption = scramble data with a key, and unscramble it with the key.
  • Signature = proof, made with a private key, that data came from you.
  • node:crypto vs Web Crypto: node:crypto is the classic Node.js API. crypto.subtle is the browser-style Web Crypto API, also in Node.js. Both use the same strong code underneath.

Simple lesson: hash to fingerprint, HMAC to prove the sender, encrypt to hide.

Hashing

  • createHash("sha256").update(data).digest("hex") = the SHA-256 fingerprint as hex text.
  • Change one letter and the whole hash changes.
  • Hashing vs encryption: a hash cannot be undone. Encryption can be undone with the key.
  • Weak algorithms: MD5 and SHA-1 are broken for security. Use SHA-256 or better.

Never invent your own encryption. Use the tools in node:crypto exactly as documented.

Hashing Files with Streams

  • A hash object is a stream: pipe a file into it.
  • Memory stays small, even for huge files.
  • Websites publish file hashes so you can check a download was not changed.

Simple lesson: SHA-256 gives a fingerprint. Never use MD5 or SHA-1 for security.

A hash turns any input into a fixed-size fingerprint. One small change gives a totally different hash "Asha 91" "Asha 92" SHA-256one way only

4f1c…9a07 b83e…12d5

Same input, same hash. You cannot get the input back from the hash.

A hash is a fingerprint of data, not a way to hide it and get it back.

HMAC and Random Values

HMAC = a hash made with a secret key. Only someone with the key can make the right one.

  • Use it to sign data you send out, such as a payment webhook or a download link.
  • The receiver makes the HMAC again with the same key and compares.

Secure Random Values

  • randomBytes(n) = n random bytes. Use them for tokens and keys.
  • randomInt(min, max) = a random whole number, from min up to below max.
  • randomUUID() = a random id like 1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed.
  • Why not Math.random()? It is made for games. Its next values can be guessed. Never use it for passwords, tokens or OTPs.

Keep secret keys in environment variables, and use a different key in each environment.

Simple lesson: sign with HMAC, and make tokens with crypto, never with Math.random().

Password Hashing

Never store passwords as plain text, and never with a fast hash like SHA-256.

  • Salt = random bytes added to each password before hashing. Two students with the same password get different hashes.
  • Slow hash = scrypt or pbkdf2. They are slow on purpose, so guessing millions of passwords takes too long.
  • Cost = how slow. scrypt has N. pbkdf2 has iterations (OWASP suggests 600,000 with SHA-256).
  • timingSafeEqual = compare in a fixed time, so attackers learn nothing from how fast a check fails.
  • Store the salt next to the hash. The salt is not secret.

Many apps use a ready library such as bcrypt or argon2. They follow the same idea: a salt plus a slow hash.

Animation: a password plus a random salt goes through a slow hash, and the salt and hash are stored together password random salt slow hashscrypt / pbkdf2 storedsalt:hash

To log in: hash the typed password with the stored salt and compare

Salt plus a slow hash: the same password never gives the same stored value.

// main.mjs: scrypt is the other good choice
import { scrypt, randomBytes, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);
const salt = randomBytes(16);
const options = { N: 16384, r: 8, p: 1 }; // N is the cost: higher is slower

const stored = await scryptAsync("green@2026", salt, 32, options);
const attempt = await scryptAsync("green@2026", salt, 32, options);
console.log("scrypt hash bytes:", stored.length);
console.log("Password correct?", timingSafeEqual(stored, attempt));

Output:

scrypt hash bytes: 32
Password correct? true

Simple lesson: salt + slow hash + timingSafeEqual. Never plain text, never a fast hash.

Encryption

AES-256-GCM = a strong, modern way to encrypt data with one secret key.

  • Key = 32 random bytes. Keep it secret, for example in an environment variable.
  • IV (or nonce) = 12 random bytes, new for every message. It is not secret.
  • Auth tag = proof that the encrypted data was not changed. Decryption fails if it was.
  • Store iv, authTag and the encrypted text together.
  • Key derivation = turn a password into a key with scrypt or pbkdf2, never use the password itself.
// main.mjs
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";

const key = randomBytes(32); // AES-256 needs 32 bytes

function encrypt(text) {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const data = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
  return { iv, data, tag: cipher.getAuthTag() };
}

function decrypt({ iv, data, tag }) {
  const decipher = createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
}

const box = encrypt("Ravi's medical note");
console.log("Encrypted looks like:", box.data.toString("base64").slice(0, 12) + "...");
console.log("Decrypted:", decrypt(box));

box.data[0] ^= 1; // someone changes one bit
try {
  decrypt(box);
} catch (err) {
  console.log("Changed data rejected:", err.message);
}

Output:

Encrypted looks like: 6J6ff3kgtM9X...
Decrypted: Ravi's medical note
Changed data rejected: Unsupported state or unable to authenticate data

Asymmetric Keys and Signatures (Advanced)

  • Key pair = a private key you keep and a public key you share.
  • Sign with the private key. Anyone verifies with the public key.
  • Used by HTTPS certificates, JWT tokens (see Token Authentication and Authorization in Express.js) and software updates.
// main.mjs
import { generateKeyPairSync, sign, verify } from "node:crypto";

const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const notice = Buffer.from("School closed on Friday");

const signature = sign(null, notice, privateKey);
console.log("Real notice verified?", verify(null, notice, publicKey, signature));
console.log("Fake notice verified?", verify(null, Buffer.from("School closed all week"), publicKey, signature));

Output:

Real notice verified? true
Fake notice verified? false

Simple lesson: AES-GCM with a fresh IV per message, and key pairs for signatures.

Secure Coding in Node.js

Path Traversal

Path traversal = a request like ../../etc/passwd that climbs out of the folder you meant to share.

  • Resolve the full path, then check it is still inside your folder.

Command Injection in child_process

Command injection = user input that becomes part of a shell command.

  • With exec, the shell sees ; and runs rm -rf reports as a second command.
  • With execFile or spawn and an argument list, there is no shell. ; is just a letter.

Prototype Pollution

Prototype pollution = input with a key like __proto__ that adds properties to every object.

  • Skip __proto__, constructor and prototype keys.
  • Use Object.create(null) or a Map for data with user-chosen keys.
  • Validate input with a schema library such as Zod.

Regular Expression DoS (ReDoS)

  • Some regular expressions take exponential time on certain inputs, such as /^(a+)+$/.
  • One request with a crafted string can freeze the whole event loop.
  • Avoid nested repeats like (a+)+, limit the input length, and test patterns with tools.
// main.mjs: each extra letter roughly doubles the time
const evil = /^(a+)+$/;
evil.test("aaaa!"); // warm up
for (const n of [22, 24, 26]) {
  const input = "a".repeat(n) + "!";
  const t0 = performance.now();
  evil.test(input);
  console.log(`${n} letters: ${(performance.now() - t0).toFixed(0)} ms`);
}

Output:

22 letters: 30 ms
24 letters: 117 ms
26 letters: 455 ms

eval() and vm Are Not Sandboxes

  • eval(text) runs any code, with your program's power. Never pass user input to it.
  • node:vm runs code in a separate context, but it is not a security boundary.
// main.mjs: code inside vm escapes and reaches process
import vm from "node:vm";

const escaped = vm.runInNewContext("this.constructor.constructor('return process')().version");
console.log("Code in vm reached the real process:", escaped.startsWith("v"));

Output:

Code in vm reached the real process: true

Decompression Limits

  • A small zip or gzip file can expand to gigabytes. This is a decompression bomb.
  • Set maxOutputLength on zlib functions, and stop streams after a size limit.

Dependency and Supply Chain Risks

  • Typosquatting = a fake package with a look-alike name, such as expresss.
  • Install scripts = postinstall code that runs on your computer during npm install.
  • Lockfile review = check package-lock.json changes in pull requests for strange new packages.
  • Run npm audit, keep packages few and up to date (see Packages and npm).

Simple lesson: check paths, never build shell commands from input, block __proto__, and keep regexes simple.

Permission Model (Advanced)

Permission model = a Node.js mode where the program may only touch what you allow.

  • node --permission app.js = turn it on. By default, almost nothing is allowed.
  • --allow-fs-read=<path> and --allow-fs-write=<path> = allow files and folders.
  • --allow-child-process and --allow-worker = allow child processes and worker threads.
  • Blocked actions throw ERR_ACCESS_DENIED.

read.js

const fs = require("node:fs");
console.log("Own folder:", fs.readFileSync(__filename, "utf8").length > 0 ? "allowed" : "?");
try {
  fs.readFileSync("/etc/hostname");
} catch (err) {
  console.log("Outside folder:", err.code);
}
console.log("Can write?", process.permission.has("fs.write"));
$ node --permission --allow-fs-read=./ read.js
Own folder: allowed
Outside folder: ERR_ACCESS_DENIED
Can write? false

What the Permission Model Does Not Protect

  • It is a seat belt for your own code and packages, not a full sandbox.
  • It does not limit network access in every version, CPU or memory use.
  • Code that is already allowed can still misuse what it is allowed to touch.

Simple lesson: --permission limits what a program may touch, but it is not a full sandbox.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Storing passwords with SHA-256Any hash is safeUse scrypt or pbkdf2 with a saltLeaked hashes are cracked fast
Comparing secrets with ===Comparison is harmlessUse timingSafeEqualAttackers learn from timing
Math.random() for tokensRandom is randomUse randomBytes or randomIntTokens can be guessed
Reusing an IV with AES-GCMThe IV is just a settingA new IV for every messageEncryption can be broken
Secrets in code or GitIt is a private repoUse environment variablesAnyone with the code has the keys
exec with user inputUsers type normal namesUse execFile with argumentsCommand injection
Merging user JSON into objectsJSON is just dataBlock __proto__ and validatePrototype pollution
Using MD5 or SHA-1 for securityOld means trustedUse SHA-256 or betterForged fingerprints

TL;DR

  1. Hash = fingerprint, cannot be undone. SHA-256, not MD5 or SHA-1.
  2. HMAC = hash with a secret key. Proves the sender.
  3. randomBytes, randomInt, randomUUID = secure random. Never Math.random() for secrets.
  4. Passwords = salt + scrypt or pbkdf2 + timingSafeEqual.
  5. AES-256-GCM = encrypt with a key, a new IV each time, and an auth tag.
  6. Path traversal = resolve and check the folder.
  7. Command injection = execFile with an argument list, never exec with input.
  8. Prototype pollution = block __proto__. ReDoS = avoid (a+)+.
  9. --permission = allow only the files and features a program needs.

Check Yourself

Exercises

What to Remember

  • Hash with SHA-256, sign with HMAC, and make secrets with crypto random functions.
  • Store passwords as salt + slow hash, and compare with timingSafeEqual.
  • Encrypt with AES-256-GCM and a fresh IV every time.
  • Keep keys and secrets out of code.
  • Resolve and check paths, use execFile with argument lists, block __proto__.
  • Use --permission to limit what a program may touch.

Next: Production Readiness. You will make your Node.js app ready to run safely for real users.