🍡 mochi

SSR framework for Svelte 5 + Bun with islands-based selective hydration

On this page

Protection Mode

Since: 0.10.0 (not released yet): Protection mode ships in 0.10.0.

The protection-mode interstitial: the Mochi logo, the text 'Please wait, we're validating your browser...', and the auto-captcha widget solving
The built-in interstitial mid-verification — the widget solves the proof-of-work on its own and reloads into the page.

Protection mode gates routes behind a browser check, like Cloudflare’s “verifying your browser” page but on your own infrastructure. An unverified client gets a 403 interstitial page: a hidden <MochiCaptchaAuto /> island runs the captcha’s hash chain and proof-of-work immediately — no slider — posts the solution to a built-in endpoint, receives a signed HttpOnly clearance cookie, and reloads into the real page. Every later request passes until the clearance expires.

await Mochi.serve({
  protection: { enabled: true },
  routes,
});

That alone protects every route — pages, APIs, WebSockets, SSE, and unmatched URLs handled by your fetch fallback.

Choosing what to protect

protect() is an optional callback that runs on every request; return true to protect a resource. It receives { kind, path, url, request }, so branching on route kind or path prefix is one line:

protection: {
  enabled: true,
  // Only APIs need verification; pages stay open.
  protect: ({ kind }) => kind === 'api',
},

kind is 'page' | 'api' | 'ws' | 'sse' | 'island' | 'file' | 'fallback'. A protect() that throws counts as protected (fail closed).

Never gated, regardless of protect(): framework client assets (the interstitial must load its own JS/CSS), the image and local-asset endpoints (their URLs are server-minted and signed), warmup self-requests, the verify endpoint itself, and unmatched 404s with no fetch fallback. publicDir static files are gated (as kind file) — set protectFiles: false to leave them open. A blocked POST never gets the interstitial — solving one ends in a reload, which would re-submit the form — so non-GET requests fail as JSON instead.

What a blocked client sees

KindResponse
page, fallback (GET / HEAD)The interstitial HTML — 403, Cache-Control: no-store
api, and any non-GET request403 JSON: { "error": "Browser verification required" }
ws, sse, island, filePlain 403

A browser calling a protected API from an already-cleared page carries the cookie automatically, so in practice only direct, cookie-less clients see the API 403. The non-HTML bodies use blockedMessage (default shown above).

Options

protection: {
  enabled: true,
  protect: ({ path }) => path.startsWith('/members'),
  bits: 19,                       // PoW difficulty; default 19
  maxAgeMs: 4 * 60 * 60 * 1000,   // clearance lifetime; default 4 hours
  maxAttempts: 5,                 // failed tries before the widget gives up; default 5
  protectFiles: true,             // gate publicDir static files too; default true
  cookieName: '_mochi_clearance', // clearance cookie name; default shown
  blockedMessage: 'Members only', // non-HTML 403 body; string or (ctx) => string
  page: './src/ProtectionShell.svelte', // custom interstitial component
},
  • bits — proof-of-work difficulty in leading zero bits; each extra bit doubles the expected work. Default: 19.
  • maxAgeMs — how long a passed verification lasts. The clearance is a sealed { iat } token in the clearance cookie (HttpOnly, SameSite=Lax, Path=/, Secure on https); both the cookie’s Max-Age and the server-side check use this value. It’s keyed off MOCHI_KEY, so clearances survive restarts and work across instances.
  • maxAttempts — after this many failed verification attempts the widget stops retrying and shows a terminal message instead (the exhaustedLabel prop on <MochiCaptchaAuto />). The count lives in sessionStorage, so closing the tab resets it. Default: 5.
  • protectFiles — also gate publicDir static files (they hit protect() as kind file). Default: true.
  • cookieName — rename the clearance cookie. Default: _mochi_clearance.
  • blockedMessage — the 403 body blocked non-HTML kinds receive: the error field of the api JSON and the plain-text body for ws/sse/island/file. A string, or a callback receiving the same context as protect(). Default: "Browser verification required".
  • page — a Svelte component rendered as the interstitial, exactly like errorPage for error pages. See below.

Customizing the interstitial

Point page at your own Svelte component. It renders through your app’s HTML shell, receives MochiProtectionPageProps{ token, bits, solveBudgetMs, verifyUrl, maxAttempts } — and only has to spread them onto <MochiCaptchaAuto />, which does the solving and submitting.

The built-in default below is a great natural starting point — copy it and restyle. This is the component’s live source, read from the installed mochi-framework:

<script lang="ts">
  import MochiCaptchaAuto from '../../captcha/MochiCaptchaAuto.svelte';
  import type { MochiProtectionPageProps } from '../../protection/types';

  let { token, bits, solveBudgetMs, verifyUrl, maxAttempts }: MochiProtectionPageProps = $props();
