lockout package counts authentication failures and temporarily locks an
account after a configurable threshold, checking the lock status before
the password KDF runs.
Import
Quick setup
lockout.Policy{} (the zero value) is itself a safe default: 5 failures / 15
minute window / 15 minute lock, composite username+IP key, 100,000-key memory
cap.
Wiring it into authentication
There are two integration paths. Pick one.GuardProvider (recommended)
There is no lock/unlock state machine here — Entry is a plain
{Failures, WindowStart, LockedUntil} struct and “locked” is derived on every
read by comparing LockedUntil to now. What matters is the call ordering: a
locked key is rejected before the delegate ever runs, so the password KDF
never executes against a locked account.
Check and RecordFailure are separate store operations, so there is an
accepted race: a burst of concurrent attempts on the same key can exceed
Threshold before the lock takes effect. Size Threshold with that slack in
mind.
GuardProvider wraps an authn.Provider (typically *authn.PasswordProvider)
and checks the lock before delegating, so a locked account never runs the
password KDF:
Authenticate: derive the key → Check (reject with
ErrLockedOut if locked, without calling the delegate) → delegate
Authenticate → on a credential-class failure (core.ErrBadCredentials)
RecordFailure; on success RecordSuccess. Any other delegate error (e.g. an
infra error) records nothing. A store error on Check fails open — the
request proceeds to the delegate — so a store outage cannot lock everyone out.
Publisher (event-based)
If you already wire core/event.Publisher into authn.Manager, use
lockout.Publisher instead — it counts failures from the same success/failure
events observability and auditing consume:
errors.Is(err, core.ErrBadCredentials))
increment the counter — a transient infra error never locks an account.
Composite key (DoS mitigation)
The default lockout key is username + client IP, not username alone. A username-only key lets an attacker lock any known account by submitting wrong passwords from anywhere; the composite key means the attacker must also control the client IP.WithGuardUsernameOnlyKey (guard) / WithUsernameOnlyKey (publisher) must be
paired with Policy.UsernameOnly: true so the key format matches on both
sides. Prefer the composite default; only opt out for a flow that explicitly
accepts the tradeoff.
Manual unlock
There’s no separate unlock call — clearing a key’s state is exactly what a successful login does. An admin endpoint resets a key the same way:Checking status directly
LockoutChecker.Check reports the current status without recording anything —
useful for a “why am I locked out” admin view:
err wraps core.ErrAccountLocked when status.Locked — the same sentinel
GuardProvider returns as lockout.ErrLockedOut, so existing exception
translation treats it as a 401, not a 500.
Distributed store (Redis or SQL)
lockout.Store (Get/Incr/Lock/Reset) is the backend contract. The
bundled MemoryStore is the in-process reference implementation. For
multi-instance deployments, github.com/thuongh2/go-security/go-security-sql
implements this interface over Postgres/MySQL (or any SQL flavor via its
pluggable Dialect — see go-security-sql); a separate
github.com/thuongh2/go-security-redis module documents the same
contract for Redis — see go-security-redis.
Store implementations must make Incr atomic (increment-within-window) and
Lock a compare-and-set of the expiry. The exported lockout.StoreConformance
test harness proves an adapter satisfies those invariants:
