Account takeover prevention for Laravel applications, powered by srvAudit ATP.

v0.1.0 2026-07-27 21:11 UTC

This package is not auto-updated.

Last update: 2026-07-28 21:31:41 UTC


README

Account takeover prevention for Laravel applications, powered by srvAudit ATP.

Credential stuffing, password spraying and brute force are spread thin enough to sit under every per-address limit you have. This package puts the decision on your authentication path instead, where per-account history and device provenance actually live.

Installation

composer require srvaudit/atp

Add a site in your srvAudit portal under Threat Protection to get a site id and a server key, then:

ATP_URL=https://atp.srvaudit.com
ATP_SITE_ID=site_01jq8xkz3n7v9wq2m4h6bcdefg
ATP_SERVER_KEY=satp_sk_...

Without both of those the package is inert — every call short-circuits to "allow" and nothing is sent anywhere, so it is safe to install before you have credentials.

Publish the config only if you need to change something:

php artisan vendor:publish --tag=atp-config

Fail-open, by construction

Every method that touches the network is wrapped so that a timeout, a refused connection, a 500, or any other exception resolves to allow. Every call the middleware makes on your login path — the breach lookup included — runs on a 50ms connect and 150ms read budget.

The only lookup that gets a longer budget is the one behind the NotBreached validation rule, which runs when somebody is setting a password rather than using one. Tune it with ATP_PWNED_TIMEOUT; the login-path budget is ATP_PWNED_LOGIN_TIMEOUT and should stay tight.

Losing protection for the duration of an incident is recoverable. Turning an ATP problem into a login outage is not. Please do not remove the try/catch blocks or raise the timeouts without understanding you are adding latency to every login.

Usage

The two-line integration

Put the middleware on your login route. It runs the pre-auth decision, and Laravel's own authentication events report the outcome for you.

Route::post('/login', [AuthenticatedSessionController::class, 'store'])
    ->middleware('atp.protect');

That is the whole integration for a standard application. A blocked attempt comes back looking exactly like a wrong password — telling an attacker they were blocked tells them what to change.

Calling it yourself

use SrvAudit\Atp\Facades\Atp;
use SrvAudit\Atp\Enums\AuthResult;

public function store(LoginRequest $request)
{
    $context = Atp::contextFrom($request);

    if (! Atp::allows($context)) {
        return back()->withErrors(['email' => __('auth.failed')]);
    }

    if (! Auth::attempt($request->credentials())) {
        Atp::report($context, AuthResult::Failure);

        return back()->withErrors(['email' => __('auth.failed')]);
    }

    Atp::report($context, AuthResult::Success);

    return redirect()->intended();
}

Set ATP_LISTEN_TO_AUTH_EVENTS=false when reporting by hand, or the outcome will be reported twice.

Two-factor authentication

The event listener cannot tell whether a Login event is the end of the story or the first of two factors. If you use 2FA, turn the listener off and report AuthResult::MfaPending yourself when credentials were correct but a challenge is outstanding. A pending challenge is not a failed credential, and counting it as one penalises your real users.

Session provenance

Real clients do things before they reach a login. Marking those steps lets the service tell a real session from traffic that jumped straight to the login form.

Route::get('/api/config', function (Request $request) {
    Atp::observe($request->header('x-device-id'), 'app_config');

    return AppConfig::current();
});

Roll this out in order: instrument first, watch the signal while your site is still observing, and only then let it contribute to a block. Enforcing before your clients are instrumented would fire it on everybody.

Breached passwords

Backed by a mirror of the Have I Been Pwned corpus, refreshed monthly.

The lookup uses k-anonymity: the password is hashed locally and only the first five characters of that digest are sent. The service returns every suffix it holds under that prefix and the comparison happens in your application. The password never leaves your process, and the service cannot tell which entry you were asking about.

use SrvAudit\Atp\Rules\NotBreached;

$request->validate([
    'password' => ['required', Password::defaults(), new NotBreached],
]);

Use this on registration and password changes, not on login — rejecting an existing user's password at sign-in locks them out of their own account with no way back in. A lookup that cannot be performed passes, because a breach service being down is not a reason to stop somebody setting a password.

Directly:

Atp::passwordBreached($password);    // true, false, or null when it could not check
Atp::passwordPrevalence($password);  // appearance count, or null

null means unknown, not clean. Never treat it as a pass.

Privacy

  • Passwords are never sent. There is no field for one in any request.
  • Account identifiers are optional. Set ATP_HASH_USERNAME=true and the package sends a keyed digest instead of the address. Keep ATP_USERNAME_PEPPER stable — changing it resets per-account counters, because old and new digests are different accounts as far as the service is concerned.
  • Breach lookups are k-anonymous. Five characters of a digest, nothing more.

Configuration

KeyDefaultPurpose
atp.urlhttps://atp.srvaudit.comService base URL
atp.siteSite id from the portal
atp.server_keyServer key from the portal
atp.connect_timeout0.05Connect budget, seconds
atp.timeout0.15Total request budget, seconds
atp.listen_to_auth_eventstrueReport outcomes from Laravel auth events
atp.hash_usernamefalseSend a digest instead of the identifier
atp.pwned.enabledtrueBreached password checking
atp.pwned.threshold1Appearances before a password counts as breached
atp.pwned.cache_ttl86400Range cache lifetime, seconds
atp.pwned.timeout1.5Budget for a lookup off the login path
atp.pwned.login_timeout0.15Budget for the lookup the middleware makes
atp.block.status422Status returned for a blocked attempt

Testing

composer install
vendor/bin/phpunit

License

MIT.