Hashing

runtime:hashing is digests, checksums, MACs and passwords. crypto.subtle stays the WebCrypto standard; this is the rest — the algorithms it has no name for, hashing that runs incrementally, encoded output, and passwords.

A digest

JavaScript
import { hash } from "runtime:hashing";

hash("sha256", "hello", "hex");        // "2cf24dba5fb0a30e…"
hash("sha256", "hello", "base64url");  // "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ"
hash("sha256", "hello");               // Uint8Array(32)

data is a string (hashed as UTF-8), an ArrayBuffer, or a view. Encoding happens in the host, so "hex" costs one allocation rather than one string per byte.

WebCrypto's spellings work unchanged, which matters when you are moving code:

JavaScript
hash("SHA-256", data, "hex") === hash("sha256", data, "hex");   // true

Input too large to hold

crypto.subtle.digest takes the whole input at once — a 4 GB upload means 4 GB of memory. A Hasher holds a few hundred bytes of state instead.

JavaScript
import { Hasher } from "runtime:hashing";

const h = new Hasher("blake3");
for await (const chunk of file.stream()) h.update(chunk);
const digest = h.digest("hex");

update() chains, and digest() ends the hasher — calling either again throws rather than quietly starting a second hash.

For a stream you already have, that whole block is one call:

JavaScript
import { hashStream } from "runtime:hashing";

const etag = await hashStream("xxhash3", response.body, "hex");

Choosing an algorithm

WantUseWhy
A content hash, integrity, addressingblake332 bytes, and many times SHA-256's throughput.
Interop with something that says SHA-256sha256The one everything speaks.
A cache key, ETag, shard selectorxxhash3An order of magnitude faster. Nobody is attacking a cache key.
A checksum in a frame or recordcrc32c4 bytes. What S3, Parquet and iSCSI checksum with.
An S3 ETag, CRAM-MD5, a legacy protocolmd5Interop only.
Checksums are not digests

xxhash* and crc32* are trivially collidable on purpose. Never use one to compare secrets, deduplicate untrusted uploads, or key a cache an attacker can write to. hmac refuses them for this reason.

Signatures and tokens

JavaScript
import { hmac, timingSafeEqual } from "runtime:hashing";

const expected = hmac("sha256", secret, body, "hex");
if (!timingSafeEqual(request.headers.get("x-signature") ?? "", expected)) {
  return new Response("bad signature", { status: 401 });
}

hmac is synchronous and takes the key directly — no importKey step. When you already hold a CryptoKey or a JWK, use crypto.subtle; it is the same construction.

Never compare a secret with ===

=== on hex strings returns as soon as two characters differ, so the time it takes reveals how much of the prefix was right — one request at a time, until the whole signature is known. timingSafeEqual does not.

Passwords

Argon2id by default. The stored string carries the algorithm, its parameters and the salt, so nothing else has to be kept beside it.

JavaScript
import { password } from "runtime:hashing";

// Signup.
user.hash = await password.hash(input);
// "$argon2id$v=19$m=19456,t=2,p=1$…"

// Login.
if (!(await password.verify(input, user.hash))) return unauthorized();

Raising the cost later

Verification reads the parameters from the stored string, not from today's configuration — so raising a default never invalidates existing hashes. A correct login is the one moment you hold the plaintext, and therefore the only moment an old hash can be replaced:

JavaScript
if (await password.verify(input, user.hash)) {
  if (password.needsRehash(user.hash)) {
    user.hash = await password.hash(input);
    await users.save(user);
  }
}

Existing hashes

bcrypt and scrypt are here so a database you already have keeps working — verify() dispatches on what the stored string says it is, with no configuration:

JavaScript
await password.verify(input, "$2b$12$…");     // bcrypt, from your old app
await password.verify(input, "$scrypt$ln=17,r=8,p=1$…");

New hashes take Argon2id unless you ask otherwise:

JavaScript
await password.hash(input, { algorithm: "bcrypt", cost: 12 });
These block the calling isolate

Password hashing is slow by design — that is the entire mechanism — and it is slow on the thread that calls it. Put a queue in front of a public login endpoint rather than letting a hundred requests hash concurrently.

bcrypt refuses past 71 bytes

bcrypt hashes at most 72 bytes including its own NUL, and most implementations silently ignore the rest — quietly making two different passwords the same password. Hashing a longer one throws here. Verification still truncates, since a stored hash may have been written by one that did.

Two-factor sign-in

totp makes the six-digit codes that authenticator apps show (RFC 6238). Give each user a secret when they turn on two-factor sign-in, and show it as a QR code of its otpauth:// URI:

