Functional Weave
Code in Rust

auth.password-hash

Hash a password for storage as pbkdf2_sha256$iterations$salt$hash, verify one in constant time, and spot weak hashes.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 36 tests, run in TypeScript, Python and Rust.hashPassword 11 · verifyPassword 15 · passwordNeedsRehash 10

What it does

Store a password as a string that says how it was hashed, check a login against it, and notice when it was hashed more weakly than today's setting:

stored = hashPassword("correct horse battery staple", salt, 600000)
# pbkdf2_sha256$600000$AAECAwQFBgcICQoLDA0ODw==$<44 base64 characters>
verifyPassword(attempt, stored)            # true / false
passwordNeedsRehash(stored, 600000)        # after a successful login: re-hash and save if true

The functions

A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. hash_password (password: string, salt: int[], iterations: int) -> string
  2. verify_password (password: string, stored: string) -> bool
  3. password_needs_rehash (stored: string, iterations: int) -> bool

Once installed, your code imports each one from the group's module.

hash_password throws on bad input 11 tests

pub fn hash_password(password: &str, salt: &[i64], iterations: i64) -> String
passwordstringexactly as typed; UTF-8 encoded, not trimmed or normalised
saltint[]16 or more random bytes, new for every hash (secrets.token_bytes(16), crypto.getRandomValues)
iterationsint1000 or more; 600000 is the current OWASP figure
returnsstringpbkdf2_sha256$<iterations>$<salt, base64>$<32-byte hash, base64>

For example

  • hash_password(correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000) → pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= a passphrase with a 16-byte salt at 1,000 iterations
  • hash_password(correct horse battery staple, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129,…) → pbkdf2_sha256$1000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$Ex4eaoxau7xpouluxG9E/RjMZTpG1gfchDgUwL5k4nE= the same password with another salt gives a different string
  • hash_password(correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,001) → pbkdf2_sha256$1001$AAECAwQFBgcICQoLDA0ODw==$OG6DHYhanszHUwoT+w6oqeOWctyHfPmQrhnxZi4B+Mc= the iteration count is part of the result
fune!(auth.password-hash@^1);  // then call hash_password(…)
impl/rust/hash_password.rs · 45 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::crypto_pbkdf2_sha256::pbkdf2_sha256;  ← from crypto.pbkdf2-sha256 ^1.0.0 · built alongside by fune
use super::encoding_base64_base64_encode::base64_encode;

/// A password as pbkdf2_sha256$<iterations>$<salt>$<hash>, the salt and the
/// 32-byte PBKDF2-HMAC-SHA256 key in standard base64. The salt is passed in
/// because a capability may not read randomness: generate 16 or more fresh
/// random bytes for every call.
///
/// # Panics
/// Panics on a salt under 16 bytes, a value outside 0-255, or fewer than
/// 1000 iterations.
pub fn hash_password(password: &str, salt: &[i64], iterations: i64) -> String {
    if salt.len() < 16 {
        panic!("salt must be at least 16 bytes, received {}", salt.len());
    }
    if iterations < 1000 {
        panic!("iterations must be a whole number of at least 1000");
    }
    let bytes: Vec<i64> = password.bytes().map(|b| b as i64).collect();
    let derived = pbkdf2_sha256(&bytes, salt, iterations, 32);
    format!("pbkdf2_sha256${}${}${}", iterations, base64_encode(salt), base64_encode(&derived))
}

pub fn fune_vector(args: &[Value]) -> Value {
    let password = match &args[0] {
        Value::Str(s) => s.as_str(),
        _ => panic!("password must be a string"),
    };
    let salt: Vec<i64> = match &args[1] {
        Value::Arr(items) => items
            .iter()
            .map(|item| match item {
                Value::Int(i) => *i,
                _ => panic!("salt must be a list of integers from 0 to 255"),
            })
            .collect(),
        _ => panic!("salt must be a list of integers from 0 to 255"),
    };
    let iterations = match &args[2] {
        Value::Int(i) => *i,
        _ => panic!("iterations must be a whole number of at least 1000"),
    };
    Value::str(&hash_password(password, &salt, iterations))
}

