Guide

Salted KDFs

scrypt and PBKDF2 and Argon2 draw a salt when you give none and print it next to the digest. Keep it or the digest is useless
scrypt, PBKDF2, Argon2 and bcrypt 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, bcrypt 16. 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.

An empty salt is still a salt, just a very short one. Who needs that? RFC 7914 does, its first scrypt vector has none. --salt "" or salt: "" runs exactly that, and the answer prints salt "" instead of a blank. Leave the option out and you're back to random bytes.

shell
hashes scrypt "" --salt "" --n 16 --r 1 --p 1
# N 16, r 1, p 1, keyLength 64, salt ""
# 77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906

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, sha3-512 or whirlpool
pbkdf2keyLength64Output bytes
argon2id, argon2i, argon2dmemory65536Memory in KiB, at least 8 per lane
argon2id, argon2i, argon2diterations3Passes over the memory
argon2id, argon2i, argon2dparallelism4Lanes
argon2id, argon2i, argon2dkeyLength32Output bytes, at least 4
argon2id, argon2i, argon2dsecret, associatedDatanoneHex mixed into the first hash, a pepper and context
bcryptcost12Base-2 logarithm of the rounds, 4 to 31

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. Argon2's defaults are RFC 9106's second recommended option, and its salt needs at least 8 bytes. bcrypt wants exactly 16 bytes of salt and refuses a password past 72 bytes instead of cutting it. The scrypt, PBKDF2, Argon2id and bcrypt 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. hashes_compute and hashes_verify cap N at 1048576, r at 32, p at 16, iterations at 10 million, keyLength and ivLength at 1024 bytes, and scrypt's block table, 128·r·N bytes, at 256 MiB. brainwallet.io's N=262144 with r=8 fills that to the byte and runs. Four times the table doesn't:

text
Invalid option N=1048576: with r=8 needs 1073741824 bytes of blocks, over 268435456 in a tool call

Argon2 gets 256 MiB too, as memory up to 262144. Its time grows with memory times passes, so that product stops at 1048576 KiB, about five seconds here. 256 MiB with four passes runs. With five it doesn't:

text
Invalid option iterations=5: with memory=262144 fills 1310720 KiB, over 1048576 in a tool call

bcrypt's cost stops at 16. Every step doubles the work, and 16 already takes a few seconds.

PBKDF2 and EVP_BytesToKey get a price on top. Ten million iterations sounds like a cap. It isn't one alone. Every iteration runs once per digest the output needs, and digests don't cost the same. Whirlpool takes nearly nine times what SHA-256 does. Ask for 1024 bytes of it at ten million and one call runs for over twenty minutes. Coffee, anyone? So the tool multiplies iterations by digests by what each digest costs here, and stops near five seconds. The error does the division for you:

text
Invalid option iterations=10000000: whirlpool fills 1024 bytes 64 at a time, so a tool call runs at most 36337 iterations; lower iterations or ask for fewer bytes

Django's 1.2 million iterations of SHA-256 still fit. About a second.

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 hashes_verify refuse to run:

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

Add --salt 73616c74 and it says MATCH.