Cryptography in Node.js: hashing, encryption and trust from scratch
How many times have you used JWT, HTTPS, or verified a binary’s hash without really understanding what was happening under the hood? Cryptography is everywhere in modern development — and understanding its mechanics changes the way you design systems.
Two researchers, a hostile network, and no guarantee the message arrives unread or untampered. This is the perfect starting point to understand cryptography in Node.js: not as mathematical black magic, but as a set of pragmatic tools you already use every day without knowing it.
At Orbitant, we wanted to go deeper into cryptography, so we asked Ulises Gascón — Node.js Core Collaborator, runtime releaser, and active contributor to JavaScript ecosystem security — to lead a Knowledge Sharing session, one of our weekly internal training sessions. Using Alice and Bob’s story, Ulises built a comprehensive session, now captured in this article with real code from Node.js’s crypto library and examples you’ll recognize from your daily work.
What cryptography solves (and what it doesn’t)
Before writing a single line of code, it’s worth understanding the problem we’re solving. Picture Alice and Bob, two researchers stationed at different bases in Antarctica. They need to coordinate daily, but their only communication channel is a network of intermediate nodes they don’t control and don’t trust. Any message they send could be read, modified, or impersonated by a third party. When Alice wants to send a message to Bob through those hostile nodes, she has four goals:
- Confidentiality: only Bob can read the message.
- Integrity: the message arrives exactly as it was sent, with no modifications.
- Non-repudiation: Alice cannot deny having sent the message.
- Authentication: Bob can verify the message comes from Alice, not a third party impersonating her.
Each of these goals corresponds to a real type of attack. Without confidentiality, any intermediate node can read your data in plaintext — this is why you should never send credentials over HTTP. Without integrity, a hostile node can modify a packet in transit without the receiver detecting it: imagine the message “temperature stable, continue experiment” arriving as “critical temperature, evacuate base.” Without non-repudiation, Alice could send an order and later deny it, since there’s no way to cryptographically prove who the author was. And without authentication, you have no way of knowing whether the message truly comes from Alice or someone impersonating her.
If you solve these four points, it doesn’t matter that the message passes through completely hostile nodes. Even if you published it in a newspaper, only Bob could understand it — and he could prove that Alice wrote it.
This is exactly what we do, without realizing it, every time we see a green padlock in the browser, install an npm dependency, or authenticate with a JWT. Cryptography doesn’t eliminate the need to trust: it shifts trust from intermediaries to mathematics.

