Laravel Fraud Detection: Middleware, Without an SDK

You do not need the PHP SDK: a production-shaped Laravel integration on the HTTP client, with a service class, middleware by strictness and Http::fake() tests.

A blacksmith's hands hammering a glowing iron bar on an anvil
Photo: Blacksmithing by Scott Sandars, CC BY 2.0, cropped. Via Wikimedia Commons.
On this page
  1. Step 1: Add the script to the page with your form
  2. Step 2: Send the check from your server
  3. Middleware, parameterised by strictness
  4. Step 3: Submit your form once
  5. Testing it without hitting the network

The API is a single POST, and Laravel's HTTP client handles it in a few lines. This is the integration written the way a Laravel application would actually structure it: a config entry, a service class, middleware with a strictness parameter, and the Blade side. It follows the three steps the Maskbreak console lists (add the script to the page with your form, send the check from your server, submit your form once) and starts in watch mode, where every check is logged and nothing is refused.

There is an official PHP package (sentinelsup/sdk on Packagist). This guide uses Laravel's own HTTP client instead, so Http::fake() covers the tests and the call goes out on every request, including the ones that arrive without a token: those are the checks the console needs to tell you which half of the integration is missing.

Step 1: Add the script to the page with your form

Load the script in your layout and put class="monocle-enriched" on the <form> element itself, never on an input:

blade
{{-- resources/views/layouts/app.blade.php --}}
<script async src="https://maskbreak.com/assets/sentinel.js"></script>

{{-- resources/views/auth/register.blade.php --}}
<form method="POST" action="/register" class="monocle-enriched">
    @csrf
    <input type="email" name="email" required>
    <input type="password" name="password" required>
    <button type="submit">Create account</button>
</form>

The script adds three hidden fields to the form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). If a Livewire, Inertia or Vue frontend posts with JavaScript instead, call await window.Sentinel.collect() before the request (check that window.Sentinel exists, and submit anyway when it does not) and send its token, fingerprintEventId and tz; the middleware below reads both spellings.

If you send a Content-Security-Policy (spatie/laravel-csp, or your web server), add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.

Step 2: Send the check from your server

Start with a config entry so the key is never inlined. Put your live key in .env as MASKBREAK_API_KEY; MASKBREAK_MODE stays unset (watch) for now.

php
// config/services.php
'maskbreak' => [
    'key' => env('MASKBREAK_API_KEY'),
    'mode' => env('MASKBREAK_MODE', 'watch'),   // 'watch' or 'enforce'
    'timeout' => 3,
],

Then a small service class you can bind and fake in tests. It maps the form's names to the API's and calls even when a field is missing. It never throws.

php
<?php
// app/Services/Maskbreak.php
namespace App\Services;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class Maskbreak
{
    /** Returns the verdict, or null when there is none. Never throws. */
    public function check(Request $request): ?array
    {
        $payload = array_filter([
            'token' => (string) ($request->input('monocle') ?? $request->input('token') ?? ''),
            'fingerprintEventId' => $request->input('sentinel_fp') ?? $request->input('fingerprintEventId'),
            'tz' => $request->input('sentinel_tz') ?? $request->input('tz'),
            'email' => is_string($request->input('email')) ? $request->input('email') : null,
            'accountId' => $request->user() ? (string) $request->user()->getAuthIdentifier() : null,
        ], fn ($v) => $v !== null);

        $result = null;
        try {
            $response = Http::withToken((string) config('services.maskbreak.key'))
                ->timeout(config('services.maskbreak.timeout'))
                ->withHeaders(['Content-Type' => 'application/json'])
                ->acceptJson()
                ->post('https://maskbreak.com/v1/evaluate', $payload);

            if ($response->successful()) {
                $result = $response->json();
            } else {
                Log::warning('maskbreak http error', ['status' => $response->status()]);
            }
        } catch (\Throwable $e) {
            // Timeout, DNS, connection reset: no verdict, never an error page.
            Log::warning('maskbreak unavailable', ['error' => get_class($e)]);
        }

        // One line per check. Never log the key, the token or form data.
        Log::info('maskbreak', [
            'mode' => config('services.maskbreak.mode'),
            'decision' => $result['decision'] ?? null,
            'reasons' => $result['reasons'] ?? null,
            'device_evidence' => $result['device_evidence'] ?? null,
            'degraded' => is_array($result) ? ! empty($result['degraded']) : null,
        ]);

        return is_array($result) ? $result : null;
    }

    /** A complete, production allow. */
    public static function cleanAllow(?array $v): bool
    {
        return $v !== null && ($v['decision'] ?? null) === 'allow'
            && ($v['device_evidence'] ?? null) === 'ok'
            && empty($v['degraded']) && empty($v['test']);
    }
}
Try it

Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.

Get an API key

Middleware, parameterised by strictness

Laravel middleware parameters make the per-action threshold natural: maskbreak:strict on the routes that deserve it, maskbreak elsewhere. In watch mode it records the verdict and always continues.

php
<?php
// app/Http/Middleware/MaskbreakGuard.php
namespace App\Http\Middleware;

use App\Services\Maskbreak;
use Closure;
use Illuminate\Http\Request;

