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
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:
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.
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:
import { hashStream } from "runtime:hashing"; const etag = await hashStream("xxhash3", response.body, "hex");
Choosing an algorithm
| Want | Use | Why |
|---|---|---|
| A content hash, integrity, addressing | blake3 | 32 bytes, and many times SHA-256's throughput. |
| Interop with something that says SHA-256 | sha256 | The one everything speaks. |
| A cache key, ETag, shard selector | xxhash3 | An order of magnitude faster. Nobody is attacking a cache key. |
| A checksum in a frame or record | crc32c | 4 bytes. What S3, Parquet and iSCSI checksum with. |
| An S3 ETag, CRAM-MD5, a legacy protocol | md5 | Interop only. |
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
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.
=== 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.
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:
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:
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:
await password.hash(input, { algorithm: "bcrypt", cost: 12 });
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 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:
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:
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:
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:
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:
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 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
esrunruntime:hashing | Nodenode:crypto | Bun | Deno | WebCrypto (everywhere) | |
|---|---|---|---|---|---|
| One-shot digest | hash() | createHash().digest() | Bun.CryptoHasher | node:crypto | subtle.digest |
| Synchronous | |||||
| Hex / base64 output | |||||
| Incremental | Hasher | ||||
| Hash a stream in one call | hashStream | ||||
| SHA-3 | |||||
| BLAKE3 | @std/crypto | ||||
| Non-cryptographic hash | Bun.hash | ||||
| Argon2id | |||||
| bcrypt | |||||
needsRehash | |||||
| TOTP (two-factor codes) | totp | ||||
| Constant-time compare | node:crypto | ||||
| Needs a capability | ambient | ambient | ambient |
Legend:
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 tonode: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.