Guide

Salted KDFs

scrypt and PBKDF2 draw a salt when you give none and print it next to the digest. Keep it or the digest is useless
scrypt and PBKDF2 are here so you can reproduce and check a digest somebody else made: a wallet file, a test vector, a puzzle. Storing your users' passwords is a different job, with rate limits, pepper and migrations. Use a library built for it.

The salt comes back

A KDF without a salt draws 32 random bytes. That's the right default and a trap at the same time, because the digest is worthless without those bytes. So the result carries them, and the CLI prints them:

shell
hashes scrypt "correct horse battery staple"
# N 16384, r 8, p 1, keyLength 64, salt 31f4aa33e6346bfa6d576a5dce9eec1c0d6c8e959271d03c2f489a71ac52453b
# bc010c96df4510eef56ce1a1fc1d140ee276b92d30a31e2ca2e065f6dac8977c9c1bd5d20951e54fbd5b4474984c0f45f5adbe8c7b18daeeacbef09dc330a902

The first line goes to stderr and the digest alone to stdout, so a pipe still gets one clean value. Run it again and you get another salt and another digest. That's what salts are for. --salt takes one back:

shell
hashes scrypt "correct horse battery staple" --salt 73616c74 --N 1024
# N 1024, r 8, p 1, keyLength 64, salt 73616c74
# a1687da9760232a5febf395ab288cb93c9ebb6725c005323a68ae49a4c24538951d7e88980797633a82cddf7cc2f37ffcfa42d2a1222eb06efcb687651975e4c

In the library the same facts sit in result.options:

ts
import { create } from "@agntn/hashes";

const derived = create("pbkdf2").hash("password", {
  salt: "73616c74",
  iterations: 4096,
  digest: "sha256",
  keyLength: 32,
});
derived.digest; // "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a"
derived.options; // { encoding: "hex", iterations: 4096, digest: "sha256", keyLength: 32, salt: "73616c74" }

The salt is hex by default. saltEncoding: "utf8" or "base64" reads it another way, and a Uint8Array is taken as it is. A salt that doesn't decode is an error, never silently shorter.

The knobs

AlgorithmOptionDefaultWhat it does
scryptN16384CPU and memory cost, a power of 2
scryptr8Block size
scryptp1Parallelization
scryptkeyLength64Output bytes
pbkdf2iterations600000Rounds of HMAC
pbkdf2digestsha512The hash under HMAC: sha256, sha384, sha512, sha3-256 or sha3-512
pbkdf2keyLength64Output bytes

scrypt checks RFC 7914's bounds the way OpenSSL does, so N has to stay below 2 to the power 16·r when r is small. The defaults are honest about what they are. PBKDF2's 600000 rounds is OWASP's number for HMAC-SHA256, and with SHA-512 OWASP asks for 220000, so the default errs slow. scrypt's N=16384 with p=1 sits below what OWASP asks for passwords today, and the security note says so out loud instead of hiding it. The scrypt and PBKDF2 pages have the full notes.

When an agent picks the cost

The library lets you ask for any cost. A tool call doesn't, because one argument from a model shouldn't pin the CPU for a minute or allocate gigabytes. hash_compute and hash_verify cap N at 1048576, r at 32, p at 16, iterations at 10 million and keyLength at 1024 bytes, and scrypt's working memory, 128·r·(N + p + 2) bytes, at 256 MiB:

text
Invalid option N=1048576: with r and p needs 1073744896 bytes, over 268435456 in a tool call

The tool answer names the salt it drew, like the CLI does. An MCP client sees only the text, so a salt that lived only in structured details would be a salt the model never saw.

Verifying

Verify needs the salt the expected digest was made with. Without it the answer could only be no, so hashes verify and hash_verify refuse to run:

shell
hashes verify pbkdf2 password c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a --iterations 4096 --digest sha256 --keyLength 32
# Missing required option: salt (the one the expected digest was made with)

Add --salt 73616c74 and it says MATCH.