class MaskbreakGuard
{
    public function __construct(private Maskbreak $maskbreak) {}

    public function handle(Request $request, Closure $next, string $level = 'normal')
    {
        $verdict = $this->maskbreak->check($request);
        $request->attributes->set('maskbreak', $verdict);

        if (config('services.maskbreak.mode') !== 'enforce') {
            return $next($request);              // watch: log only
        }
        // Route on decision, never on the legacy isSuspicious flag.
        if (($verdict['decision'] ?? null) === 'block') {
            return response()->json(['error' => 'Request declined'], 403);
        }
        if ($level === 'strict' && ! Maskbreak::cleanAllow($verdict)) {
            return response()->json(['error' => 'Additional verification required'], 409);
        }
        return $next($request);
    }
}

Register the alias, then apply it where the cost of being wrong justifies it.

php
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias(['maskbreak' => \App\Http\Middleware\MaskbreakGuard::class]);
})

// routes/web.php
Route::post('/register', RegisterController::class)
    ->middleware('maskbreak');                    // refuse only a block

Route::post('/promo/redeem', RedeemController::class)
    ->middleware(['auth', 'maskbreak:strict']);   // strictest gate on the site

Route::post('/account/password', PasswordController::class)
    ->middleware(['auth', 'maskbreak:strict']);

In a controller, $request->attributes->get('maskbreak') gives you the verdict. In enforce mode, send a review to a step-up (email verification, a second factor) rather than refusing it: a plain VPN lands there, and VPN users are customers.

Step 3: Submit your form once

Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and storage/logs (or your log channel) shows one maskbreak line. degraded: true means no network token arrived (the script was blocked, or the class is missing or sits on an input); device_evidence: "not_sent" means no browser check id arrived; "ok" with degraded false is a complete check.

After a few days of comparing those lines with Events, set MASKBREAK_MODE=enforce, run php artisan config:cache if you cache config, and deploy. A strict route then holds anything that is not a clean, complete, production allow, including a fixed test token such as test_clean, which comes back with test: true.

Testing it without hitting the network

Because the service uses Laravel's HTTP client, Http::fake() covers the whole thing, including the failure paths, which are the ones worth asserting.

php
use Illuminate\Support\Facades\Http;

public function test_blocked_signup_is_refused_in_enforce_mode(): void
{
    config(['services.maskbreak.mode' => 'enforce']);
    Http::fake(['*/v1/evaluate' => Http::response(['decision' => 'block', 'risk_score' => 95, 'reasons' => ['proxy_detected']], 200)]);

    $this->post('/register', ['email' => 'a@b.c', 'password' => 'x', 'monocle' => 'tok', 'sentinel_fp' => 'fp'])
         ->assertStatus(403);
}

public function test_signup_survives_a_detection_outage(): void
{
    config(['services.maskbreak.mode' => 'enforce']);
    Http::fake(['*/v1/evaluate' => Http::response(null, 500)]);

    // Fail open: the signup must still succeed.
    $this->post('/register', ['email' => 'a@b.c', 'password' => 'x'])
         ->assertSuccessful();
}

public function test_the_check_goes_out_without_a_token(): void
{
    Http::fake(['*/v1/evaluate' => Http::response(['decision' => 'allow', 'degraded' => true], 200)]);

    $this->post('/register', ['email' => 'a@b.c', 'password' => 'x']);

    Http::assertSent(fn ($request) => str_contains($request->url(), '/v1/evaluate'));
}

The last test is the one that keeps watch mode honest: a request with no token still reaches Maskbreak, so the console can show you that the browser half is missing instead of waiting for a check that never comes.

Questions people ask

Is there an official PHP or Laravel SDK for Maskbreak?
Yes: sentinelsup/sdk on Packagist, alongside the Node and Python packages. This guide uses Laravel's own HTTP client instead, for two reasons: Http::fake() covers the tests, and the check goes out on every request, including one without a token (the SDK helpers need one), which is what lets the console show which half of the integration is missing.
Where should the Maskbreak check go in a Laravel app?
In middleware, parameterised by strictness, applied per route. Laravel middleware parameters map naturally onto action risk: plain maskbreak on registration, so enforce mode refuses only a block, and maskbreak:strict on promotion redemption and password changes, where anything short of a clean, complete allow is held. Putting it in a controller works but tends to get duplicated; putting it globally applies one threshold to actions whose costs are not comparable.
How do I test the integration without calling the API?
Use Http::fake() with a pattern matching */v1/evaluate. Because the service class goes through Laravel's HTTP client rather than raw cURL, faking covers both the success paths and the failure paths. The failure cases are the ones actually worth asserting: a 500 or a timeout should leave the signup succeeding, since failing closed turns a detection outage into a signup outage.
What should happen when the request has no token?
Send the check anyway and let the request through on ordinary routes. An ad blocker or a content-security-policy problem can stop the script for real visitors; Maskbreak answers the check marked degraded, and the console shows that the network half is missing. On a strict route in enforce mode, hold the action for verification instead of refusing it outright.

Paste it in, then watch the verdicts

The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.

Get started freeRead the API docs