JavaScript
import { totp } from "runtime:hashing";

const secret = totp.generateSecret(); // base32, 160 bits
const uri = totp.uri({ secret, issuer: "Acme", account: user.email });
// Render `uri` as a QR code; store `secret` with the user.

Ask for one code before switching it on, so a user who scanned it wrong is not locked out. At sign-in, check the code and store the step it matched:

JavaScript
const step = totp.verify(code, user.totpSecret, { after: user.totpLastStep });
if (step === null) return unauthorized();
user.totpLastStep = step; // the same code is refused next time
await users.save(user);

verify accepts a code from one step (30 seconds) either side of now, for clocks that differ slightly. Passing the last accepted step as after refuses that code and older ones, so a code seen over someone's shoulder cannot be used again. It returns null, and never throws, for a wrong or malformed code.

Store the secret encrypted, since anyone who has it can make codes. Keep the defaults (SHA-1, 6 digits, 30 seconds): Google Authenticator ignores any other setting in the URI and would show codes that never match.

Backup codes

Give the user recovery codes for when the phone is lost. Show them once, and store each one as a password:

JavaScript
const codes = totp.backupCodes(); // 10 codes like "4kq7m-x2c9d"
user.backupCodes = await Promise.all(codes.map((c) => password.hash(c)));

At sign-in, accept a code that matches one stored hash, and delete that hash so the code works once.

Deriving a key

A password hash is for checking a password, not for encrypting with. To turn a secret into an encryption key, use the key derivation functions in crypto.subtle: HKDF and PBKDF2.

From a high-entropy secret, such as a random master key in an environment variable, use HKDF. A different info string gives an independent key for each purpose:

JavaScript
const master = await crypto.subtle.importKey("raw", masterBytes, "HKDF", false, ["deriveKey"]);
const key = await crypto.subtle.deriveKey(
  { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("exports v1") },
  master,
  { name: "AES-GCM", length: 256 },
  false,
  ["encrypt", "decrypt"],
);

From a password, use PBKDF2 with a random salt stored beside the ciphertext, and the iteration count OWASP recommends for SHA-256:

JavaScript
const salt = crypto.getRandomValues(new Uint8Array(16));
const pw = await crypto.subtle.importKey("raw", new TextEncoder().encode(input), "PBKDF2", false, ["deriveKey"]);
const key = await crypto.subtle.deriveKey(
  { name: "PBKDF2", hash: "SHA-256", salt, iterations: 600_000 },
  pw,
  { name: "AES-GCM", length: 256 },
  false,
  ["encrypt", "decrypt"],
);

Capability

Hashing needs nothing

Hashing reads nothing and reaches nothing, so every function here works under nothing granted. The one exception is password.hash(), which draws a random salt and so needs Entropy — password.verify() needs nothing, because the salt is inside the stored string. A service that only checks passwords is granted nothing at all.

How this compares

esrun
runtime:hashing
Node
node:crypto
BunDenoWebCrypto
(everywhere)
One-shot digesthash()createHash().digest()Bun.CryptoHashernode:cryptosubtle.digest
Synchronous async only
Hex / base64 output built in encode it yourself
Incremental Hasher
Hash a stream in one call hashStream pipe it pipe it
SHA-3
BLAKE3 BLAKE2 only @std/crypto
Non-cryptographic hash xxHash, CRC-32 Bun.hash
Argon2id npm npm
bcrypt npm npm
needsRehash
TOTP (two-factor codes) totp npm npm npm
Constant-time compare via node:crypto
Needs a capability — except the saltambientambientambient

Legend: Supported · Partial / via another module · Not supported


Why not just crypto.subtle. It is the WebCrypto standard and it is complete for what it covers — keys, signatures, ciphers, KDFs. What it does not cover is a server hashing a 4 GB upload, wanting hex, reaching for a cache key, or storing a password. Those four gaps are what sent everyone to node:crypto.

Why the checksums are in the same module. Because the question is the same question — "what is the hash of this" — and a second import path would not have stopped anyone choosing wrongly. The place it actually matters is hmac, which refuses them.

Why the salt comes from JavaScript. A password hash needs randomness, and randomness here is the Entropy provider's to give. Rather than let a host op help itself, the module calls crypto.getRandomValues — so hashing a password needs Entropy because it genuinely needs randomness, and verifying one needs nothing at all.

See the API reference for every option, and the benchmarks for what the encoded and incremental paths cost.

Last updated on
Edit this page