Better Auth hashes passwords with scrypt. createAccess and the Better Auth plugin aventaraAccess() hash them with
argon2id instead. There are three ways to set it up:
- The default. Set neither
passwordsnorbetterAuth.emailAndPassword.password. Passwords are hashed with argon2id at the"owasp-minimum"profile. - A named profile.
passwords: { argon2id: "rfc9106-low-memory" }besidebetterAuth. A misspelled profile is a type error, and is refused at run time withACCESS_OPTIONS_INVALID. AddlegacyScrypt: trueto accept passwords Better Auth already stored with scrypt. - Your own hook. Set Better Auth's
emailAndPassword.password(hashandverify) in thebetterAuthblock. It is used as you wrote it, and Access installs nothing. To keep Better Auth's scrypt, pass its own functions.
// src/access.passwords.ts
import type { AccessPasswordsOptions } from "@aventara/access/auth";
import type { BetterAuthOptions } from "better-auth";
import { hashPassword, verifyPassword } from "better-auth/crypto";
// 1. The default: leave `passwords` out, and `emailAndPassword.password` too.
export const byDefault = {
enabled: true,
} satisfies BetterAuthOptions["emailAndPassword"];
// 2. A named profile: createAccess({ betterAuth, policies, passwords }).
export const passwords = {
argon2id: "rfc9106-low-memory",
} satisfies AccessPasswordsOptions;
// 2b. The same, for an app whose users Better Auth signed up with scrypt.
export const movingOffScrypt = {
argon2id: "owasp-minimum",
legacyScrypt: true,
} satisfies AccessPasswordsOptions;
// 3. Your own hook, here Better Auth's scrypt: betterAuth: { emailAndPassword }, and no `passwords`.
export const emailAndPassword = {
enabled: true,
password: {
hash: hashPassword,
verify: verifyPassword,
},
} satisfies BetterAuthOptions["emailAndPassword"];Setting both passwords and betterAuth.emailAndPassword.password is refused with ACCESS_OPTIONS_INVALID, so it is
never in doubt which one hashes.
The profiles
| Profile | Memory | Passes (t) | Lanes (p) | Source |
|---|---|---|---|---|
"owasp-minimum" (the default) | 19 MiB (m=19456) | 2 | 1 | OWASP Password Storage Cheat Sheet |
"rfc9106-low-memory" | 64 MiB (m=65536) | 3 | 4 | RFC 9106 section 4, the second recommended option |
Both use a 16-byte random salt and a 32-byte tag. RFC 9106's first recommended option (2 GiB of memory per hash) is not offered: a server that signs users in concurrently cannot spend 2 GiB on each.
How hashes are stored and checked
- A hash is a PHC string,
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. Verifying reads the parameters from the stored string, so you can move to another profile later and every older hash still verifies. The hash is compared in constant time. - Every check costs one argon2id computation at your profile, whatever the stored value is, so the time a check takes does not say what an account holds. One exception: after you move to another profile, a hash stored at the old one is checked at the old one's cost, so the time a sign-in takes can tell such an account apart (and that it exists) until its user next signs in, which rehashes it at your profile (below). Active users' hashes move; an account nobody signs in to keeps the old cost until its password is set again.
- A stored hash that costs more than the largest profile (
mover 65536,tover 3,pover 4) is refused without being computed, with a warning in Better Auth's log. A tampered row cannot make a sign-in allocate gigabytes. - A corrupted value, or a failure inside the computation, fails the check like a wrong password. A check never throws.
- A password longer than Better Auth's
emailAndPassword.maxPasswordLength(128 by default) is never hashed:PASSWORD_TOO_LONG(400). Passwords are NFKC-normalised before hashing, as Better Auth's scrypt does. - Better Auth's plugins that hash or check a password (
admin,username,phone-number,email-otp, two-factor) use the same hook.
Rehashing at sign-in
A sign-in whose password matches a hash stored at other parameters than your profile's (an older profile, a cheaper or a costlier one) stores the password again at your profile. Change the profile, and each active account moves to it at its next sign-in.
- The hook checks the password against the stored hash itself before it writes anything, so it only ever stores a hash of the password that matched. A wrong password changes nothing.
- The write is a compare-and-set: it changes the account only while it still holds the hash just checked. A password changed or reset in the meantime is never overwritten. Two sign-ins at once both write a valid hash of the same password, and the later one stays.
- Cost: a hash already at your profile costs one read of the credential account per password sign-in, and no write. The sign-in that moves a hash costs two argon2id computations more (the check again, and the new hash) and one write.
- The write goes through Better Auth's database adapter, past its
accountupdate hooks (databaseHooks): it is the same password, stored anew. - A failure is logged as an error, without the password or a hash, and the sign-in stands. The next sign-in tries again.
- An account with two-factor on may not be rehashed by a password sign-in that its second step holds back: once two-factor's hook has ended the session that sign-in opened, there is none to rehash for.
- With your own
emailAndPassword.passwordhook, Access rehashes nothing. - Kiosk PINs move the same way at the next switch (The kiosk profile).
Moving off scrypt
Hashes Better Auth stored with scrypt (<salt>:<key> in hex) fail the check by default, with a warning in Better
Auth's log. With legacyScrypt: true they verify through Better Auth's own verifyPassword, and a sign-in that matches
one stores the password again with argon2id. Each account leaves scrypt at its next sign-in. Once none is left, turn
legacyScrypt off.
While it is on, every hash and check also costs one scrypt, so sign-up and sign-in are about twice as slow.
Node.js 22
argon2id runs on Node.js's own crypto.argon2, added in Node.js 24.7.0, with no native dependency. Node.js 22 has
none, so there Access uses @node-rs/argon2, an optional peer you install yourself at the exact version Access is
tested against:
npm install --save-exact @node-rs/argon2@2.2.2It computes the same function, and Access writes and reads the PHC strings itself, so a hash made on Node.js 22
verifies on Node.js 24 and back. On Node.js 24 it is never loaded. On Node.js 22 without it, createAccess refuses
with ACCESS_OPTIONS_INVALID, unless you hash passwords yourself.
Kiosk PINs are hashed with argon2id at the same profile (The kiosk profile).