The 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. 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:
Ordering inside 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:
Only credential-class failures (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: