Voyti

Voyti

User management, authentication & authorization

Lockout

Brute-force protection for the login and registration forms ships as a separate package. It listens to core's existing auth events to count failed attempts per IP address and delays further attempts, even ones with the correct credentials, with an exponentially growing wait that starts from the very first failure.

Prerequisite

Your application must have a PSR-16 Psr\SimpleCache\CacheInterface implementation configured and bound in your DI container. Any PSR-16 compliant cache works. See yiisoft/cache documentation for one option and its available backends.

Installation

Create an issue →

Storage

Failed-attempt counts are tracked in the cache. Each cache entry's key is a SHA-256 hash of the request's IP address, scoped separately for login and registration so the two counters never collide. Its value is a plain attempt count.

The cache entry's TTL is renewed on every failure to at least loginMinRetentionSeconds / registrationMinRetentionSeconds, creating a sliding window anchored to the most recent attempt: an attacker who keeps failing stays tracked, and the entry only expires once attempts stop for that long. Once the currently required delay grows past that minimum, the TTL instead tracks the delay itself, which can be much longer, so the count can't reset while the caller is still required to wait.

Configuration

// config/params.php
return [
    'yiirocks/voyti' => [
        'lockout' => [
            'loginMinRetentionSeconds' => 600,
        ],
    ],
];
loginMinRetentionSeconds int
900
Minimum time, in seconds, a login failure count is remembered for one IP, even when the currently required delay is smaller. Renewed on every new failure.
loginBaseDelaySeconds int
1
Wait enforced on the very first failed login attempt. Doubles on every further failure: 1s, 2s, 4s...
loginMaxDelaySeconds int
3600
Ceiling on the login delay. There is no cap on the attempt count itself: an attacker who keeps failing just keeps hitting this capped wait.
registrationMinRetentionSeconds int
60
Minimum time, in seconds, a registration failure count is remembered for one IP. Renewed on every new failure.
registrationBaseDelaySeconds int
1
Wait enforced on the very first failed registration attempt. Doubles on every further failure.
registrationMaxDelaySeconds int
600
Ceiling on the registration delay, lower than login's since registration abuse is lower stakes than an account-takeover attempt.

How it works

Listener Core event Behavior
RecordFailedLoginAttemptListenerFailedLoginEventRecords a failure against the request's IP.
BlockLockedOutLoginListenerBeforeLoginEventWhenever the IP has any recorded failures, delays the login by the computed backoff, even if the password just entered is correct.
RecordFailedRegistrationAttemptListenerRegisterFormValidationFailedEventRecords a failure against the request's IP.
BlockLockedOutRegistrationListenerBeforeRegisterEventWhenever the IP has any recorded failures, delays the registration by the computed backoff.

Both blocking listeners throw core's ActionPreventedException, the same cancellation mechanism core itself uses for BeforeLoginEvent and BeforeRegisterEvent: the dispatching controller catches it and surfaces a translated error on the form instead of a raw exception. The translated message carries the computed wait, in seconds, so the UI can tell the user exactly how long to wait before retrying.

Design decisions

IP-scoped, not account-scoped: Counters are scoped by IP address. Account scoping would let an attacker lock a legitimate user out of their own account just by deliberately failing that user's login from elsewhere, an easy denial-of-service. IP scoping avoids this while still stopping a single attacker hammering one account or many.

Counts failures, not all traffic: This is a lockout, not a general-purpose rate limiter. Only failed attempts increment the counter and trigger a delay. Traffic to /login or /register that never fails is never delayed.

Progressive delay, not a hard deny: Every failure doubles the wait rather than denying the attempt outright, starting from a barely noticeable second on the very first failure. There is no attempt count that locks an IP out for good; the delay just keeps growing, capped at loginMaxDelaySeconds / registrationMaxDelaySeconds. This follows OWASP's and NIST's guidance against a lockout control that itself becomes a denial-of-service against the legitimate user: a real attacker just waits out the capped delay indefinitely, while a locked-out legitimate user is never permanently shut out.

Minimum retention is not the delay: loginMinRetentionSeconds / registrationMinRetentionSeconds don't gate how long you wait to retry. That's driven entirely by baseDelaySeconds / maxDelaySeconds. They only set a floor on how long a failed-attempt count is remembered when the currently required delay is smaller, so a single mistyped password isn't instantly forgotten while an attacker who stops trying still cools down eventually.