On this page
Most integration examples show the happy path and stop. This one is written as middleware you would actually deploy: reusable across routes, never failing your own request because of the fraud check, starting in watch mode, and applying different strictness to different actions rather than one global rule.
It takes three steps, the same three the Maskbreak console lists: add the script to the page with your form, send the check from your server, and submit your form once. The code calls the HTTP API with fetch, which Node 18 and later have built in, so there is nothing to install.
Step 1: Add the script to the page with your form
Load sentinel.js on the page that holds the form, and put class="monocle-enriched" on the <form> element itself, never on an input inside it.
<!-- views/signup.html: the page with your form -->
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<form class="monocle-enriched" method="post" action="/signup">
<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 that form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the browser's time zone). The browser check starts when the page loads with a marked form (or at the first click into a field) and writes sentinel_fp when it finishes, so a form submitted right after the page loads may arrive without it.
If your frontend renders the form with JavaScript (React, Vue, a fetch submit), ask the script for the values instead and send them with your form data. Check that window.Sentinel exists first, and never hold the form back when the script is missing or blocked:
// In your submit handler, before the fetch()
const evidence = window.Sentinel
? await window.Sentinel.collect().catch(() => ({}))
: {}; // script blocked: send the form anyway
await fetch('/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// collect() gives { token, fingerprintEventId, tz }
body: JSON.stringify({ email, password, ...evidence }),
});
If your site sends a Content-Security-Policy header (helmet does by default), the browser blocks the script until you add Maskbreak's hosts to the directives you already have:
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:
Step 2: Send the check from your server
One function does the call. It maps the form's field names to the API's (monocle to token, sentinel_fp to fingerprintEventId, sentinel_tz to tz), and it calls even when a field is missing: a check that arrives without a token or a browser check id is what lets the console tell you which half is missing. It never throws, which matters in Express 4, where an unhandled rejection in a handler ends the process.
// lib/maskbreak.js: server-side only
const MODE = process.env.MASKBREAK_MODE || 'watch'; // 'watch' or 'enforce'
// Returns the verdict, or null when there is none (timeout, network error,
// wrong key). Never throws.
async function checkVisitor(body = {}, extra = {}) {
const payload = {
token: body.monocle || body.token || '',
fingerprintEventId: body.sentinel_fp || body.fingerprintEventId || undefined,
tz: body.sentinel_tz || body.tz || undefined,
email: extra.email, // optional: adds email.disposable
accountId: extra.accountId, // optional: your own server-side user id
};
let result = 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(payload),
signal: AbortSignal.timeout(3000),
});
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.name);
}
// One line per check. Never log the key, the token or form data.
console.log('[maskbreak]', JSON.stringify({
mode: MODE,
decision: result ? result.decision : null,
reasons: result ? result.reasons : null,
device_evidence: result ? result.device_evidence : null,
degraded: result ? !!result.degraded : null,
}));
return result;
}
module.exports = { MODE, checkVisitor };
The middleware is a factory, parameterised by how strict the route should be. In watch mode, the default, it records the verdict on the request and always continues.
// middleware/maskbreak.js
const { MODE, checkVisitor } = require('../lib/maskbreak');
// strict: false refuse only a block (enforce mode)
// strict: true continue only on a clean, complete allow (enforce mode)
function guard({ strict = false } = {}) {
return async function maskbreakGuard(req, res, next) {
const body = req.body || {};
const result = await checkVisitor(body, {
email: typeof body.email === 'string' ? body.email : undefined,
accountId: req.user ? String(req.user.id) : undefined,
});
req.maskbreak = result;
if (MODE !== 'enforce') return next(); // watch: log only, always continue
if (result && result.decision === 'block') {
return res.status(403).json({ error: 'Request declined' });
}
if (strict) {
const clean = result && result.decision === 'allow' && !result.degraded
&& !result.test && result.device_evidence === 'ok';
if (!clean) return res.status(409).json({ error: 'Additional verification required' });
}
return next(); // a review on a normal route: the handler steps up
};
}
module.exports = { guard };
Mount it per route, after the body parsers. An HTML form posts application/x-www-form-urlencoded, so express.urlencoded has to be there or req.body is empty.
const express = require('express');
const { MODE } = require('./lib/maskbreak');
const { guard } = require('./middleware/maskbreak');
const app = express();
app.use(express.urlencoded({ extended: false, limit: '64kb' })); // HTML forms
app.use(express.json({ limit: '64kb' })); // fetch() posts
// Public reads: no check at all.
app.get('/api/products', listProducts);
// Signup: refuse only a block, step up on review.
app.post('/signup', guard(), async (req, res, next) => {
try {
const stepUp = MODE === 'enforce' && req.maskbreak?.decision === 'review';
await createUser(req.body, { requireEmailVerification: stepUp });
res.redirect('/welcome');
} catch (err) {
next(err); // your own failures too: Express 4 does not catch a rejected handler
}
});
// Promo redemption: the strictest gate on the site.
app.post('/api/promo/redeem', requireAuth, guard({ strict: true }), redeemPromo);
// Password change: anything that is not clean is held.
app.post('/api/account/password', requireAuth, guard({ strict: true }), changePassword);
Set MASKBREAK_API_KEY to your live key in your host's environment or secret store, never in the repository or in browser code, and deploy.
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 3: Submit your form once
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 your server logs one [maskbreak] line for it. That line says what is missing when something is:
decision: nullafter an HTTP warning: a 401 is the key; anything else, read the warning.degraded: true: no network token reached your server (or the network check was unavailable). Usually the script was blocked by your CSP or a content blocker, the class is missing or sits on an input, or the form was rendered after the page loaded and needsSentinel.collect().device_evidence: "not_sent": the form posted nosentinel_fp. The browser check had not finished, was blocked, or the value did not reach your server.device_evidence: "unavailable": asentinel_fparrived, but Maskbreak could not resolve it: a value that is not a browser check id, or the browser check service did not answer.device_evidence: "ok"anddegraded: false: a complete check.
Switching to enforce
Leave it in watch mode for a few days and compare the log lines with the console's Events: what share of your real traffic lands in review, which reason codes dominate, whether your own staff or QA tooling trips it. Then set MASKBREAK_MODE=enforce and redeploy. Nothing in the code changes.
In enforce mode a block is refused everywhere, a review on a normal route reaches your handler (which steps up rather than refusing), and a strict route continues only on a clean, complete, production allow. A strict route holds a check that came back without device evidence, without a network token, or with test: true: a fixed test token such as test_clean must never pass a production gate. Start enforcing on one action, whichever is costing you money, and expand from there rather than turning it on everywhere at once, which is how a bad threshold becomes a revenue incident.
Why failing open is not a cop-out
It is tempting to treat a failed fraud check as a reason to refuse. Work through the arithmetic before you do.
If the API is unreachable for ten minutes and you fail closed, every signup and checkout in that window fails. If you fail open, ten minutes of traffic goes unscreened, and your existing controls, rate limits and post-hoc review still apply. The second outcome is almost always cheaper, and it is the one your on-call engineer will thank you for at 3am. That is why checkVisitor returns null on any failure and a normal route continues on null.
The exception is an action that is both irreversible and high-value. For those, failing open is a real risk, which is what strict: true is for: it holds the action and lets you queue it for manual review rather than auto-approve it.
Logging the reason, not just the outcome
Store reasons and risk_score alongside your own decision. When someone reports that a legitimate customer was declined, you want to answer from evidence rather than reconstruct it.
The response also carries decision_source and engine_decision when your own rules or allow-pins changed the outcome, which is what tells you whether a bad decision came from the engine or from a rule you wrote.
const v = req.maskbreak;
await db.riskLog.insert({
userId: req.user?.id,
action: 'promo_redeem',
decision: v?.decision ?? null,
engineDecision: v?.engine_decision ?? v?.decision ?? null,
source: v?.decision_source ?? 'engine',
score: v?.risk_score ?? null,
reasons: v?.reasons ?? [],
deviceEvidence: v?.device_evidence ?? null,
});
Questions people ask
- Should fraud detection middleware fail open or closed?
- Open, for almost every action. If the detection API is unreachable and you fail closed, every signup and checkout during the outage fails; if you fail open, a window of traffic goes unscreened while your rate limits and post-hoc review still apply. The second is nearly always the cheaper incident. The exception is actions that are both irreversible and high-value, where the better answer is to queue for manual review rather than auto-approve or auto-refuse.
- What if the client never sends a token?
- Send the check anyway and let the request through on ordinary routes. An ad blocker, a content-security-policy problem or a form rendered after the page loaded can stop the token for real visitors. Maskbreak answers such a check marked degraded, and the console shows that the network half is missing, which is how you find the cause. On a strict route in enforce mode, hold the action for verification instead.
- How do I apply different strictness to different routes?
- Make the middleware a factory with a strict option and mount it per route. Public reads need no check at all; signup refuses only a block in enforce mode and steps up on review; promotion redemption and password changes continue only on a clean, complete allow. A single global threshold is always wrong somewhere, because the cost of a false positive on a product page and on a checkout are not remotely comparable.
- How should I roll this out to an existing app?
- In watch mode, the default: every check is sent and logged, and nothing is refused. After a few days you have your own baseline: what share of genuine traffic lands in review, which reason codes dominate, and whether internal QA or staff tooling trips it. Then set MASKBREAK_MODE=enforce with the guard mounted on the action that is actually costing money, and expand from there. Enforcing everywhere at once is how a mis-set threshold becomes a revenue incident.
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.