verify_password throws on bad input 15 tests

pub fn verify_password(password: &str, stored: &str) -> bool
passwordstringthe attempt, exactly as typed
storedstringa string hashPassword made; its own iteration count and salt are used
returnsbooltrue when the password matches; a malformed stored string throws

For example

  • verify_password(correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=) → true the right password
  • verify_password(correct horse battery stapler, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=) → false a wrong password
  • verify_password(Correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=) → false case matters
fune!(auth.password-hash@^1);  // then call verify_password(…)
impl/rust/verify_password.rs · 88 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::crypto_constant_time_equal::constant_time_equal;  ← from crypto.constant-time-equal ^1.0.0 · built alongside by fune
use super::crypto_pbkdf2_sha256::pbkdf2_sha256;  ← from crypto.pbkdf2-sha256 ^1.0.0 · built alongside by fune
use super::encoding_base64_base64_decode::base64_decode;

const MALFORMED: &str = "stored password hash is malformed";

fn sextet(b: u8) -> i64 {
    match b {
        b'A'..=b'Z' => (b - b'A') as i64,
        b'a'..=b'z' => (b - b'a' + 26) as i64,
        b'0'..=b'9' => (b - b'0' + 52) as i64,
        b'+' => 62,
        b'/' => 63,
        _ => -1,
    }
}

/// Would base64_decode accept this, and give at least one byte? Checked
/// first so a bad field is reported as a malformed hash, with the words the
/// other languages use, rather than as base64's own panic.
fn is_base64(text: &str) -> bool {
    let b = text.as_bytes();
    if b.is_empty() || b.len() % 4 != 0 {
        return false;
    }
    let padding = b.iter().rev().take_while(|&&c| c == b'=').count();
    if padding > 2 || b.len() - padding == 0 {
        return false;
    }
    let body = &b[..b.len() - padding];
    if body.iter().any(|&c| sextet(c) < 0) {
        return false;
    }
    let last = sextet(body[body.len() - 1]);
    match padding {
        1 => last & 3 == 0,
        2 => last & 15 == 0,
        _ => true,
    }
}

/// (iterations, salt, hash) of a stored pbkdf2_sha256 string. A string this
/// code did not write is a data problem to surface, not a wrong password.
///
/// # Panics
/// Panics when the string is not in that form.
pub fn parse_stored_hash(stored: &str) -> (i64, Vec<i64>, Vec<i64>) {
    let parts: Vec<&str> = stored.split('$').collect();
    if parts[0] != "pbkdf2_sha256" {
        panic!("stored password hash is not a pbkdf2_sha256 hash");
    }
    if parts.len() != 4 {
        panic!("{}", MALFORMED);
    }
    let count = parts[1].as_bytes();
    if count.is_empty() || count.len() > 10 || count[0] == b'0' || !count.iter().all(|c| c.is_ascii_digit()) {
        panic!("{}", MALFORMED);
    }
    let iterations = count.iter().fold(0i64, |n, c| n * 10 + i64::from(c - b'0'));
    if !is_base64(parts[2]) || !is_base64(parts[3]) {
        panic!("{}", MALFORMED);
    }
    (iterations, base64_decode(parts[2]), base64_decode(parts[3]))
}

/// Does the password match the stored hash? Re-derived with the stored salt
/// and iteration count, compared in constant time.
///
/// # Panics
/// Panics when the stored string is not one hash_password makes.
pub fn verify_password(password: &str, stored: &str) -> bool {
    let (iterations, salt, expected) = parse_stored_hash(stored);
    let bytes: Vec<i64> = password.bytes().map(|b| b as i64).collect();
    constant_time_equal(&pbkdf2_sha256(&bytes, &salt, iterations, expected.len() as i64), &expected)
}