</script>

<svelte:head>
  <title>Checking your browser…</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="robots" content="noindex" />
</svelte:head>

<div class="wrap">
  <main class="stage">
    <span class="brand">
      <span class="logo" aria-hidden="true">🍡</span><span>mochi</span>
    </span>
    <p class="message">Please wait, we're validating your browser...</p>
    <div class="widget">
      <MochiCaptchaAuto mochi:hydrate {token} {bits} {solveBudgetMs} {verifyUrl} {maxAttempts} />
    </div>
  </main>
</div>

<style>
  :global(html, body) {
    margin: 0;
    overflow-x: hidden;
  }
  :global(*, *::before, *::after) {
    box-sizing: border-box;
  }
  :global(body) {
    color-scheme: light dark;
  }

  .wrap {
    --mochi-protection-bg: #f1ecdf;
    --mochi-protection-ink: #2a2825;
    --mochi-protection-ink-soft: #6b665e;
    --mochi-protection-gradient-tint: rgba(56, 92, 71, 0.1);

    --mochi-protection-font-serif: Georgia, 'Iowan Old Style', 'Palatino Linotype', Cambria, serif;
    --mochi-protection-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;

    min-height: 100vh;
    width: 100%;
    background: radial-gradient(1200px 600px at 50% -10%, var(--mochi-protection-gradient-tint), transparent 60%), var(--mochi-protection-bg);
    color: var(--mochi-protection-ink);
    font-family: var(--mochi-protection-font-sans);
    font-size: 15px;
    line-height: 1.55;
    -webkit-font-smoothing: antialiased;
  }

  .stage {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 18px;
    padding: 24px;
    text-align: center;
  }

  .brand {
    display: inline-flex;
    align-items: baseline;
    gap: 10px;
    font-family: var(--mochi-protection-font-serif);
    font-weight: 600;
    font-size: 28px;
  }

  .logo {
    font-size: 44px;
    line-height: 1;
    transform: translateY(4px);
  }

  .message {
    font-family: var(--mochi-protection-font-serif);
    font-style: italic;
    font-size: 18px;
    margin: 0;
    color: var(--mochi-protection-ink-soft);
  }

  .widget {
    width: min(320px, 100%);
  }

  @media (prefers-color-scheme: dark) {
    .wrap {
      --mochi-protection-bg: #1a1815;
      --mochi-protection-ink: #f1ecdf;
      --mochi-protection-ink-soft: #a39e94;
      --mochi-protection-gradient-tint: rgba(154, 184, 163, 0.06);
    }

    .widget {
      --mochi-captcha-border: #2d2a25;
      --mochi-captcha-track-bg: #23201c;
      --mochi-captcha-accent: #9ab8a3;
      --mochi-captcha-accent-soft: #2f3b33;
      --mochi-captcha-hint-text: #a39e94;
      --mochi-captcha-error-bg: #2b1e1b;
      --mochi-captcha-error-border: #4a322c;
      --mochi-captcha-error-text: #e0a294;
    }
  }
</style>

Testing protected routes

solveCaptcha() solves a minted challenge server-side, so a test can clear itself without a browser:

import { mintCaptcha, solveCaptcha } from 'mochi-framework';

const fields = solveCaptcha(mintCaptcha());
const form = new FormData();
form.set('captcha_token', fields.captcha_token);
form.set('captcha_pow', fields.captcha_pow);
const res = await fetch(`${base}/_mochi/protection/verify`, { method: 'POST', body: form, headers: { origin: base } });
const cookie = res.headers.get('Set-Cookie'); // send this on protected requests

Lower captcha: { bits: 8 } in the test server so the solve takes milliseconds. Mind that the verify endpoint refuses tokens minted below the protection difficulty — if you set protection.bits above the captcha default, mint with mintCaptcha({ bits }) to match.

See it in action

Live demos showing key concepts from this page

API

ExportWhat it is
PROTECTION_CLEARANCE_COOKIEThe default clearance cookie name
DEFAULT_PROTECTION_MAX_AGE_MSDefault clearance lifetime (4 hours)
DEFAULT_PROTECTION_MAX_ATTEMPTSDefault verification attempt cut-off (5)
MochiProtectionOptionsThe Mochi.serve({ protection }) option type
MochiProtectionContext / MochiProtectionKindWhat protect() receives
MochiProtectionPagePropsProps a custom page component receives
PROTECTION_SHELL_COMPONENTAbsolute path of the built-in interstitial
MochiCaptchaAuto (mochi-framework/components)The auto-solving widget the interstitial uses