Skip to content

AuthAttemptLimiter

Viames Marino edited this page May 10, 2026 · 1 revision

Pair framework: AuthAttemptLimiter

Pair\Core\AuthAttemptLimiter rate-limits password authentication attempts through RateLimiter.

It is used by User during normal web login and token-based mobile login, so both flows share the same protection and the same generic failure message.

When to use

Use AuthAttemptLimiter when a custom authentication flow checks local Pair credentials and should apply the same login-attempt limits as the framework.

The limiter protects two buckets for each attempt:

  • the normalized login identifier, such as email or username
  • the client IP address

Both keys are hashed before reaching the storage-backed rate limiter. This slows down brute-force attempts against one account and broad attempts from one address without exposing raw identifiers in limiter storage.

Configuration

The default login limiter reads:

PAIR_AUTH_RATE_LIMIT_ENABLED=true
PAIR_AUTH_RATE_LIMIT_MAX_ATTEMPTS=10
PAIR_AUTH_RATE_LIMIT_DECAY_SECONDS=900

Invalid or non-positive numeric values fall back to safe minimums.

Main methods

login(): AuthAttemptLimiter

Builds the default limiter for password login attempts from Env.

$limiter = \Pair\Core\AuthAttemptLimiter::login();

__construct(bool $enabled = true, int $maxAttempts = 10, int $decaySeconds = 900, ?RateLimiter $limiter = null)

Creates a limiter with explicit settings. Supplying a RateLimiter instance is useful in tests or in a custom integration that already owns limiter storage.

$limiter = new \Pair\Core\AuthAttemptLimiter(true, 5, 300);

attempt(string $scope, string $identifier, ?string $ipAddress = null): RateLimitResult

Records one authentication attempt and returns the stricter decision between the IP bucket and the identifier bucket.

$attempt = $limiter->attempt('login', $email, $_SERVER['REMOTE_ADDR'] ?? null);

if (!$attempt->allowed) {
    return \Pair\Api\ApiResponse::errorResponse('AUTHENTICATION_FAILED');
}

The $scope lets applications keep separate buckets for different auth surfaces, such as login, admin-login, or password-reset.

clear(string $scope, string $identifier, ?string $ipAddress = null): void

Clears both buckets after a successful authentication challenge.

$limiter->clear('login', $email, $_SERVER['REMOTE_ADDR'] ?? null);

Framework behavior

User calls AuthAttemptLimiter::login() before validating credentials.

When the attempt is blocked, Pair:

  • writes the failed-login audit record
  • returns the normal generic authentication failure message
  • does not reveal whether the identifier exists

When login succeeds, Pair clears the matching limiter buckets.

Common pitfalls

  • Using different scopes for attempt() and clear().
  • Keying only by IP and missing distributed attempts against one account.
  • Returning a distinct "too many attempts" message that reveals account existence.
  • Forgetting that file-backed limits are local to the current node when Redis is not configured.

See also: User, RateLimiter, Configuration file, Env, API.

Clone this wiki locally