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$32768$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^15 = 32768 |
CPU + memory cost |
r |
8 | block size |
p |
1 | parallelism |
Measured — node v26.3.0, Apple M2 Pro, median of 5 runs at r=8 p=1 keyLen=32. Memory is 128 · N · r, held for the whole derivation:
N |
time | memory |
|---|---|---|
2^14 |
39 ms | 16 MiB |
2^15 (this) |
73 ms | 32 MiB |
2^16 |
156 ms | 64 MiB |
2^17 (OWASP's floor) |
271 ms | 128 MiB |
Why not OWASP's 2^17
Every row of that table is a cost your server pays, per attempt, on an endpoint an anonymous caller controls. Two facts decide it:
- The brute-force lockout that is on by default is keyed by email — deliberately, so an unknown address locks exactly like a real one and the lock is not an existence oracle. An attacker who rotates the email field is therefore not rate-limited, and every attempt buys a full derivation. General per-IP rate limiting is opt-in (
@voltro/plugin-ratelimit). - The common deployment target is a small container. At 0.25 vCPU,
2^17is over a second of CPU and 128 MiB per anonymous attempt — one laptop can hold that box down, and ten concurrent logins is an OOM.
Availability is part of security, so the ceiling here is set by what an anonymous caller can make the server spend, not by the offline-cracking table alone. Put a per-IP limiter in front of /auth and raising N becomes cheap.
The parameters are encoded inline in the hash string (scrypt$32768$8$1$…), so a cost bump decodes older hashes and re-encodes them on the next sign-in — see Rehash-on-verify. Hashes minted at 2^14 keep verifying; nothing to migrate.
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.
Brute-force lockout
plugin-auth locks an account after repeated failed credential attempts, so password-spraying and credential-stuffing don't get unlimited guesses. After 5 failed attempts (a wrong password — or, for MFA users, a wrong second-factor code) within 15 minutes, sign-in for that email is refused with a 429 account_locked (carrying a retryAfterSeconds) for 15 minutes. A completed login clears the counter.
The counter is keyed by email, not user id, and it tracks unknown addresses too: a locked account and an unknown-but-hammered address respond identically, so the lock can't be turned into an existence oracle — the same reasoning as the timing-oracle defence above.
It is on by default — a security default you get for free. Tune or disable it per app:
authRoutesPlugin({
store,
lockout: {
maxAttempts: 5, // failed attempts before locking (default 5)
windowSeconds: 900, // counting window (default 15 min)
lockSeconds: 900, // lock duration (default 15 min)
},
})The counter lives in the loginAttempts table (contributed via authTables), so it appears automatically on your next voltro db apply / voltro dev boot. For lockout that holds across multiple nodes, back the store with Postgres (postgresUserStore) — the in-memory store is single-node.
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.