---
title: "Activity & risk — Auth for Laravel | Roundly"
description: "Throttling, opt-in lockout, the login-activity log, new-device detection, pluggable risk assessment and pruning."
url: https://roundly-consulting.com/open-source/docs/auth-for-laravel/activity-risk
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Auth for Laravel](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)

# 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:

```php
'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 — the lock is a conditional claim, so concurrent failures lock and notify once. A locked account’s password login answers exactly like a wrong password (invalid\_credentials, the attempt counted against the login throttles) — never account\_locked or a lock-length Retry-After, which would confirm the password or that the account exists. A lock written while a login was hashing refuses that login the same way, a correct password included; failures while locked are never counted, even when checked against an account loaded before the lock. 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:

```php
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:

```php
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();
    }
}
```

```php
'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 (SuspiciousLoginDetected, and UnusualSignInNotification once the sign-in completes); 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, SignInBlockedNotification to the owner, 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:

```php
// 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)
