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 dep —
node:crypto.scryptis 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 scryptlt;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.