---
title: "Salted KDFs"
description: "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"
canonical_url: "https://hashes.agntn.dev/guide/kdf"
---
# 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

<caution title="Not your login system">

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.

</caution>

## 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:

```bash
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:

```bash
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.

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

## The knobs

<table>
<thead>
  <tr>
    <th>
      Algorithm
    </th>
    
    <th>
      Option
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      What it does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      scrypt
    </td>
    
    <td>
      <code>
        N
      </code>
    </td>
    
    <td>
      16384
    </td>
    
    <td>
      CPU and memory cost, a power of 2
    </td>
  </tr>
  
  <tr>
    <td>
      scrypt
    </td>
    
    <td>
      <code>
        r
      </code>
    </td>
    
    <td>
      8
    </td>
    
    <td>
      Block size
    </td>
  </tr>
  
  <tr>
    <td>
      scrypt
    </td>
    
    <td>
      <code>
        p
      </code>
    </td>
    
    <td>
      1
    </td>
    
    <td>
      Parallelization
    </td>
  </tr>
  
  <tr>
    <td>
      scrypt
    </td>
    
    <td>
      <code>
        keyLength
      </code>
    </td>
    
    <td>
      64
    </td>
    
    <td>
      Output bytes
    </td>
  </tr>
  
  <tr>
    <td>
      pbkdf2
    </td>
    
    <td>
      <code>
        iterations
      </code>
    </td>
    
    <td>
      600000
    </td>
    
    <td>
      Rounds of HMAC
    </td>
  </tr>
  
  <tr>
    <td>
      pbkdf2
    </td>
    
    <td>
      <code>
        digest
      </code>
    </td>
    
    <td>
      <code>
        sha512
      </code>
    </td>
    
    <td>
      The hash under HMAC: <code>
        sha256
      </code>
      
      , <code>
        sha384
      </code>
      
      , <code>
        sha512
      </code>
      
      , <code>
        sha3-256
      </code>
      
      , <code>
        sha3-512
      </code>
      
       or <code>
        whirlpool
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      pbkdf2
    </td>
    
    <td>
      <code>
        keyLength
      </code>
    </td>
    
    <td>
      64
    </td>
    
    <td>
      Output bytes
    </td>
  </tr>
  
  <tr>
    <td>
      argon2id, argon2i, argon2d
    </td>
    
    <td>
      <code>
        memory
      </code>
    </td>
    
    <td>
      65536
    </td>
    
    <td>
      Memory in KiB, at least 8 per lane
    </td>
  </tr>
  
  <tr>
    <td>
      argon2id, argon2i, argon2d
    </td>
    
    <td>
      <code>
        iterations
      </code>
    </td>
    
    <td>
      3
    </td>
    
    <td>
      Passes over the memory
    </td>
  </tr>
  
  <tr>
    <td>
      argon2id, argon2i, argon2d
    </td>
    
    <td>
      <code>
        parallelism
      </code>
    </td>
    
    <td>
      4
    </td>
    
    <td>
      Lanes
    </td>
  </tr>
  
  <tr>
    <td>
      argon2id, argon2i, argon2d
    </td>
    
    <td>
      <code>
        keyLength
      </code>
    </td>
    
    <td>
      32
    </td>
    
    <td>
      Output bytes, at least 4
    </td>
  </tr>
  
  <tr>
    <td>
      argon2id, argon2i, argon2d
    </td>
    
    <td>
      <code>
        secret
      </code>
      
      , <code>
        associatedData
      </code>
    </td>
    
    <td>
      none
    </td>
    
    <td>
      Hex mixed into the first hash, a pepper and context
    </td>
  </tr>
  
  <tr>
    <td>
      bcrypt
    </td>
    
    <td>
      <code>
        cost
      </code>
    </td>
    
    <td>
      12
    </td>
    
    <td>
      Base-2 logarithm of the rounds, 4 to 31
    </td>
  </tr>
</tbody>
</table>

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](https://hashes.agntn.dev/algorithms/scrypt), [PBKDF2](https://hashes.agntn.dev/algorithms/pbkdf2), [Argon2id](https://hashes.agntn.dev/algorithms/argon2id) and [bcrypt](https://hashes.agntn.dev/algorithms/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:

```bash
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`.


## Sitemap

See the full [sitemap](https://hashes.agntn.dev/sitemap.md) for all pages.
