# Password hashing

> The password helpers use one versioned scrypt format with fixed security checks. Applications store the encoded hash and verify candidates without handling the low-level parameters themselves.

OAuth2 role Authorization Server

Not DaloyJS. Bring an identity provider.

Owns login screens, user records, credential resets, MFA, consent, client registration, and token issuance and revocation. This is the hardest part of authentication to get right, and getting it wrong is a breach rather than a bug. Use a managed provider (Auth0, Okta, Entra ID, Cognito, Clerk) or a vetted self-hosted one (Keycloak, Zitadel, Ory).

Read this before you use the page. If you are hashing passwords, you are storing credentials, which means you have taken on the authorization-server role whether you meant to or not. Password storage is the small, easy part. What follows it is the hard part: reset tokens that cannot be replayed, enumeration-safe error messages, breached-password checks, lockout that does not become a denial of service, MFA enrolment and recovery, and a credible answer when a customer asks how you handle a leak.

Use `passwordHash()` when credentials genuinely have to live in your database: an internal tool, an air-gapped deployment, a migration off a legacy store, a seeded admin account. For a product with real users, let a provider hold the passwords and let DaloyJS verify the tokens it issues.

[Auth architecture: where DaloyJS fits in OAuth2 & OpenID Connect](/docs/auth/architecture) explains all three roles and the two deployment shapes we recommend.

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

**Diagram: One password in, one PHC string out**

1. **Plaintext password** (input) - UTF-8, 1 to 4096 bytes
2. **Random 16-byte salt** (salt) - crypto.randomBytes per call
3. **scrypt (Node core)** (kdf) - N=2^17, r=8, p=1, 32-byte key
4. **PHC-style string** (output) - $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 *any* failure, 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.

**Diagram: passwordVerify(password, storedHash)**

- **Re-derive scrypt key** (source, login attempt) - params + salt parsed from the stored PHC string
- **true** (digests equal) - timingSafeEqual comparison
- **false, never throws** (anything else) - wrong 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](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) string that is safe to store in a single database column:

```
$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](/docs/security/websocket-login-throttle) page) so online guessing is rate-limited, and with the [auth slice](/docs/security/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](/docs/http-signatures).

---

Source: https://daloyjs.dev/docs/security/hashing