NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Auth for Laravel

Activity & risk

Throttling

Ten buckets, checked before any credential work. Identifiers are HMAC’d, so no raw email lands in a cache key. Exceeding one answers too_many_attempts (429, Retry-After), records the attempt and fires LoginThrottled:

'throttle' => [                          // max attempts / decay seconds
    'login' => ['max' => 5, 'decay' => 60],                 // identifier + IP
    'login_ip' => ['max' => 50, 'decay' => 600],            // IP, any identifier
    'login_account' => ['max' => 20, 'decay' => 3_600],     // identifier, any IP
    'email_request' => ['max' => 3, 'decay' => 600],        // link/OTP/reset/resend per identifier+IP
    'email_request_ip' => ['max' => 20, 'decay' => 600],
    'email_request_account' => ['max' => 10, 'decay' => 3_600],
    'refresh' => ['max' => 30, 'decay' => 60],              // per IP
    'registration' => ['max' => 5, 'decay' => 3_600],       // per IP
    'verification' => ['max' => 5, 'decay' => 600],         // verify attempts per identifier+IP
    'reauthentication' => ['max' => 5, 'decay' => 300],     // per session
],

'lockout' => [
    'enabled' => false,
    'threshold' => 10,
    'duration' => 900,
    'reset_unlocks' => true,
],

Lockout (opt-in)

Off by default: throttling is DoS-resistant, while a hard lock lets an attacker lock a victim out. When enabled, reaching threshold sets locked_until for duration seconds, fires AccountLocked and mails the owner. A locked identifier answers too_many_attempts (never account_locked — that would confirm a password), a lock never revokes existing sessions, and reset_unlocks lifts it on a password reset.

Login activity

One row per attempt: guard, type, outcome, method, reason, account, identifier (plain, hashed or omitted), IP, user agent, device fingerprint, session id and a new-device flag. Refresh is recorded only on failure. GET activity lists an account’s own rows without the identifier or fingerprint:

use RoundlyConsulting\Auth\Enums\ActivityOutcome;
use RoundlyConsulting\Auth\Events\LoginActivityRecorded;
use RoundlyConsulting\Auth\Facades\Authentication;
use RoundlyConsulting\Auth\Support\Models;

Authentication::guard('users')->activity($user, 20);   // paginated, newest first

LoginActivity::query()->where('outcome', ActivityOutcome::Throttled->value)->count();

// Enrich rows with a location from your own lookup
Event::listen(function (LoginActivityRecorded $event): void {
    $activity = Models::loginActivities()->find($event->activityId);
    [$country, $city] = GeoIp::lookup($activity->ip_address);   // your own lookup
    $activity->enrich($country, $city);
});

New devices

The fingerprint is an HMAC of the guard and the X-Device-Id header, or of the user agent when the header is absent. A device is known only after a completed login with that fingerprint inside retention — a reset or sign-in link requested from the device does not count, nor does a re-authentication made with a (possibly stolen) access token. The first ever login is not new (skip_first_login). A new device fires NewDeviceDetected and mails the owner once the login completes.

Risk

Plug in an assessor. It receives the guard, account, method, session context and new-device flag after the first factor and returns a level (low, elevated, high) with signals:

use RoundlyConsulting\Auth\Contracts\AssessesLoginRisk;
use RoundlyConsulting\Auth\DataTransferObjects\LoginRiskContext;
use RoundlyConsulting\Auth\DataTransferObjects\RiskAssessment;
use RoundlyConsulting\Auth\Enums\RiskLevel;

final class NewDeviceRisk implements AssessesLoginRisk
{
    public function assess(LoginRiskContext $context): RiskAssessment
    {
        return $context->newDevice
            ? new RiskAssessment(RiskLevel::Elevated, ['new_device'])
            : new RiskAssessment();
    }
}
'activity' => [
    'enabled' => true,
    'store_identifier' => 'plain',       // plain|hash|none
    'retention_days' => 90,
    'new_device' => [
        'enabled' => true,
        'header' => 'X-Device-Id',
        'skip_first_login' => true,
    ],
],

'risk' => [
    'assessor' => NewDeviceRisk::class,  // class-string<AssessesLoginRisk>|null
    'reactions' => ['elevated' => 'notify', 'high' => 'require_second_factor'],  // allow|notify|require_second_factor|deny
    'deny_response' => 'uniform',        // uniform|explicit
],

Reactions per level: allow; notify (event and mail after completion); require_second_factor (a verification step is prepended for every login method — also email logins with after_email_login off — and the login is denied when the account has no factor to step up with); deny (SuspiciousLoginDetected, owner mailed, a uniform invalid_credentials — or login_denied with deny_response = explicit).

Pruning

authentication:prune force-deletes challenges and one-time tokens a day past expiry, and finished invitations and activity past each guard’s retention. The four models are also Prunable, so model:prune works too:

// routes/console.php
Schedule::command('authentication:prune')->daily();

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.