`maxPasswordLength` is not enforced before hashing on sign-in, verify-password, change-password (currentPassword), delete-user, two-factor and admin create-user
Is this suited for github?
- Yes, this is suited for github
Reproduction
Default config, default scrypt hasher, [email protected] and main (41b7dc1).
const auth = betterAuth({ emailAndPassword: { enabled: true }, /* any adapter */ });
// 1. sign-up enforces the bound before hashing
await auth.api.signUpEmail({ body: { name: "a", email: "[email protected]", password: "x".repeat(129) } });
// -> APIError 400 PASSWORD_TOO_LONG, no hash computed
// 2. sign-in does not
await auth.api.signInEmail({ body: { email: "[email protected]", password: "x".repeat(1024 * 1024) } });
// -> APIError 401 INVALID_EMAIL_OR_PASSWORD, after a full scrypt over the 1 MiB inputSame shape on every endpoint in the table below: the input goes straight to
ctx.context.password.hash / .verify with no length check.
Current vs. Expected behavior
Current. maxPasswordLength (default 128, context/create-context.ts) is
checked on the seven endpoints that set a password, and skipped on the eleven
that verify one (plus /admin/create-user, which hashes with neither a min
nor a max):
| Endpoint | Call site on main |
Unchecked field |
|---|---|---|
POST /sign-in/email |
api/routes/sign-in.ts:539,550,557 |
password |
POST /sign-in/username |
plugins/username/index.ts:472,496 |
password |
POST /sign-in/phone-number |
plugins/phone-number/routes.ts:162 |
password |
POST /change-password |
api/routes/update-user.ts:277 |
currentPassword (newPassword is checked at 262) |
POST /delete-user |
api/routes/update-user.ts:483 |
password |
POST /verify-password |
api/routes/password.ts:379 → utils/password.ts:17 |
password |
POST /two-factor/enable |
plugins/two-factor/index.ts:185 → utils/password.ts:17 |
password |
POST /two-factor/disable |
plugins/two-factor/index.ts:365 → utils/password.ts:17 |
password |
POST /two-factor/generate-backup-codes |
plugins/two-factor/backup-codes/index.ts:494 → utils/password.ts:32,36 |
password |
POST /two-factor/get-totp-uri |
plugins/two-factor/totp/index.ts:216 → utils/password.ts:32,36 |
password |
POST /admin/create-user |
plugins/admin/routes.ts:458 |
password |
Checked, for comparison: /sign-up/email (sign-up.ts:228), /change-password
newPassword (update-user.ts:262), /set-password (update-user.ts:338),
/reset-password (password.ts:287), /email-otp/reset-password
(email-otp/routes.ts:953), /phone-number/reset-password
(phone-number/routes.ts:813), /admin/set-user-password (admin/routes.ts:1723).
Expected. Every endpoint that hands a caller-supplied password to the hasher
rejects it with PASSWORD_TOO_LONG when it exceeds maxPasswordLength, before
any database lookup or hash, the same way sign-up.ts:228-234 does. The OWASP
Password Storage Cheat Sheet requires the comparison function to have "a maximum
input length, to protect against denial of service attacks with very long
inputs", and better-auth's own docs present maxPasswordLength as the bound on
passwords, not on new passwords only.
What version of Better Auth are you using?
1.7.4, and main at 41b7dc1 (2026-09-17).
System info
Bun 1.4.2, Linux x64. Measurements below use `@better-auth/utils` 0.4.2 (the
version 1.7.4 pins), both builds.Which area(s) are affected? (Select all that apply)
Backend
Auth config (if applicable)
Defaults. `emailAndPassword.enabled: true`, no `password.hash`/`verify`
override, no `maxPasswordLength` override.Additional context
Measured cost with the default scrypt (N=16384, r=16, p=1), median of 15
verifyPassword calls:
| Input | node:crypto scrypt |
@noble/hashes scrypt |
|---|---|---|
| 13 chars | 38.1 ms | 65.6 ms |
| 128 chars | 38.0 ms | 67.1 ms |
| 1 MiB | 37.7 ms (×0.99) | 73.5 ms (×1.12) |
| 1 MiB of NFKC-expanding chars | 43.0 ms (×1.13) | 81.8 ms (×1.25) |
scrypt's memory-hard loop does not depend on input length, so with the default hasher this is not a practical amplification. The reasons to fix it anyway:
- The limit is a documented option and it is silently not applied on 11 of 18
password endpoints.
#8899closed on the assumption it was. ctx.context.passwordis user-replaceable (emailAndPassword.password), and the endpoints are the only place the bound can be applied uniformly for every hasher. better-auth does not bound request body size itself; whatever the host allows reaches the hasher./admin/create-userwrites a credential account with no length policy at all, so an over-long password can already exist. Once the verify-side endpoints check, such an account getsPASSWORD_TOO_LONGon sign-in. That is the same trade-offchange-passwordalready makes fornewPasswordand is worth a line in the changelog.
Source: better-auth/better-auth