Skip to main content

Guide

Node.js Crypto vs @noble/hashes vs crypto-js 2026

Compare Node.js WebCrypto API, @noble/hashes, and crypto-js for cryptographic operations in JavaScript. Hashing, HMAC, encryption, browser compatibility.

·PkgPulse Team·
0

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

PackageWeekly DownloadsBrowserAsyncAudited
crypto (built-in)N/APartial✅ 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/hashes is a direct, better replacement with the same synchronous API

Feature Comparison

FeatureWebCrypto (native)@noble/hashescrypto-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 size0KB (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)

Timing-Safe Comparison and Preventing Timing Attacks

A subtle but critical security concern in cryptographic operations is timing attacks — attacks where an adversary measures how long comparison operations take to determine secret values. Standard string comparison in JavaScript uses short-circuit evaluation: "abc" === "abd" returns false after comparing only the third character. An attacker who can measure response time precisely can binary-search through possible values, comparing expected and actual signatures character by character.

The WebCrypto API prevents this automatically. crypto.subtle.verify() for HMAC and digital signature verification uses constant-time comparison internally — it always takes the same amount of time regardless of where the signatures differ. This means you should always prefer crypto.subtle.verify(algorithm, key, signature, data) over computing a hash and comparing the result with ===. The @noble/hashes library provides utils.equalBytes(a, b) for constant-time comparison of Uint8Array values, which is the equivalent safe comparison for hash digests. Never use bytesToHex(hash1) === bytesToHex(hash2) in production — the string comparison leaks timing information.

This distinction matters most for webhook signature verification and API authentication tokens. A production webhook endpoint should verify GitHub, Stripe, or Shopify signatures using constant-time comparison only. A naive implementation that stringifies the HMAC digest and compares with === introduces a theoretical timing vulnerability — one that is difficult to exploit in practice over HTTP due to network jitter, but that security auditors will flag and that becomes more serious in co-located services or internal APIs with lower network latency.

Runtime Compatibility Across Environments

One of the most significant shifts in JavaScript cryptography since 2023 is the convergence of runtime environments on the WebCrypto standard. Node.js 19 moved crypto.subtle from behind an --experimental flag to globalThis.crypto, Deno has supported WebCrypto since 1.0, Bun exposes a compatible API, and Cloudflare Workers implement a strict subset of the standard. This means code written with globalThis.crypto.subtle works across all these runtimes without modification or polyfills — a meaningful improvement over the era when you needed node:crypto for Node.js and a polyfill for everything else.

The catch is that the WebCrypto spec is designed for maximum security, which means the API is deliberately verbose and asynchronous. Operations like importKey require specifying key usage flags (["sign"], ["verify"], ["encrypt"], ["decrypt"]) at key creation time, which prevents key misuse at the API level. For developers building webhook verification, this design is valuable — a key imported for HMAC verification cannot be accidentally used for signing. But for developers who just need to hash a string, the five-step SHA-256 operation (encode → digest → convert to Uint8Array → map to hex → join) feels over-engineered.

@noble/hashes hits the right point on the convenience/correctness curve for most application-level cryptography. Its synchronous API makes it trivial to use in test code, CLI scripts, and configuration loading — contexts where async/await is awkward or unavailable. The bytesToHex and utf8ToBytes utilities handle the most common encoding conversions, eliminating the boilerplate that makes raw WebCrypto tedious. The security audit by Cure53 (the same firm that audits Bitcoin Core cryptographic libraries) gives the noble library family credibility that crypto-js never achieved. For teams that previously reached for crypto-js out of convenience, @noble/hashes is a direct replacement with a similar sync API and a much stronger security posture.

Migrating Away from crypto-js in Existing Codebases

Many existing Node.js codebases use crypto-js for historical reasons — it was the dominant browser-compatible crypto library from 2012 through 2020, and countless projects pulled it in as a transitive dependency. Migrating away is straightforward for the most common operations. SHA-256 hashing with crypto-js (CryptoJS.SHA256(message).toString()) maps directly to @noble/hashes: bytesToHex(sha256(utf8ToBytes(message))). HMAC signing (CryptoJS.HmacSHA256(message, key).toString()) maps to hmac(sha256, utf8ToBytes(key), utf8ToBytes(message)) followed by bytesToHex. The output formats differ slightly — crypto-js returns uppercase hex by default while noble returns lowercase — so ensure downstream comparisons are case-insensitive or normalize both sides.

AES encryption migration from crypto-js to WebCrypto is the most involved change because the APIs are architecturally different. crypto-js's CryptoJS.AES.encrypt(plaintext, password) derives a key using OpenSSL's MD5-based key derivation (EVP_BytesToKey), which is weak by modern standards. When migrating, you should not aim for crypto-compatibility with crypto-js's output — treat the migration as an opportunity to re-encrypt stored data with proper PBKDF2 key derivation and AES-GCM authenticated encryption. Store a migration flag in your data to distinguish between old crypto-js-encrypted records and new WebCrypto-encrypted ones during the transition period.

Choosing an Algorithm: SHA, BLAKE3, and Scrypt

Not all hashing needs are alike, and the algorithm choice matters as much as the library choice. For general content fingerprinting — cache keys, ETag values, file integrity — SHA-256 is the safe default because it's fast, universal, and produces output that all systems recognize. For performance-critical hashing at high throughput (processing millions of database rows or large files), BLAKE3 is significantly faster than SHA-256 and available via @noble/hashes/blake3. BLAKE3 is not yet part of the WebCrypto spec, so it requires a library regardless of your runtime.

For password storage, neither SHA-256 nor BLAKE3 is appropriate — they are designed to be fast, which is the opposite of what you want for passwords. @noble/hashes provides scryptAsync, a memory-hard function that is appropriate for password hashing. However, most Node.js applications should use argon2 (via the argon2 npm package) or bcrypt instead — these are purpose-built for passwords with better defaults and wider operational familiarity. Reserve @noble/hashes for everything except password storage: checksums, HMACs, key derivation for non-password secrets, and blockchain/signing operations where the noble ecosystem's @noble/curves complements the hash functions.

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.

Compare security and cryptography packages on PkgPulse →

See also: AVA vs Jest and bcrypt vs argon2 vs scrypt: Password Hashing in 2026, jose vs jsonwebtoken vs fast-jwt: JWT for Node.js 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.