Hashing: the foundation of integrity in Node.js cryptography
A hash function takes any input — whether 3 characters or 8 gigabytes of data — and always produces a fixed-length output. It’s deterministic: the same input always produces the same output. It’s irreversible: you can’t recover the original from the hash. And it’s sensitive to minimal changes: altering a single character produces a completely different hash.
Node.js’s crypto library exposes these functions directly:
const { createHash } = require('crypto');
const generateHash = (txt, algorithm) => {
return createHash(algorithm).update(txt).digest('hex');
};
const generateChecksum = (txt) => ({
md5: generateHash(txt, 'md5'),
sha1: generateHash(txt, 'sha1'),
sha256: generateHash(txt, 'sha256'),
});
generateChecksum('Just a simple Text');
/*
{
md5: '42fc79f713ed18f0aff045c8a86eb14c',
sha1: '4ca3e243ede34c908fd3a9ec971d4b4eb3ff4da5',
sha256: '838af5f849c8a7b5eb8c7021feb47c53836bda5f183228194e3373b5f245c7a5'
}
*/
What’s this useful for in practice? Verifying file integrity (checksums), generating unique identifiers, and avoiding duplicates in distributed systems. But beware: these fast hash functions (MD5, SHA-1, SHA-256) are not suitable for storing passwords. For that, you need key derivation functions that are slow by design, like scrypt, bcrypt, or argon2 — as you’ll see next.
The rainbow table problem
Here’s the first scare: if you store passwords with a fast hash and no salt — whether MD5 or SHA-256 — you’re vulnerable to rainbow table attacks. The mechanics are straightforward: someone generates a massive dictionary of common passwords (1234, password, qwerty) along with their precomputed hashes. When they access your database, they just look for matches in that dictionary.
The solution is salting: a random value added to the password before hashing. This guarantees that two users with the same password have different hashes in the database, making dictionary attacks computationally infeasible.
const { scryptSync, randomBytes, timingSafeEqual } = require('crypto');
const password = 'my-password-123';
// Same password, different salt → different hash every time
const salt1 = randomBytes(16);
const salt2 = randomBytes(16);
const hash1 = scryptSync(password, salt1, 64);
const hash2 = scryptSync(password, salt2, 64);
console.log('Hash 1:', hash1.toString('hex'));
// Hash 1: 5f4dcc3b5aa765d61d8327deb882cf99...
console.log('Hash 2:', hash2.toString('hex'));
// Hash 2: 4812d0e8b9704c741907fe1a11820a2e...
console.log('Equal?', hash1.equals(hash2));
// Equal? false
// But both verify correctly with their respective salt
const check1 = scryptSync(password, salt1, 64);
const check2 = scryptSync(password, salt2, 64);
console.log('Hash 1 valid?', timingSafeEqual(hash1, check1)); // true
console.log('Hash 2 valid?', timingSafeEqual(hash2, check2)); // true
Notice the snippet uses timingSafeEqual instead of === to compare hashes. The reason is that === exits the loop as soon as it finds a mismatched character, which opens the door to a timing attack: by measuring how long the server takes to respond, an attacker can guess characters one by one. timingSafeEqual always takes the same amount of time, regardless of how many characters match.
For guidance on which hashing algorithms to use in each context — and which to avoid — the OWASP Password Storage Cheat Sheet is the most comprehensive and up-to-date reference.
HMAC and JWT: message authentication
Hashing solves integrity, but not authentication. You need a way to say: “I wrote this message, and you can prove it.” That’s where HMAC (Hash-based Message Authentication Code) comes in.
The idea is simple: instead of using a random salt, you use a shared secret key. The result is a hash that only someone who knows the key can verify.
const { createHmac, timingSafeEqual } = require('crypto');
const secret = 'shared-secret-key';
function hmac(message, key) {
return createHmac('sha256', key).update(message).digest('hex');
}
// Alice sends message + MAC
const msg = 'Supplies to Delta point';
const mac = hmac(msg, secret);
const packet = { msg, mac };
console.log('Sent packet:', JSON.stringify(packet, null, 2));
// {
// "msg": "Supplies to Delta point", ← plaintext, anyone can read it
// "mac": "a7f3b2c1d9e8..." ← generated with secret
// }
// Bob verifies
const bobMac = hmac(packet.msg, secret);
const valid = timingSafeEqual(Buffer.from(packet.mac), Buffer.from(bobMac));
console.log('Authentic?', valid); // true
The message travels in plaintext — anyone can read it. But without the secret key, no one can generate a valid HMAC. This is what guarantees integrity and authentication, not confidentiality.
What happens if an attacker intercepts the message, modifies it, and tries to forge the HMAC with a made-up key?
// An attacker reads the message, modifies it, and tries to forge
const fakePacket = {
msg: 'Supplies to Gamma point',
mac: hmac('Supplies to Gamma point', 'made-up-key'),
};
const fakeValid = timingSafeEqual(
Buffer.from(fakePacket.mac),
Buffer.from(bobMac)
);
console.log('Fake authentic?', fakeValid); // false
Sound familiar? This is exactly the mechanism behind JSON Web Tokens (jwt.io is a great visual reference for inspecting them). A JWT has three parts:
- Header: the algorithm used (e.g.,
HS256= HMAC + SHA-256). - Payload: the user’s data (claims, roles, expiration).
- Signature: the HMAC of the header + payload computed with your secret key.
A JWT is not an encrypted message. Its content is public and anyone can decode the payload from Base64. What it guarantees is that the content hasn’t been modified and that it was issued by whoever holds the secret key. Never include sensitive information in a JWT’s payload without additional encryption.
If someone changes admin: false to admin: true in the payload, the signature no longer matches the one the server generated. The token becomes invalid. The server, which holds the secret key, detects the tampering by recalculating the signature and comparing it with the one received.
RFC 7519 defines precisely how JWTs should be structured, when they must expire, and how to rotate them. It’s worth reading before implementing any token-based authentication system.