pub fn fune_vector(args: &[Value]) -> Value {
    let password = match &args[0] {
        Value::Str(s) => s.as_str(),
        _ => panic!("password must be a string"),
    };
    let stored = match &args[1] {
        Value::Str(s) => s.as_str(),
        _ => panic!("stored password hash must be a string"),
    };
    Value::Bool(verify_password(password, stored))
}

password_needs_rehash throws on bad input 10 tests

pub fn password_needs_rehash(stored: &str, iterations: i64) -> bool
storedstringa string hashPassword made
iterationsintthe count new hashes use today
returnsbooltrue when it has fewer iterations, a salt under 16 bytes or a hash other than 32 bytes

For example

  • password_needs_rehash(pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 600,000) → true 1,000 iterations when new hashes use 600,000
  • password_needs_rehash(pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 1,000) → false the same count as today
  • password_needs_rehash(pbkdf2_sha256$5000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$u/ryxpXZ/oGk/lfmNp6APHuNO8PyBzG5QsqV6CyK6qE=, 1,000) → false more iterations than today is not a reason to re-hash
fune!(auth.password-hash@^1);  // then call password_needs_rehash(…)
impl/rust/password_needs_rehash.rs · 28 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::auth_password_hash_verify_password::parse_stored_hash;  ← verifyPassword, another function of this group · built into the same file, even by a slim install

/// Was this hash made more weakly than hashes are made today? Call it after
/// a successful login and re-hash and save when it answers true.
///
/// # Panics
/// Panics on fewer than 1000 iterations or a stored string not in the
/// pbkdf2_sha256 form.
pub fn password_needs_rehash(stored: &str, iterations: i64) -> bool {
    if iterations < 1000 {
        panic!("iterations must be a whole number of at least 1000");
    }
    let (stored_iterations, salt, expected) = parse_stored_hash(stored);
    stored_iterations < iterations || salt.len() < 16 || expected.len() != 32
}

