Skip to content

Search docs

Jump between documentation pages.

Browse docs

Password hashing

Think of it like… a paper shredder that always cross-cuts to the same government spec. You do not choose the blade pattern, the strip width, or how many passes it makes: you feed a password in, and the only thing that comes out is confetti that can be compared against other confetti, never reassembled.

Daloy ships exactly one correct way to hash a password. The API is two functions with no knobs: no algorithm switch, no cost-factor argument, no salt management. Import them from the @daloyjs/core/hashing subpath:

ts
import { passwordHash, passwordVerify } from "@daloyjs/core/hashing";

const hash = await passwordHash("hunter2");
// "$scrypt$N=131072,r=8,p=1$<salt-base64>$<hash-base64>"

await passwordVerify("hunter2", hash); // true
await passwordVerify("wrong", hash);   // false
One password in, one PHC string out
  1. 01inputPlaintext passwordUTF-8, 1 to 4096 bytes
  2. 02saltRandom 16-byte saltcrypto.randomBytes per call
  3. 03kdfscrypt (Node core)N=2^17, r=8, p=1, 32-byte key
  4. 04outputPHC-style string$scrypt$N=131072,r=8,p=1$…$…
passwordHash() encodes the algorithm, parameters, salt, and digest into one self-describing string, so verifying later never requires re-supplying any of them.

Why scrypt, not Argon2 or bcrypt

OWASP's Password Storage Cheat Sheet lists Argon2id first and scrypt as the recommended alternative. Argon2id has no implementation in Node core: using it means installing a native binding, and @daloyjs/core refuses to ship runtime dependencies as a supply-chain guarantee. scrypt is memory-hard like Argon2, ships in node:crypto, and Daloy pins it to the OWASP-aligned parameters (N = 2^17, r = 8, p = 1, 32-byte key, 16-byte salt). bcrypt is not memory-hard and silently truncates passwords at 72 bytes, so it is not offered at all.

If your organization mandates Argon2id specifically, install the argon2 package in your app and use it directly: the framework intentionally does not wrap it.

Constant-time verification, no exception oracle

passwordVerify() re-derives the key with the parameters stored in the PHC string and compares digests with crypto.timingSafeEqual. It returns false for anyfailure, including a malformed or truncated stored hash, and never throws. A caller (or an attacker watching your error responses) cannot distinguish "corrupt hash in the database" from "wrong password" through exception side channels.

passwordVerify(password, storedHash)
login attemptRe-derive scrypt keyparams + salt parsed from the stored PHC string
digests equaltruetimingSafeEqual comparison
anything elsefalse, never throwswrong password, malformed hash, foreign parameters
Every failure path collapses into the same boolean so response timing and error shape leak nothing about why verification failed.

The PHC string

The output is a PHC-style string that is safe to store in a single database column:

text
$scrypt$N=131072,r=8,p=1$5uPXKW3ZJdyj0hrSNz7BSg$1kSpZzz9mcXpAsRq/ZmTHktGlaFsD16kSCPWDT9CJ7c
   |         |                 |                    |
   |         |                 |                    +-- 32-byte derived key (base64)
   |         |                 +-- 16-byte random salt (base64)
   |         +-- cost parameters, pinned to OWASP-aligned values
   +-- algorithm identifier

Verification accepts only this exact shape. Hashes that claim different N/r/p values are rejected rather than honored, so an attacker who can tamper with stored hashes cannot downgrade a record to a cheap cost factor and brute-force it offline.

Input guardrails

  • Empty passwords are rejected: passwordHash throws a TypeError and passwordVerify returns false.
  • 4096-byte cap: scrypt runs PBKDF2-HMAC-SHA256 over the full password, so an unbounded input lets an attacker amplify CPU per call. Anything longer than 4096 UTF-8 bytes is refused, which is far above any legitimate passphrase.
  • Fresh salt per hash: hashing the same password twice yields two different strings, so equal passwords are not linkable across rows.

In a login slice

ts
import { z } from "zod";
import { App, loginThrottle } from "@daloyjs/core";
import { passwordHash, passwordVerify } from "@daloyjs/core/hashing";

const app = new App();
const credentials = z.object({
  email: z.string().email(),
  password: z.string().min(12).max(1024),
});

app.post(
  "/signup",
  {
    request: { body: credentials },
    responses: { 201: { description: "Created" } },
  },
  async ({ body }) => {
    await db.users.insert({
      email: body.email,
      passwordHash: await passwordHash(body.password),
    });
    return { status: 201 };
  },
);

app.post(
  "/login",
  {
    hooks: loginThrottle({ windowMs: 60_000, max: 5 }),
    request: { body: credentials },
    responses: {
      200: { description: "OK" },
      401: { description: "Invalid credentials" },
    },
  },
  async ({ body }) => {
    const user = await db.users.findByEmail(body.email);
    // Verify against a dummy hash when the user is missing so response
    // timing does not reveal which emails exist.
    const ok = await passwordVerify(
      body.password,
      user?.passwordHash ?? DUMMY_HASH,
    );
    if (!user || !ok) return { status: 401 };
    return { status: 200 };
  },
);

Pair it with loginThrottle() (see the login throttle page) so online guessing is rate-limited, and with the auth slice for the full session/CSRF picture. The DUMMY_HASH pattern above keeps the scrypt work constant whether or not the account exists; generate it once at boot with await passwordHash(randomUUID()).

When not to use it

passwordHash is for credentials a human types. For API keys and webhook secrets that are long and random, a plain SHA-256 digest compared with timingSafeEqual() is appropriate and thousands of times cheaper: memory-hard KDFs only exist to slow down guessing of low-entropy secrets. For signing, see HTTP message signatures.