On this page
Next.js gives you three plausible places to run a fraud check: middleware.ts, a Route Handler, or a Server Action. Only two of them work. This walks through a real integration, including the mistake that costs people a week.
The short version: the check needs evidence collected in the browser, so it cannot live in Edge middleware that runs before your form is submitted. Put it where the action happens, and start in watch mode, where every check is logged and nothing is refused.
Why middleware is the wrong place
Edge middleware runs on every matching request, before your route code, with access to headers and cookies but not to a form body. That is fine for coarse routing decisions and wrong for fraud detection, because the signals that matter are collected client-side and submitted with the request.
The device layer in particular (automation, emulators, fake browsers, tampering) is only available once a browser has run the collector. Middleware sees the request too early for any of that to exist.
There is also a cost argument. Middleware runs on every request including static assets and prefetches, so putting an API call there spends your allowance on traffic that was never a decision point.
Step 1: Add the script to the page with your form
Load the script once in your root layout. It runs on every page, but it only starts the browser check where a form is used.
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://maskbreak.com/assets/sentinel.js" strategy="afterInteractive" />
</body>
</html>
);
}
A Next.js form is rendered by React, usually after the script has run, so do not rely on hidden fields appearing in it. Ask the script for the evidence when the visitor submits: Sentinel.collect() resolves to { token, fingerprintEventId, tz } and waits at most five seconds for the browser check. Check that window.Sentinel exists, and never hold the form back when it does not (a content blocker or a strict CSP removes it for real visitors).
// app/signup/signup-form.tsx
'use client';
type Evidence = { token: string | null; fingerprintEventId: string | null; tz: string | null };
declare global {
interface Window { Sentinel?: { collect(opts?: { timeout?: number }): Promise<Evidence> } }
}
export function SignupForm() {
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const evidence = window.Sentinel
? await window.Sentinel.collect().catch(() => null)
: null; // script missing or blocked: submit anyway
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: (form.elements.namedItem('email') as HTMLInputElement).value,
token: evidence?.token ?? null,
fingerprintEventId: evidence?.fingerprintEventId ?? null,
tz: evidence?.tz ?? null,
}),
});
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<button type="submit">Create account</button>
</form>
);
}
If your app sets a Content-Security-Policy (in next.config.js headers or in middleware with a nonce), add Maskbreak's hosts to it, or the browser blocks the script:
script-src https://maskbreak.com https://mcl.maskbreak.com https://*.mcl.maskbreak.com https://mesu.maskbreak.com 'wasm-unsafe-eval'
connect-src https://mcl.maskbreak.com https://*.mcl.maskbreak.com wss://mcl.maskbreak.com wss://*.mcl.maskbreak.com https://mesu.maskbreak.com
worker-src https://mesu.maskbreak.com blob:
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 keyStep 2: Send the check from your server
One server-only module makes the call. It sends whatever evidence arrived, including none: in watch mode a check without a token or a browser check id still has to reach Maskbreak, because that is how the console tells you which half is missing. It never throws, and it logs one line per check.
// lib/maskbreak.ts
import 'server-only'; // npm install server-only: the build fails if client code imports this file
export const MODE = process.env.MASKBREAK_MODE ?? 'watch'; // 'watch' | 'enforce'
export type Verdict = {
decision: 'allow' | 'review' | 'block';
risk_score: number;
reasons: string[];
device_evidence?: 'ok' | 'not_sent' | 'unavailable';
degraded?: boolean;
test?: boolean;
};
type Input = {
token?: string | null;
fingerprintEventId?: string | null;
tz?: string | null;
email?: string;
accountId?: string;
};
// Returns the verdict, or null when there is none. Never throws.
export async function checkVisitor(input: Input): Promise<Verdict | null> {
let result: Verdict | null = null;
try {
const res = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MASKBREAK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: input.token || '',
fingerprintEventId: input.fingerprintEventId || undefined,
tz: input.tz || undefined,
email: input.email,
accountId: input.accountId,
}),
signal: AbortSignal.timeout(3000),
cache: 'no-store',
});
if (res.ok) result = await res.json();
else console.warn('[maskbreak] HTTP', res.status); // 401: the key, 400: the body
} catch (err) {
console.warn('[maskbreak] unavailable:', (err as Error).name);
}
// One line per check. Never log the key, the token or form data.
console.log('[maskbreak]', JSON.stringify({
mode: MODE,
decision: result?.decision ?? null,
reasons: result?.reasons ?? null,
device_evidence: result?.device_evidence ?? null,
degraded: result ? !!result.degraded : null,
}));
return result;
}
The Route Handler reads the JSON body, runs the check, and in watch mode always continues:
// app/api/signup/route.ts
import { NextResponse } from 'next/server';
import { MODE, checkVisitor } from '@/lib/maskbreak';
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const email = typeof body.email === 'string' ? body.email : undefined;
const verdict = await checkVisitor({
token: body.token,
fingerprintEventId: body.fingerprintEventId,
tz: body.tz,
email, // adds email.disposable; checked transiently
});
if (MODE === 'enforce' && verdict?.decision === 'block') {
return NextResponse.json({ error: 'Signup unavailable' }, { status: 403 });
}
// Route on decision, never on the legacy isSuspicious flag.
const stepUp = MODE === 'enforce' && verdict?.decision === 'review';
await createAccount({ email, requiresEmailVerification: stepUp });
return NextResponse.json({ ok: true, stepUp });
}
Set MASKBREAK_API_KEY to your live key in your host's environment variables (for Vercel, the Production environment), never with a NEXT_PUBLIC_ prefix, which would ship it to every browser. Then redeploy.
Server Actions work too
With a Server Action the evidence still has to come from the browser, and the same timing problem applies: a form that React renders after the page has loaded does not receive the hidden fields. Make the form's action a client function that adds the evidence to the FormData, then calls the Server Action:
// app/signup/action-form.tsx
'use client';
import { signup } from './actions';
export function SignupActionForm() {
async function submit(formData: FormData) {
const evidence = window.Sentinel
? await window.Sentinel.collect().catch(() => null)
: null;
if (evidence?.token) formData.set('monocle', evidence.token);
if (evidence?.fingerprintEventId) formData.set('sentinel_fp', evidence.fingerprintEventId);
if (evidence?.tz) formData.set('sentinel_tz', evidence.tz);
await signup(formData);
}
return (
<form action={submit}>
<input name="email" type="email" required />
<button type="submit">Create account</button>
</form>
);
}
// app/signup/actions.ts
'use server';
import { MODE, checkVisitor } from '@/lib/maskbreak';
export async function signup(formData: FormData) {
const email = formData.get('email')?.toString();
const verdict = await checkVisitor({
token: formData.get('monocle')?.toString(),
fingerprintEventId: formData.get('sentinel_fp')?.toString(),
tz: formData.get('sentinel_tz')?.toString(),
email,
});
if (MODE === 'enforce' && verdict?.decision === 'block') {
return { error: 'Signup unavailable' };
}
// ...create the account; step up on review in enforce mode
}
Step 3: Submit your form once
Open the deployed 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 your server logs one [maskbreak] line. degraded: true means no network token arrived (the script was blocked or collect() was not called); device_evidence: "not_sent" means no browser check id arrived; "ok" with degraded: false is a complete check.
Switching to enforce
After a few days of comparing the log lines with Events, set MASKBREAK_MODE=enforce and redeploy. The code above then refuses a block and steps up a review. For an irreversible action, continue only on a clean, complete, production allow: decision === 'allow', device_evidence === 'ok', and neither degraded nor test set, because a fixed test token such as test_clean must never pass a production gate.
Four things that bite people
- Gating on
isSuspicious. It is a legacy flag: your dashboard rules, your IP and visitor pins and the disposable-email check never change it. Route ondecision. - Forgetting
fingerprintEventId. Without it the response has nodeviceblock at all, so automation and fake-browser detection silently never run and you conclude the product does not work. - Skipping the call when a field is missing. Send it anyway. A check with no token is answered (marked
degraded), and it is the only way the console can show you that the browser half is broken. - Blocking on
review.reviewexists so you can step up, with email verification or a second factor, rather than refuse. Refuse onblock.
Where to put the strictest rule
Signup is the obvious place to start and rarely where the money is. Promotion and referral redemption is where losses concentrate, and it is the action with essentially no legitimate automated use case.
Once accounts exist, pass accountId (your own server-side user id) on later calls. That is what enables device-to-account linking, which is how you catch one environment behind many accounts: a pattern no single request will ever show on its own.
Questions people ask
- Can I run fraud detection in Next.js middleware?
- Not usefully. Edge middleware runs before your route code with access to headers and cookies but not the form body, and the device-layer signals — antidetect traits, automation surfaces, tampering score — only exist after a browser has run the client collector. Middleware sees the request too early for any of them. It also runs on every matching request including prefetches and static assets, so an API call there burns your quota on traffic that was never a decision point. Use a Route Handler or Server Action instead.
- Should I gate on decision or isSuspicious?
- On decision. isSuspicious is a legacy flag set by VPN, proxy, Tor, fake-browser, automation, emulator and virtual-machine evidence. Your dashboard rules and IP or visitor pins never change it, and neither does the disposable-email check, because those are applied to decision. The three values are allow, review, and block.
- What happens if the Maskbreak API is down?
- Let the request through. The checkVisitor function in this guide catches every failure (a timeout, a network error, a non-2xx answer), logs it and returns null, and the Route Handler continues without a verdict. A detection outage that turns into a signup or checkout outage is a more expensive incident than the fraud it would have caught.
- Do I need the fingerprintEventId if I already send the token?
- You need it for any device-layer detection. The token carries network signals; the fingerprint event id is what enables the device block in the response, including antidetect browser detection, automation detection, emulator and virtual machine flags, and the tampering score. Omit it and the response has no device object, which is a common reason an integration appears to detect nothing; the response's device_evidence field says whether the browser check arrived.
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.



