Passwords

scrypt-based password hashing, verification, timing-oracle defence, and why not bcrypt or argon2.

@voltro/plugin-auth/password exposes two functions: hashPassword(plaintext) and verifyPassword(plaintext, hash). Both return Effects, not Promises — hashPassword fails with a typed PasswordEmptyError | PasswordHashError; verifyPassword returns Effect<boolean> (never fails — see below). Compose them in Effect.gen, or Effect.runPromise them at the edge.

Hashing on sign-up

import { hashPassword } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const hash = await Effect.runPromise(hashPassword('correct horse battery staple'))
// → 'scrypt$16384$8$1
#x3C;saltB64>
#x3C;derivedB64>'

Store hash in the passwordHash column of your users table. Never store the plaintext.

Verifying on sign-in

import { verifyPassword } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const ok = await Effect.runPromise(verifyPassword(input.password, user.passwordHash))
if (!ok) throw new Unauthorised({})

verifyPassword compares the derived key with timingSafeEqual, so timing-based guessing of a correct prefix is mitigated. It also returns Effect<boolean> with no failure channel: a malformed hash, a parse error, or a scrypt error all collapse to false (via Effect.catchAll). That's deliberate — surfacing "malformed" vs "mismatched" would let an attacker fingerprint stored-hash structure. You only ever branch on the boolean.

Why scrypt, not bcrypt / argon2

We picked scrypt deliberately. It's:

Function Native dep? Memory-hard? OWASP-recommended?
scrypt ✗ (in node:crypto)
bcrypt ✓ (bcrypt npm pkg) partially
argon2 ✓ (argon2 npm pkg) ✓ (preferred)
pbkdf2 only with high iteration count
  • No native depnode:crypto.scrypt is built into Node. argon2 needs a C addon that breaks on Alpine / Bun / serverless runtimes regularly.
  • Memory-hard — defeats GPU brute-forcing the way bcrypt + pbkdf2 don't.
  • OWASP-acceptable — not their top pick (argon2id is) but explicitly listed as safe.

Cost parameters

The framework uses scrypt with these defaults:

Param Value Effect
N 2^14 = 16384 CPU + memory cost
r 8 block size
p 1 parallelism

At those parameters a single hash takes roughly ~50ms on a modern laptop — slow enough to make offline brute force expensive, fast enough that sign-in feels instant. The cost is roughly equivalent to bcrypt's $2y$10$. The parameters are encoded inline in the hash string (scrypt$16384$8$1$…), so a future cost bump can decode older hashes and re-encode on the next sign-in.

For high-throughput service-to-service flows that need many auths per second, use API keys instead — apiKeyStrategy from @voltro/protocol/apikey. Passwords are for humans.

Timing-oracle defence

A naive sign-in implementation leaks "is this email registered?" via response time:

// BAD — fast 401 for unknown email, slow 401 for wrong password
const user = await store.findByEmail(input.email)
if (!user) return error(401)
if (!await verifyPassword(input.password, user.passwordHash)) return error(401)

The framework's handleSignIn always runs verifyPassword (with a dummy hash for the unknown-email case) so response times are uniform.

import { handleSignIn } from '@voltro/plugin-auth'
// handleSignIn internally (Effect-gen):
//   const user = yield* store.findByEmail(email)
//   const ok = user
//     ? yield* verifyPassword(password, user.passwordHash)
//     : (yield* verifyPassword(password, freshDecoyHash), false)
//   if (!user || !ok) return json(401, { error: 'invalid credentials' })

Use handleSignIn instead of rolling your own — the timing-oracle gap is the kind of subtle bug that hides for years. Note it returns a 401 HandlerResult, it does not throw a domain error.

Rehashing on parameter bump

When the framework updates the default cost parameters, existing hashes stay valid — verifyPassword reads N/r/p from the stored hash string itself (they're encoded inline as scrypt

lt;N>
lt;r>
lt;p>$…
). Rehash-on-verify ships: needsRehash(stored) reports whether a hash is below the current cost, and verifyPasswordWithRehash(plaintext, stored) returns { valid, rehash? } — when the password matches an under-cost hash, rehash is a freshly-minted replacement. handleSignIn wires this through UserStore.updatePassword, so a user's stored hash silently strengthens on their next login, no forced reset and no backfill.

Password policy

The framework doesn't enforce a policy at the hash layer — that's a UX decision. Enforce at the sign-up handler:

import { Schema } from 'effect'

const PasswordSchema = Schema.String.pipe(
  Schema.minLength(12),                              // OWASP minimum for non-2FA
  Schema.pattern(/[a-z]/),
  Schema.pattern(/[A-Z]/),
  Schema.pattern(/[0-9]/),
)

OWASP's current guidance: minimum length 8 (12 preferred), no upper-cap below 64, no required character classes if length ≥ 12. handleSignUp itself only enforces the 8-character floor; richer policy is yours to add at the route. Checking passwords against the HIBP breach corpus is a good idea — wire the k-anonymity range API into your sign-up route yourself; the plugin ships no HIBP helper.