SvelteKit Bot Detection in hooks.server.ts

One server hook, typed locals, and the SvelteKit details that decide whether it holds: form actions versus endpoints, and what not to screen.

A worn steel lifting hook with a safety latch hangs from a chipped yellow hoist block
Photo: Lifting hook with a safety latch hanging from a bridge crane - 2 by W.carter, CC0 1.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. Type the locals
  4. Using the verdict in a form action
  5. Step 3: Submit your form once
  6. What changes per adapter
  7. What the hook does not do
  8. The short version

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 browser half, the hook, the typing, form actions versus +server.ts endpoints, and the adapter differences that matter once you deploy, in the three steps the Maskbreak console lists: add the script to the page with your form, send the check from your server, and submit your form once. It starts in watch mode, where every check is logged and nothing is refused.

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

Load the script once in the root layout:

html
<!-- 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, with class="monocle-enriched" on the <form> itself. On a full page load the script adds three hidden fields to such a form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). But SvelteKit usually reaches a page by client-side navigation, and a form rendered after the page loaded posts without monocle. So ask the script for the evidence at submit time and put it in the form data. use:enhance lets you do that before the post goes out:

svelte
<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { SubmitFunction } from '@sveltejs/kit';

  // Add the browser evidence to the form data before SvelteKit posts it.
  // Never hold the form back: a blocked script only means less evidence.
  const withEvidence: SubmitFunction = async ({ formData }) => {
    const ev = window.Sentinel
      ? await window.Sentinel.collect().catch(() => null)
      : null;
    if (ev?.token) formData.set('monocle', ev.token);
    if (ev?.fingerprintEventId) formData.set('sentinel_fp', ev.fingerprintEventId);
    if (ev?.tz) formData.set('sentinel_tz', ev.tz);
  };
</script>

<form method="POST" class="monocle-enriched" use:enhance={withEvidence}>
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button>Create account</button>
</form>

A form that was on the page when it loaded also carries the hidden fields the script added, so a post that skips enhance (a submit before the page has hydrated) arrives with the same field names. Without JavaScript nothing is added, and the check goes out without evidence. If your app sends a Content-Security-Policy (kit.csp in svelte.config.js), 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

typescript
// src/lib/server/maskbreak.ts: server-only, never imported by client code
import { MASKBREAK_API_KEY } from '$env/static/private';
import { env } from '$env/dynamic/private';

// Optional, so it comes from the dynamic module: a static import of a
// variable that is not set fails the build.
export const MODE = env.MASKBREAK_MODE ?? 'watch';   // 'watch' | 'enforce'

// Form posts carry monocle / sentinel_fp / sentinel_tz; a JSON endpoint
// gets token / fingerprintEventId / tz from Sentinel.collect().
export async function readFields(request: Request): Promise<Record<string, unknown>> {
  try {
    if ((request.headers.get('content-type') ?? '').includes('application/json')) {
      const body = await request.json();
      return body && typeof body === 'object' ? body : {};
    }
    return Object.fromEntries(await request.formData());
  } catch {
    return {};
  }
}

const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined);

// Sends whatever evidence arrived, including none: a check without a token
// or a browser check id is how the console tells you which half is missing.
// Returns the verdict, or null. Never throws.
export async function checkVisitor(b: Record<string, unknown>, accountId?: string) {
  let result: App.Verdict | null = null;
  try {
    const res = await fetch('https://maskbreak.com/v1/evaluate', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer ' + MASKBREAK_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        token: str(b.monocle) ?? str(b.token) ?? '',
        fingerprintEventId: str(b.sentinel_fp) ?? str(b.fingerprintEventId),
        tz: str(b.sentinel_tz) ?? str(b.tz),
        email: str(b.email),
        accountId,
      }),
      signal: AbortSignal.timeout(3000),   // in front of a user-facing action
    });
    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);   // outage: no opinion
  }
  // 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 hook runs the check on the few POST paths that need it and puts the verdict on event.locals:

typescript
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { checkVisitor, readFields } from '$lib/server/maskbreak';

// 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 wanted = event.request.method === 'POST'
    && SCREENED.some((re) => re.test(event.url.pathname));
  if (!wanted) return resolve(event);

  // Read the body without consuming it: clone first, or the action
  // that runs next finds an empty stream.
  const fields = await readFields(event.request.clone());
  event.locals.verdict = await checkVisitor(fields, event.locals.user?.id);
  return resolve(event);   // the hook gathers; actions and endpoints decide
};

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.

$env/static/private (inside $lib/server) is what guarantees the key cannot reach the browser: SvelteKit refuses at build time if a private or server-only module is pulled into client code. Importing the key from $env/static/public would ship it in the bundle. Set MASKBREAK_API_KEY to your live key in your host's environment variables.

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

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:

typescript
// src/app.d.ts
declare global {
  namespace App {
    interface Verdict {
      decision: 'allow' | 'review' | 'block';
      risk_score: number;
      reasons: string[];
      device_evidence?: 'ok' | 'not_sent' | 'unavailable';
      degraded?: boolean;
      test?: boolean;
    }
    interface Locals {
      user?: { id: string };
      verdict?: Verdict | null;
    }
  }
  interface Window {
    Sentinel?: {
      collect(opts?: { timeout?: number }): Promise<{
        token: string | null; fingerprintEventId: string | null; tz: string | 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

Deciding belongs in the action, where you can see what is at stake. In watch mode nothing is refused:

typescript
// src/routes/signup/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { MODE } from '$lib/server/maskbreak';

export const actions: Actions = {
  default: async ({ request, locals }) => {
    const v = locals.verdict;
    // No verdict: a blocked script or an outage. Degraded, not hostile.
    if (MODE === 'enforce' && v?.decision === 'block') {
      return fail(403, { error: 'Sign-up unavailable from this connection.' });
    }

    const data = await request.formData();
    const user = await createUser(data, {
      needsReview: MODE === 'enforce' && 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 client posts the collect() values in the body, and the hook reads them the same way:

typescript
// src/routes/api/withdraw/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MODE } from '$lib/server/maskbreak';

export const POST: RequestHandler = async ({ locals, request }) => {
  const v = locals.verdict;
  // Money leaving: in enforce mode, only a clean, complete, production allow.
  const clean = v?.decision === 'allow' && v.device_evidence === 'ok'
    && !v.degraded && !v.test;
  if (MODE === 'enforce' && !clean) {
    return json({ error: 'Additional verification required' }, { status: 409 });
  }
  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 holds the middle band, missing evidence and test results too. There is more on picking those lines in risk score thresholds.

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 your server log shows one [maskbreak] line. degraded: true means no network token arrived (the script was blocked, or the form posted without the enhance function); device_evidence: "not_sent" means no browser check id arrived; "ok" with degraded: false is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce and redeploy.

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 AbortSignal. Watch the subrequest and CPU limits if you add anything heavier.

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: per-address and per-account attempt limits remain the cheapest control you own.

The short version

  • Script in the root layout, the class on the <form>, and Sentinel.collect() in use:enhance so a client-rendered form still carries the evidence.
  • One handle hook, screening a short list of POST paths, sending token, fingerprintEventId and tz every time, even when one is missing.
  • 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.
  • Watch first. In enforce mode the hook gathers and the action decides: block-only on signup, strict on money.
  • Adapters differ: the address header on Node, the runtime limits at the edge, and no hook at all when prerendered.

Questions people ask

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 AbortSignal), 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.

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