pub fn fune_vector(args: &[Value]) -> Value {
    let stored = match &args[0] {
        Value::Str(s) => s.as_str(),
        _ => panic!("stored password hash must be a string"),
    };
    let iterations = match &args[1] {
        Value::Int(i) => *i,
        _ => panic!("iterations must be a whole number of at least 1000"),
    };
    Value::Bool(password_needs_rehash(stored, iterations))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add auth.password-hash

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add auth.password-hash --only hashPassword
Download for Rust auth.password-hash-1.0.0-rust.fune · 22,773 bytes sha256 85f3471f8e83e510fc5ce10efc56a66e7b4d60058c75e5394efacecd56b67935

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./auth.password-hash-1.0.0-rust.fune, or fetch it from a terminal with fune pull auth.password-hash@1.0.0:rust.

The whole function, every language, is one file too: auth.password-hash-1.0.0.fune, 30,725 bytes, sha256 78bdf1f46fd5900c50e04361990d04d460d9ed8b68898a699f9ae11135782913. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before auth.password-hash.hashPassword
// fune: before auth.password-hash.verifyPassword
// fune: before auth.password-hash.passwordNeedsRehash

after — your function gets the result and the arguments, and returns the final result.

// fune: after auth.password-hash.hashPassword
// fune: after auth.password-hash.verifyPassword
// fune: after auth.password-hash.passwordNeedsRehash

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace crypto.constant-time-equal in auth.password-hash
// fune: replace crypto.pbkdf2-sha256 in auth.password-hash
// fune: replace encoding.base64 in auth.password-hash
// fune: replace encoding.utf8 in auth.password-hash

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show auth.password-hash --steps.

// fune: step auth.password-hash.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

hashPassword 11 tests

CaseArgumentsExpected
a passphrase with a 16-byte salt at 1,000 iterations correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000 → pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=
the same password with another salt gives a different string correct horse battery staple, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129,… → pbkdf2_sha256$1000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$Ex4eaoxau7xpouluxG9E/RjMZTpG1gfchDgUwL5k4nE=
the iteration count is part of the result correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,001 → pbkdf2_sha256$1001$AAECAwQFBgcICQoLDA0ODw==$OG6DHYhanszHUwoT+w6oqeOWctyHfPmQrhnxZi4B+Mc=
non-ASCII is hashed as UTF-8 pässwörd 🔑, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000 → pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$3NkHSPhtBEJ7V71tycgH0BEWOfIITqysFEFL5iBfsgk=
a trailing space is part of the password, not trimmed correct horse battery staple , 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000 → pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$oUKJ6EHOjzE04WJBnhA+dla9KZq/IWZZswmcGWhbSNI=
the empty password still hashes; refusing it is the password policy's job , 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000 → pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$xbMBsf1hvO1j8AZCojBOxnRRn7182DxLyD2v4XQ/mFU=
a salt of high bytes, whose base64 uses + and / correct horse battery staple, 251, 255, 191, 251, 255, 191, 251, 255, 191, 251, 255, 191, 251, 255, 191, 251, 255, 191, 1,000 → pbkdf2_sha256$1000$+/+/+/+/+/+/+/+/+/+/+/+/$zbEcnYXrJwbG3QbB8OYQTmndOkFcnEtJtHURfCSVTMc=
a 15-byte salt is refused correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 1,000 → error: salt must be at least 16 bytes, received 15
999 iterations is under NIST SP 800-132's minimum correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 999 → error: iterations must be a whole number of at least 1000
a fractional iteration count correct horse battery staple, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000.5 → error: iterations must be a whole number of at least 1000
Show the other 1 test
CaseArgumentsExpected
a salt byte above 255 correct horse battery staple, 256, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1,000 → error: salt must be a list of integers from 0 to 255

verifyPassword 15 tests

CaseArgumentsExpected
the right password correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → true
a wrong password correct horse battery stapler, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → false
case matters Correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → false
a trailing space makes it a different password correct horse battery staple , pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → false
non-ASCII, right pässwörd 🔑, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$3NkHSPhtBEJ7V71tycgH0BEWOfIITqysFEFL5iBfsgk= → true
the same letters with a combining accent are different bytes, so wrong pässwörd 🔑, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$3NkHSPhtBEJ7V71tycgH0BEWOfIITqysFEFL5iBfsgk= → false
the stored count is used, whatever today's setting correct horse battery staple, pbkdf2_sha256$5000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$u/ryxpXZ/oGk/lfmNp6APHuNO8PyBzG5QsqV6CyK6qE= → true
the empty password against its own hash , pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$xbMBsf1hvO1j8AZCojBOxnRRn7182DxLyD2v4XQ/mFU= → true
a hash of 16 bytes, not 32, is checked at its own length correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKg== → true
another algorithm's hash correct horse battery staple, bcrypt$2b$12$abcdefghijklmnopqrstuv → error: stored password hash is not a pbkdf2_sha256 hash
Show the other 5 tests
CaseArgumentsExpected
a missing field correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw== → error: stored password hash is malformed
an iteration count with a leading zero correct horse battery staple, pbkdf2_sha256$01000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → error: stored password hash is malformed
a zero iteration count correct horse battery staple, pbkdf2_sha256$0$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → error: stored password hash is malformed
base64 with a character outside the alphabet correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8c*== → error: stored password hash is malformed
an empty salt field correct horse battery staple, pbkdf2_sha256$1000$$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4= → error: stored password hash is malformed

passwordNeedsRehash 10 tests

CaseArgumentsExpected
1,000 iterations when new hashes use 600,000 pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 600,000 → true
the same count as today pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 1,000 → false
more iterations than today is not a reason to re-hash pbkdf2_sha256$5000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$u/ryxpXZ/oGk/lfmNp6APHuNO8PyBzG5QsqV6CyK6qE=, 1,000 → false
an 8-byte salt from an older scheme pbkdf2_sha256$5000$AAECAwQFBgc=$2FZ3ntoESozfKw6vOmuoLOSWdzwww4+R0vJBF5xQLtY=, 1,000 → true
a 16-byte hash from an older scheme pbkdf2_sha256$5000$AAECAwQFBgcICQoLDA0ODw==$AhEiPny5jm3U51k/u5lqxw==, 1,000 → true
a 32-byte salt is fine pbkdf2_sha256$1000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$Ex4eaoxau7xpouluxG9E/RjMZTpG1gfchDgUwL5k4nE=, 1,000 → false
one iteration short pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 1,001 → true
today's count must itself be at least 1,000 pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 10 → error: iterations must be a whole number of at least 1000
a stored string from another algorithm sha1$abc$def, 1,000 → error: stored password hash is not a pbkdf2_sha256 hash
a stored string with non-canonical base64 pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODx==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 1,000 → error: stored password hash is malformed

More from the author

**The salt is an argument.** A capability cannot read randomness and stay testable, so the application generates it: 16 or more bytes from a secure source, fresh for every hash (`list(secrets.token_bytes(16))` in Python, `Array.from(crypto.getRandomValues(new Uint8Array(16)))` in TypeScript), never reused, never derived from the user. Fewer than 16 bytes is refused (NIST SP 800-132 section 5.1 asks for at least 128 bits).

**The algorithm** is PBKDF2-HMAC-SHA256 (`crypto.pbkdf2-sha256`) over the password's UTF-8 bytes, deriving 32 bytes. The iteration count is the work factor: OWASP's Password Storage Cheat Sheet recommends 600,000 for this algorithm, which takes about 50 ms in Python and about 200 ms in pure TypeScript or Rust (timings in `crypto.pbkdf2-sha256`). Fewer than 1,000 is refused (NIST SP 800-132's minimum). The count, salt and hash are all in the stored string, so raising the count later does not break existing hashes: `verifyPassword` uses the stored count, and `passwordNeedsRehash` says which hashes to upgrade at their owner's next successful login.

The stored form is `pbkdf2_sha256$<iterations>$<salt>$<hash>`, with the salt and hash in standard, padded base64. It looks like Django's, but Django keeps its salt as text, so the two are not interchangeable.

**verifyPassword** re-derives the key with the stored salt and count and compares it with `crypto.constant-time-equal`, so the time taken does not reveal how close a guess was. A wrong password is `false`. A stored string that is not this format (another algorithm, a missing field, bad base64, a count of 0 or with a leading zero) throws, because it means the database holds something this code did not write, and quietly answering `false` would lock the user out with no trace of why.

The password is used exactly as given: not trimmed (a trailing space is part of it) and not Unicode-normalised, so `"é"` typed as one code point and as `e` plus a combining accent are different passwords. NIST SP 800-63B suggests normalising; the standard libraries of Rust and browser-free TypeScript have no normaliser, so it is left to the caller to apply NFC consistently if it wants one.

Sources: RFC 8018 section 5.2 (PBKDF2); NIST SP 800-132, Recommendation for Password-Based Key Derivation, sections 5.1 and 5.2 (https://csrc.nist.gov/pubs/sp/800/132/final); NIST SP 800-63B section 5.1.1.2 (https://pages.nist.gov/800-63-3/sp800-63b.html); OWASP Password Storage Cheat Sheet (https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html).

Files

PathBytes
README.md3,024
impl/python/hash_password.py1,184
impl/python/password_needs_rehash.py603
impl/python/verify_password.py1,782
impl/rust/hash_password.rs1,784
impl/rust/password_needs_rehash.rs1,073
impl/rust/verify_password.rs3,088
impl/typescript/hash_password.ts1,235
impl/typescript/password_needs_rehash.ts668
impl/typescript/verify_password.ts1,962
vectors.json8,654