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.
- hashPassword (password: string, salt: int[], iterations: int) -> string
- verifyPassword (password: string, stored: string) -> bool
- passwordNeedsRehash (stored: string, iterations: int) -> bool
Once installed, your code imports each one from the group's module.
hashPassword throws on bad input 11 tests
export function hashPassword(password: string, salt: readonly number[], iterations: number): string
| password | string | exactly as typed; UTF-8 encoded, not trimmed or normalised |
| salt | int[] | 16 or more random bytes, new for every hash (secrets.token_bytes(16), crypto.getRandomValues) |
| iterations | int | 1000 or more; 600000 is the current OWASP figure |
| returns | string | pbkdf2_sha256$<iterations>$<salt, base64>$<32-byte hash, base64> |
For example
hashPassword(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 iterationshashPassword(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 stringhashPassword(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
import { hashPassword } from "#fune/auth.password-hash@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { pbkdf2Sha256 } from "./crypto_pbkdf2_sha256.ts"; ← from crypto.pbkdf2-sha256 ^1.0.0 · built alongside by fune
import { base64Encode } from "./encoding_base64_base64_encode.ts";
import { utf8Encode } from "./encoding_utf8_utf8_encode.ts";
/**
* 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.
*/
export function hashPassword(password: string, salt: readonly number[], iterations: number): string {
if (typeof password !== "string") throw new TypeError("password must be a string");
if (!Array.isArray(salt) && !(salt instanceof Uint8Array)) {
throw new TypeError("salt must be a list of integers from 0 to 255");
}
if (salt.length < 16) {
throw new RangeError(`salt must be at least 16 bytes, received ${salt.length}`);
}
if (typeof iterations !== "number" || !Number.isInteger(iterations) || iterations < 1000) {
throw new RangeError("iterations must be a whole number of at least 1000");
}
const hash = pbkdf2Sha256(utf8Encode(password), salt, iterations, 32);
return `pbkdf2_sha256$${iterations}$${base64Encode(salt)}$${base64Encode(hash)}`;
}verifyPassword throws on bad input 15 tests
export function verifyPassword(password: string, stored: string): boolean
| password | string | the attempt, exactly as typed |
| stored | string | a string hashPassword made; its own iteration count and salt are used |
| returns | bool | true when the password matches; a malformed stored string throws |
For example
verifyPassword(correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=)→ true the right passwordverifyPassword(correct horse battery stapler, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=)→ false a wrong passwordverifyPassword(Correct horse battery staple, pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=)→ false case matters
import { verifyPassword } from "#fune/auth.password-hash@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { pbkdf2Sha256 } from "./crypto_pbkdf2_sha256.ts"; ← from crypto.pbkdf2-sha256 ^1.0.0 · built alongside by fune
import { constantTimeEqual } from "./crypto_constant_time_equal.ts"; ← from crypto.constant-time-equal ^1.0.0 · built alongside by fune
import { base64Decode } from "./encoding_base64_base64_decode.ts";
import { utf8Encode } from "./encoding_utf8_utf8_encode.ts";
const MALFORMED = "stored password hash is malformed";
function decodeField(text: string): readonly number[] {
let bytes: readonly number[];
try {
bytes = base64Decode(text);
} catch {
throw new RangeError(MALFORMED);
}
if (bytes.length === 0) throw new RangeError(MALFORMED);
return bytes;
}
/**
* The parts of a stored pbkdf2_sha256 string, or an error: a string this
* code did not write is a data problem to surface, not a wrong password.
*/
export function parseStoredHash(stored: string): { iterations: number; salt: readonly number[]; hash: readonly number[] } {
if (typeof stored !== "string") throw new TypeError("stored password hash must be a string");
const parts = stored.split("$");
if (parts[0] !== "pbkdf2_sha256") throw new RangeError("stored password hash is not a pbkdf2_sha256 hash");
if (parts.length !== 4) throw new RangeError(MALFORMED);
const count = parts[1];
if (count.length === 0 || count.length > 10 || count[0] === "0") throw new RangeError(MALFORMED);
for (let i = 0; i < count.length; i++) {
if (count[i] < "0" || count[i] > "9") throw new RangeError(MALFORMED);
}
return { iterations: Number(count), salt: decodeField(parts[2]), hash: decodeField(parts[3]) };
}
/**
* Does the password match the stored hash? Re-derived with the stored salt
* and iteration count, compared in constant time.
*/
export function verifyPassword(password: string, stored: string): boolean {
if (typeof password !== "string") throw new TypeError("password must be a string");
const { iterations, salt, hash } = parseStoredHash(stored);
return constantTimeEqual(pbkdf2Sha256(utf8Encode(password), salt, iterations, hash.length), hash);
}passwordNeedsRehash throws on bad input 10 tests
export function passwordNeedsRehash(stored: string, iterations: number): boolean
| stored | string | a string hashPassword made |
| iterations | int | the count new hashes use today |
| returns | bool | true when it has fewer iterations, a salt under 16 bytes or a hash other than 32 bytes |
For example
passwordNeedsRehash(pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 600,000)→ true 1,000 iterations when new hashes use 600,000passwordNeedsRehash(pbkdf2_sha256$1000$AAECAwQFBgcICQoLDA0ODw==$ppsXnjrdPB4KryJ6DrOqKqhkWrhv7PbKAMF1Eml8cZ4=, 1,000)→ false the same count as todaypasswordNeedsRehash(pbkdf2_sha256$5000$ZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoM=$u/ryxpXZ/oGk/lfmNp6APHuNO8PyBzG5QsqV6CyK6qE=, 1,000)→ false more iterations than today is not a reason to re-hash
import { passwordNeedsRehash } from "#fune/auth.password-hash@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { parseStoredHash } from "./auth_password_hash_verify_password.ts"; ← 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, while the plain password is at hand, and re-hash and
* save when it answers true.
*/
export function passwordNeedsRehash(stored: string, iterations: number): boolean {
if (typeof iterations !== "number" || !Number.isInteger(iterations) || iterations < 1000) {
throw new RangeError("iterations must be a whole number of at least 1000");
}
const parsed = parseStoredHash(stored);
return parsed.iterations < iterations || parsed.salt.length < 16 || parsed.hash.length !== 32;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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. 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
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./auth.password-hash-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.password-hash@1.0.0:typescript.
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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).