SvelteKit has one server-side chokepoint — handle in hooks.server.ts — and everything that reaches your server goes through it. That makes the integration short. It also makes it easy to screen things you never meant to screen, which costs you an API call per favicon request and a slower page load for the privilege.
This covers the hook, the typing, form actions versus +server.ts endpoints, and the adapter differences that matter once you deploy.
The browser half
Load the collector once in the root layout:
<!-- src/routes/+layout.svelte -->
<svelte:head>
<script async src="https://maskbreak.com/assets/sentinel.js">
</script>
</svelte:head>
<slot />
Then mark the forms that matter. The script injects a hidden monocle field into any form carrying the class, and because SvelteKit form actions post ordinary form data, the token arrives in formData with no client JavaScript of yours involved:
<!-- src/routes/signup/+page.svelte -->
<form method="POST" class="monocle-enriched" use:enhance>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Create account</button>
</form>
That works with progressive enhancement on or off, which is the reason to use the form-field route rather than reading a token in JavaScript and attaching it manually.
The server hook
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { MASKBREAK_KEY } from '$env/static/private';
// Screen these paths only. Screening every request buys you an API
// call per favicon and a slower page.
const SCREENED = [/^\/signup/, /^\/login/, /^\/checkout/,
/^\/api\/(withdraw|invite)/];
export const handle: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
const wanted = event.request.method === 'POST'
&& SCREENED.some((re) => re.test(path));
if (!wanted) return resolve(event);
// Read the body without consuming it: clone first, or the action
// that runs next finds an empty stream.
const form = await event.request.clone().formData().catch(() => null);
const token = form?.get('monocle')?.toString();
event.locals.verdict = token
? await evaluate(token, event.locals.user?.id)
: null;
return resolve(event);
};
async function evaluate(token: string, accountId?: string) {
const ac = new AbortController();
// Hard deadline. This is in front of a user-facing action.
const t = setTimeout(() => ac.abort(), 1500);
try {
const res = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + MASKBREAK_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ token, accountId }),
signal: ac.signal,
});
if (!res.ok) return null; // 4xx is our bug; log it upstream
return await res.json();
} catch {
return null; // outage: no opinion, not "safe"
} finally {
clearTimeout(t);
}
}
The clone() is not optional and its failure mode is unpleasant. A request body is a stream that can be read once; consume it in the hook and the form action downstream receives an empty formData, so every field looks missing and validation rejects a perfectly good signup. It reproduces every time, which at least makes it quick to find.
$env/static/private is the import that guarantees the key cannot reach the browser: SvelteKit refuses at build time if a private module is pulled into client code. Importing the key from $env/static/public would ship it in the bundle, which is the one mistake in this file you cannot undo after deploying.
Type the locals
Without this, event.locals.verdict is a type error and people reach for any, which defeats the reason you are using TypeScript on the risky path:
// src/app.d.ts
declare global {
namespace App {
interface Verdict {
decision: 'allow' | 'review' | 'block';
risk_score: number;
reasons: string[];
}
interface Locals {
user?: { id: string };
verdict: Verdict | null;
}
}
}
export {};
Note the union on decision. It is what makes a typo in a comparison a compile error rather than a branch that silently never runs.
Using the verdict in a form action
The hook gathers evidence; it does not decide. Deciding belongs in the action, where you can see what is at stake:
// src/routes/signup/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, locals }) => {
const v = locals.verdict;
// No verdict: ad blocker, CSP mistake, or an outage. Degraded,
// not hostile.
if (v?.decision === 'block') {
return fail(403, {
error: 'Sign-up unavailable from this connection.',
});
}
const data = await request.formData();
const user = await createUser(data, {
needsReview: v?.decision === 'review',
});
return { success: true, id: user.id };
},
};
fail rather than error is deliberate: it returns the message to the same page with the form state intact, so a false positive is a sentence the user can read rather than an error page they bounce from.
For a JSON endpoint the shape is the same, with the token in the body instead:
// src/routes/api/withdraw/+server.ts
export const POST = async ({ locals, request }) => {
// Money leaving: refuse anything that is not clean.
if (locals.verdict && locals.verdict.decision !== 'allow') {
return json({ error: 'refused' }, { status: 403 });
}
return json(await payout(await request.json()));
};
Two different thresholds on two routes, and that is the point: signup refuses only a hard block because VPN users are customers, while an irreversible payout refuses the middle band too. There is more on picking those lines in risk score thresholds.
What changes per adapter
The hook code is identical across adapters; the runtime around it is not.
adapter-node. Ordinary Node, with a caveat about addresses: behind a reverse proxy, event.getClientAddress() returns the proxy's address unless the server is started with ADDRESS_HEADER=X-Forwarded-For and a correct XFF_DEPTH. Get that wrong and every visitor appears to come from your load balancer, which quietly ruins any per-address logic you build alongside this.
adapter-cloudflare and adapter-vercel edge. The hook runs on a Workers-style runtime with no Node built-ins, which the code above already respects — it uses only fetch and AbortController. Watch the subrequest and CPU limits if you add anything heavier, and keep the deadline tight, since the platform will kill a long-running invocation less gracefully than your own timeout does.
adapter-static. There is no server, so hooks.server.ts never runs. A prerendered route cannot be screened at all, which is worth knowing before you mark a signup page prerender = true for the page-speed number.
What the hook does not do
Screening a form submission answers one question at one moment. It does not stop someone scraping your product pages, and it should not: a GET on a public page is not where this belongs, and running the check on every navigation makes your site slower for everyone in exchange for very little. If content scraping is the problem, that is an edge concern — see bot detection at the Cloudflare edge.
It also does not replace rate limiting. A token that is valid the first time is valid on the tenth attempt too, and per-address, per-account attempt limits remain the cheapest control you own.
The short version
- One
handlehook, screening a short list of POST paths — not every request. clone()the request before reading the body, or the form action downstream sees nothing.- Import the key from
$env/static/private. The public equivalent ships it to the browser. - Type
App.Locals, and makedecisiona union so a typo fails the build. - The hook gathers, the action decides. Different routes deserve different thresholds.
fail, noterror: a false positive should be a readable sentence on the same page.- Adapters differ — the address header on Node, the runtime limits at the edge, and no hook at all when prerendered.
Frequently Asked Questions
Where does bot detection go in a SvelteKit app?
In the handle function in src/hooks.server.ts, which every server request passes through. Screen a short list of POST paths rather than everything: running the check on asset and navigation requests costs an API call each and slows the site for no benefit. Put the verdict on event.locals and let each form action or endpoint decide what to do with it.
Why is my form action receiving empty form data?
The hook read the request body first. A body is a stream that can only be consumed once, so calling formData() in hooks.server.ts leaves nothing for the action. Call event.request.clone().formData() instead and the original stream stays intact.
How do I keep the API key out of the client bundle?
Import it from $env/static/private. SvelteKit fails the build if a private environment module is reachable from client code, which turns the mistake into a compile error rather than a leaked credential. $env/static/public is bundled into the browser and must never hold a server key.
Does this work on Cloudflare Pages or Vercel Edge?
Yes, provided the hook uses only Web APIs — fetch and AbortController — which the code here does. There are no Node built-ins involved. Keep the timeout tight, because the platform terminating a long invocation is less graceful than your own deadline, and mind the subrequest limits if you call more than one service from the same hook.
One hook, every route covered
Maskbreak returns a decision, a score and the reasons behind it in a single fetch. Free tier: 1,000 requests per hour, no credit card.
Try Maskbreak free →