Node.js Crypto vs @noble/hashes vs crypto-js: Cryptography in JavaScript (2026)
TL;DR
Node.js WebCrypto API (globalThis.crypto) is the best choice for server-side cryptography — it's native, fast, no dependencies, and available in Node.js 19+, Deno, Bun, and Cloudflare Workers. @noble/hashes (from Paul Miller's noble crypto libraries) is the go-to for pure JavaScript hashing when you need browser + Node.js compatibility or don't want to deal with async WebCrypto. crypto-js is legacy — functional but unmaintained and has had security issues. Don't use it in new projects.
Key Takeaways
- Node.js WebCrypto: Zero downloads (built-in) — native, async, FIPS-compatible, available everywhere
- @noble/hashes: ~8M weekly downloads — audited, zero deps, browser + Node.js, synchronous
- crypto-js: ~12M weekly downloads — legacy installs, largely unmaintained since 2023
- WebCrypto is async — every operation returns a Promise
- @noble/hashes is synchronous — easier to use in non-async contexts
- Never use crypto-js for new code in 2026 — use WebCrypto or @noble/hashes
- For RSA, ECDSA, ECDH key operations: WebCrypto is the only native option
Download Trends
| Package | Weekly Downloads | Browser | Async | Audited |
|---|---|---|---|---|
crypto (built-in) | N/A | Partial | ✅ | ✅ NIST |
@noble/hashes | ~8M | ✅ | ❌ Sync | ✅ 2024 |
crypto-js | ~12M | ✅ | ❌ Sync | ⚠️ Issues |
Node.js WebCrypto API
The globalThis.crypto API is the standard — available in Node.js 19+, browsers, Deno, Bun, and Cloudflare Workers:
Hashing
// SHA-256 hash:
async function sha256(data: string): Promise<string> {
const encoder = new TextEncoder()
const bytes = encoder.encode(data)
const hashBuffer = await crypto.subtle.digest("SHA-256", bytes)
// Convert ArrayBuffer to hex string:
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
const hash = await sha256("pkgpulse.com")
// "a3b4c5d6..."
// SHA-1 (only for compatibility — don't use for security):
const sha1Hash = await crypto.subtle.digest("SHA-1", encoder.encode("data"))
// SHA-512:
const sha512Hash = await crypto.subtle.digest("SHA-512", encoder.encode("data"))
HMAC (Message Authentication Code)
// Create HMAC-SHA256 for webhook signature verification:
async function createHmac(secret: string, message: string): Promise<string> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false, // Not extractable
["sign"]
)
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(message)
)
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
// Verify HMAC:
async function verifyHmac(secret: string, message: string, expected: string): Promise<boolean> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
)
const signatureBytes = Uint8Array.from(
expected.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
)
return crypto.subtle.verify("HMAC", key, signatureBytes, encoder.encode(message))
}
// Stripe webhook verification pattern:
async function verifyStripeSignature(
payload: string,
sigHeader: string,
webhookSecret: string
): Promise<boolean> {
const [, timestamp, , signature] = sigHeader.split(",").flatMap(p => p.split("="))
const signedPayload = `${timestamp}.${payload}`
const expected = await createHmac(webhookSecret, signedPayload)
return expected === signature
}
AES Encryption
// AES-GCM symmetric encryption (authenticated — detects tampering):
async function encrypt(plaintext: string, password: string): Promise<string> {
const encoder = new TextEncoder()
// Derive key from password:
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveKey"]
)
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"]
)
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoder.encode(plaintext)
)
// Combine salt + iv + ciphertext for storage:
const result = new Uint8Array(salt.length + iv.length + ciphertext.byteLength)
result.set(salt, 0)
result.set(iv, salt.length)
result.set(new Uint8Array(ciphertext), salt.length + iv.length)
return btoa(String.fromCharCode(...result))
}
async function decrypt(encrypted: string, password: string): Promise<string> {
const encoder = new TextEncoder()
const data = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0))
const salt = data.slice(0, 16)
const iv = data.slice(16, 28)
const ciphertext = data.slice(28)
const keyMaterial = await crypto.subtle.importKey(
"raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]
)
const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
keyMaterial,
{ name: "AES-GCM", length: 256 },
false, ["decrypt"]
)
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext)
return new TextDecoder().decode(plaintext)
}
Random Bytes
// Cryptographically secure random values:
const randomBytes = crypto.getRandomValues(new Uint8Array(32))
// Generate a random token (URL-safe base64):
function generateToken(bytes = 32): string {
const array = crypto.getRandomValues(new Uint8Array(bytes))
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "")
}
const sessionToken = generateToken() // Cryptographically random
@noble/hashes
@noble/hashes by Paul Miller — the most trusted pure-JavaScript crypto library in 2026. Security-audited, zero dependencies, synchronous.
import { sha256, sha512, sha1 } from "@noble/hashes/sha2"
import { sha3_256 } from "@noble/hashes/sha3"
import { blake3 } from "@noble/hashes/blake3"
import { hmac } from "@noble/hashes/hmac"
import { pbkdf2, pbkdf2Async } from "@noble/hashes/pbkdf2"
import { scrypt, scryptAsync } from "@noble/hashes/scrypt"
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils"
// SHA-256 — synchronous! Much simpler than WebCrypto:
const hashBytes = sha256("pkgpulse.com") // Uint8Array
const hashHex = bytesToHex(sha256("pkgpulse.com")) // hex string
// SHA-512:
const hash512 = bytesToHex(sha512(utf8ToBytes("data")))
// SHA3-256:
const hash3 = bytesToHex(sha3_256("data"))
// BLAKE3 (fastest):
const blakeHash = bytesToHex(blake3("data"))
// HMAC-SHA256:
const key = utf8ToBytes("my-secret-key")
const message = utf8ToBytes("message-to-sign")
const mac = hmac(sha256, key, message)
const macHex = bytesToHex(mac)
// PBKDF2 key derivation:
const derivedKey = pbkdf2(sha256, "password", "salt", {
c: 100000, // 100K iterations
dkLen: 32, // 32 byte output
})
// Scrypt (memory-hard, better than PBKDF2 for password hashing):
const scryptKey = await scryptAsync("password", "salt", {
N: 2 ** 16, // CPU/memory cost
r: 8,
p: 1,
dkLen: 32,
})
@noble/hashes in browser — works everywhere:
// Same code, browser + Node.js + Deno + Bun + Cloudflare Workers:
import { sha256 } from "@noble/hashes/sha2"
import { bytesToHex } from "@noble/hashes/utils"
// No async, no API differences, no polyfills needed:
function hashPassword(password: string, salt: string): string {
return bytesToHex(sha256(`${password}:${salt}`))
}
Noble library family:
// @noble/hashes — hashing and MACs
import { sha256, sha512 } from "@noble/hashes/sha2"
// @noble/ciphers — symmetric encryption
import { chacha20poly1305 } from "@noble/ciphers/chacha"
import { gcm } from "@noble/ciphers/aes"
// @noble/curves — elliptic curves (secp256k1, ed25519, etc.)
import { secp256k1 } from "@noble/curves/secp256k1"
import { ed25519 } from "@noble/curves/ed25519"
// For Ethereum/Bitcoin wallet operations, secp256k1 is the standard
const privateKey = secp256k1.utils.randomPrivateKey()
const publicKey = secp256k1.getPublicKey(privateKey)
const signature = secp256k1.sign(messageHash, privateKey)
crypto-js (Legacy — Avoid)
crypto-js is widely downloaded due to historical usage, but development has slowed significantly since 2023.
// This is the old pattern — included for reference only:
import CryptoJS from "crypto-js"
// SHA-256 (works but use @noble/hashes instead):
const hash = CryptoJS.SHA256("hello").toString()
// AES encryption (use WebCrypto instead):
const encrypted = CryptoJS.AES.encrypt("plaintext", "secret-key").toString()
const decrypted = CryptoJS.AES.decrypt(encrypted, "secret-key").toString(CryptoJS.enc.Utf8)
Why not crypto-js in 2026:
- Last significant update: 2022. No active maintainer.
- No TypeScript types included natively
- Security audit found issues in 2022 that were only partially addressed
- Larger bundle size than @noble/hashes (CryptoJS: ~71KB, noble: ~6KB for sha256)
- No support for modern algorithms (BLAKE3, CHACHA20-POLY1305, Ed25519)
@noble/hashesis a direct, better replacement with the same synchronous API
Feature Comparison
| Feature | WebCrypto (native) | @noble/hashes | crypto-js |
|---|---|---|---|
| SHA-256/512 | ✅ async | ✅ sync | ✅ sync |
| SHA3 | ✅ | ✅ | ⚠️ Partial |
| BLAKE3 | ❌ | ✅ | ❌ |
| HMAC | ✅ | ✅ | ✅ |
| PBKDF2 | ✅ | ✅ | ✅ |
| AES encryption | ✅ GCM | ✅ (noble/ciphers) | ✅ CBC/CTR |
| RSA/ECC keys | ✅ | ✅ (noble/curves) | ✅ Limited |
| Browser support | ✅ Modern browsers | ✅ | ✅ |
| Edge runtimes | ✅ | ✅ | ⚠️ |
| Synchronous | ❌ Async only | ✅ | ✅ |
| Security audit | ✅ NIST standard | ✅ 2024 | ❌ Issues |
| Active maintenance | ✅ | ✅ | ❌ Slow |
| Bundle size | 0KB (native) | ~6-40KB | ~71KB |
When to Use Each
Choose WebCrypto (native) if:
- Server-side Node.js 19+ operations (no browser compatibility needed)
- You need RSA, ECDSA, ECDH key operations (public key crypto)
- FIPS compliance is required
- The async API is acceptable in your context
Choose @noble/hashes if:
- You need synchronous hashing (simpler code, easier testing)
- Browser + Node.js compatibility without polyfills
- Working on blockchain/crypto applications needing Ed25519 or secp256k1
- You want a security-audited library with zero dependencies
Don't use crypto-js if:
- Starting a new project in 2026
- You have any security requirements
- Bundle size matters (it's 10x larger than noble for equivalent functionality)
Methodology
Download data from npm registry (weekly average, February 2026). Security information from published advisories and community audits. Bundle sizes from bundlephobia. Feature comparison based on WebCrypto spec, @noble/hashes 1.x, and crypto-js 4.x.