Hashing and verifying passwords
Passwords must never be stored in plain text, and a plain digest like SHA-256 is too fast to resist brute force. A password hash needs a salt and a deliberately slow derivation. This example uses PBKDF2 from the built-in Web Crypto API.
The OWASP recommended cost for PBKDF2 with SHA-256.
const ITERATIONS = 600_000;Hash a password with a random salt. Store the salt next to the hash; it is not a secret, it only makes each hash unique.
async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await deriveBits(password, salt);
return `${salt.toBase64()}:${hash.toBase64()}`;
}Verify by re-deriving with the stored salt and comparing the results in constant time.
async function verifyPassword(
password: string,
stored: string
): Promise<boolean> {
const [salt, expected] = stored
.split(":")
.map((part) => Uint8Array.fromBase64(part));
const actual = await deriveBits(password, salt);
return timingSafeEqual(actual, expected);
}The key derivation itself: PBKDF2 with SHA-256, producing 256 bits.
async function deriveBits(
password: string,
salt: Uint8Array<ArrayBuffer>
): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{ hash: "SHA-256", iterations: ITERATIONS, name: "PBKDF2", salt },
key,
256
);
return new Uint8Array(bits);
}Compare hashes in constant time so the comparison itself does not leak how many leading bytes matched.
import { timingSafeEqual } from "jsr:@ende/crypto/timing-safe-equal";Putting it together: hash at signup, verify at login.
const stored = await hashPassword("hunter2");
console.log(stored.length > 60); // true
console.log(await verifyPassword("hunter2", stored)); // true
console.log(await verifyPassword("wrong", stored)); // falsePBKDF2 ships with the runtime and is OWASP approved. If your threat model calls for a memory-hard algorithm, use an argon2 library instead, the API shape stays the same: hash at signup, verify at login.
Run this example locally using the Ende CLI:
ende run https://docs.ende.sh/examples/scripts/hash_password.ts