Understanding this structure is essential for implementing robust authentication flows. As a visual supplement to the above, this quick breakdown of how JWT works precisely details how these three parts interact to validate user identity without compromising system scalability.
Symmetric, asymmetric, and hybrid encryption
With hashing and HMAC you have integrity and authentication, but the message is still readable by anyone who intercepts it. To guarantee confidentiality, you need encryption.
Symmetric: fast, with a known weak spot
Symmetric encryption uses the same key to encrypt and decrypt. It’s extremely fast, ideal for large volumes of data, and Node.js’s crypto library supports it with AES:
const { createCipheriv, createDecipheriv, randomBytes } = require('crypto');
const key = randomBytes(32);
const iv = randomBytes(12);
// Encrypt
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted =
cipher.update('Hello Antarctica', 'utf8', 'hex') + cipher.final('hex');
const tag = cipher.getAuthTag();
console.log(encrypted);
// 6fdf19e6077e165cfd869d385e1964
// Decrypt
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
console.log(decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8'));
// 'Hello Antarctica'
The Initialization Vector (IV) plays the same role as the salt in hashing: it ensures that two identical messages produce different encrypted outputs. This makes pattern analysis harder and prevents vulnerabilities like the one that helped break Enigma in World War II — operators always sent the same weather sequence at the start of each message, giving cryptanalysts an anchor point.
The problem with symmetric encryption is key distribution. If Alice and Bob are at different points in Antarctica and can only communicate through untrusted channels, how do they agree on a key without exposing it to the hostile nodes between them?
Asymmetric: distributed by design
Asymmetric encryption uses two mathematically related keys:
- Public key: you share it with the world. The more people who have it, the better.
- Private key: it never leaves your machine. If you lose it, you lose access.
What you encrypt with someone’s public key can only be decrypted by that person with their private key. And what you sign with your private key can be verified by anyone with your public key — without ever having had prior contact with you.
The trade-off is performance: asymmetric encryption is orders of magnitude slower and has a limit on the data size it can process directly (a few hundred bytes with RSA-2048, depending on the padding used).
const { generateKeyPairSync, publicEncrypt, privateDecrypt } = require('crypto');
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
console.log(publicKey);
// -----BEGIN PUBLIC KEY-----
// MIIBIjANBg(......)2n7sH5XoVh
// -----END PUBLIC KEY-----
// Encrypt with public, decrypt with private
const encrypted = publicEncrypt(publicKey, Buffer.from('Hello Antarctica'));
console.log(encrypted.toString('base64'));
// VFODA0sb1+40(...)+glzZtzMoSV/oZ134w==
console.log(privateDecrypt(privateKey, encrypted).toString());
// 'Hello Antarctica'
Hybrid: what everyone uses in production
The standard solution combines both. It’s what PGP does, and what HTTPS does under the hood in every TLS session:
- Generate a single-use symmetric key for this message.
- Encrypt the message with that symmetric key (fast, no size limit).
- Encrypt the symmetric key with the recipient’s public key (secure, small).
- Send both together.
The recipient uses their private key to decrypt the symmetric key, then uses that key to decrypt the message. Result: the speed of symmetric encryption with the key distribution security of asymmetric.
Cryptography in your daily workflow: commits, releases, and chains of trust
All of this isn’t textbook theory. It’s in your daily workflow, even if you don’t see it.
When Ulises signs a Node.js release, he computes the SHA of each binary and signs that digest with his private PGP key. You can see a real example in Node.js 22’s SHASUMS256.txt.asc file. If you download that binary and verify the signature with his public GPG key, you can independently confirm two things — regardless of the distribution server:
- The binary hasn’t been modified since it left his machine.
- It was the author who generated it, not an intermediary who compromised the server.
You can verify it yourself with a few commands:
# Import the releaser's public key (Ulises Gascón)
gpg --keyserver hkps://keys.openpgp.org --recv-keys A363A499291CBBC940DD62E41F10027AF002F8B0
# Download the signed checksums
curl -O https://nodejs.org/dist/v22.15.0/SHASUMS256.txt.asc
# Verify the signature
gpg --verify SHASUMS256.txt.asc
# Download the binary
curl -O https://nodejs.org/dist/v22.15.0/node-v22.15.0-linux-x64.tar.xz
# Verify binary integrity against the checksum
grep node-v22.15.0-linux-x64.tar.xz SHASUMS256.txt.asc | sha256sum --check
The Node.js binary verification page documents the full process with each releaser’s keys.
The same applies to GitHub commits. Git’s history is a chain of hashes, but that doesn’t guarantee the person listed as author is the one who actually made the commit. Signing your commits with GPG adds that verification layer:
git config --global commit.gpgsign true
git config --global user.signingkey YOUR_KEY_ID
With this, GitHub shows the “Verified” badge on your commits and anyone can cryptographically verify that the commit came from your machine.

This is especially relevant in open source projects with many contributors, or in environments where the commit history has legal or audit value. The GnuPG documentation covers key generation and management in detail, and this practical guide from backend.cafe walks through it step by step.
And the HTTPS chain of trust works the same way: your browser trusts certain root certificates, which vouch for intermediate certificates, which in turn vouch for each domain’s certificate. If any link fails — because your system hasn’t been updated in years and the root certificate has expired — the chain breaks. The famous green padlock is just the visible tip of all this machinery.
The question of whether quantum computing will break all of this is legitimate. The answer is more nuanced than it might seem: eventually, for some algorithms, yes. Algorithms like RSA or the elliptic curves we use today in asymmetric encryption are not resistant to quantum attacks in their current form. Post-quantum algorithms already exist, and there are actors collecting encrypted traffic right now to decrypt it once they have sufficient quantum capability. This isn’t science fiction — it’s engineering with a long planning horizon.
What won’t change is the conceptual mechanics. Hashing, symmetric, asymmetric, hybrid — they’ve been the same building blocks for decades. Implementations evolve; algorithms get updated; security parameters are adjusted. But truly understanding the mechanics is what lets you choose the right algorithm for each context, spot when someone has taken a dangerous shortcut, and avoid the trap of thinking a JWT is an encrypted message just because the URL has a green padlock.
FAQ
Can I still use MD5 for anything in 2026?
Yes, but not for passwords. MD5 is fast and has known collisions, making it unsuitable for credential storage. For verifying the integrity of a large file in a low-criticality context — comparing two internal backups, for example — it’s still useful for its speed. For passwords, use bcrypt, scrypt, or argon2.
Is a JWT secure for user data?
It depends on what you mean by “secure.” A JWT’s payload is public — anyone can decode it from Base64, even though they can’t modify it without invalidating the signature. Never put passwords, third-party access tokens, or sensitive personal information in the payload without additional encryption. What a JWT guarantees is integrity and authenticity, not confidentiality.
Is it worth signing Git commits?
In open source projects with many contributors, regulated environments, or critical infrastructure — yes. In internal projects with a small team and controlled repository access, the benefit is smaller — but the cost of setting it up is minimal and the habit is valuable.