You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(auth): make the second factor obey the operator's lockout policy (#3690) (#3706)
* fix(auth): make the second factor obey the operator's lockout policy (#3690)
`twoFactor()` was constructed with a schema and nothing else, so better-auth's
built-in `accountLockout` defaults (on, 10 attempts, 15 minutes) governed
two-factor verification regardless of what the admin configured. An operator who
tightened the account lockout threshold to 3 got a password stage that locked at
3 and a second factor that still locked at 10 — the stricter door was the looser
one, and nothing in the UI said so.
Project `lockoutThreshold` / `lockoutDurationMinutes` onto better-auth's own
`accountLockout` shape rather than adding a parallel `two_factor_lockout_*` pair:
one policy, one mental model, and an upstream addition arrives as a new option
instead of a conflict. The projection runs through `applyConfigPatch`, which
resets the cached instance, so a settings change lands without a restart.
Threshold `0` is deliberately not forwarded as `enabled: false`. It is the
password stage's "off", and a deployment may leave that stage unlocked because
rate limiting or an IdP covers it; the second factor is the last check before a
session is issued, so it keeps better-auth's default instead of being switched
off by a setting that never mentioned it. The threshold field also stops hiding
behind `email_password_enabled` — two-factor verification exists in passwordless
deployments, where it was previously unreachable.
The dogfood test now runs under an explicit policy (7 attempts / 40 minutes),
chosen to differ from every default, and derives its assertions from it — pinned
to better-auth's defaults it could not tell "configured to 10" from
"configuration ignored". Verified by removing the wiring: the lock never engages
and both budget tests fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H
* fix(auth): make Unlock Account release the second factor too (#3690)
`unlockUser` reset only `sys_user.failed_login_count` / `locked_until`. Sign-in
can lock at either stage and the two counters are independent, so a user locked
at the second factor had no admin escape hatch at all — the only way out was
waiting the duration out.
That was survivable while the second-factor lock needed better-auth's 10
failures. Now that the threshold is operator-configurable and 3 is a reasonable
choice, it is a lock admins will hit routinely, so the action has to cover it.
The second-factor clear is best-effort and runs after the primary write: an
account with no enrolment, or an environment where 2FA was never switched on,
must still get the unlock the admin asked for.
Covered by two unit tests (both enrolments cleared; a failing lookup does not
sink the unlock) and an end-to-end one that re-locks the account, unlocks it
through `/auth/admin/unlock-user`, and signs in again. The end-to-end test takes
its admin session BEFORE re-locking — after the lock there is no way to obtain
one, which is the whole reason the escape hatch has to exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H
---------
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: content/docs/permissions/authentication.mdx
+15-2Lines changed: 15 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -455,10 +455,23 @@ code that reads it). Changes take effect immediately; no restart is required.
455
455
456
456
| Setting | Effect |
457
457
|---|---|
458
-
|`lockout_threshold`| Lock an account after this many consecutive failed sign-ins. |
459
-
|`lockout_duration_minutes`| How long a lockout lasts. Admins can clear it early with the **Unlock** action on the user record. |
458
+
|`lockout_threshold`| Lock an account after this many consecutive failed sign-in attempts. Counts **both** stages: wrong passwords and wrong two-factor codes. |
459
+
|`lockout_duration_minutes`| How long a lockout lasts, at either stage. Admins can clear it early with the **Unlock** action on the user record, which releases both stages. |
460
460
|`rate_limit_max` / `rate_limit_window_seconds`| Per-IP request throttle on auth endpoints. |
461
461
462
+
The two stages keep **separate counters** — `sys_user.failed_login_count` for the
463
+
password check, `sys_two_factor.failed_verification_count` for the second factor —
464
+
so a locked password stage and a locked second factor are independent states. Each
465
+
counter resets on a success at its own stage, and the admin **Unlock Account**
466
+
action clears both at once.
467
+
468
+
Setting `lockout_threshold` to `0` disables the **password-stage** lockout only.
469
+
Two-factor verification keeps a built-in limit of 10 attempts per 15 minutes,
470
+
because it is the last check before a session is issued. Two-factor verification
471
+
additionally caps attempts at 5 per challenge, which is not configurable: a
472
+
threshold above 5 simply forces the attacker to restart the sign-in flow more
0 commit comments