# Deno and Bun | Nolgard

> Verify Nolgard tokens in Deno and Bun with the built-in fetch. A Deno.serve and a Bun.serve signup handler that call /v1/verify.

Source: https://nolgard.com/docs/deno-bun/
Last updated: 2026-10-25

[Docs](https://nolgard.com/docs/) / Deno and Bun

Guide

# Deno and Bun

A signup handler for `Deno.serve` and one for `Bun.serve`. Both use the built-in `fetch`, so there is nothing to install.

## Browser

```
<script src="https://nolgard.com/v1/client.js" data-site-key="SITE_KEY" defer></script>
<script>
  document.querySelector('#signup').addEventListener('submit', async (event) => {
    event.preventDefault();
    let token; // stays undefined when the script is blocked: the server takes the missing-token path
    try {
      await Nolgard.ready;
      token = await Nolgard.execute({ action: 'signup' });
    } catch {}
    const form = event.target;
    const response = await fetch('/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token, email: form.elements.email.value })
    });
    // Show your own success or error message here.
  });
</script>
```

## Deno

```
// main.ts (Deno 2): deno run --allow-net --allow-env main.ts
type Verify = { success: boolean; error?: string; decision?: string };
type Account = { email: string };

// Your own function. confirmEmailFirst holds the trial until the email is confirmed.
declare function createAccount(email: string, options?: { trialDays?: number; confirmEmailFirst?: boolean }): Promise<void>;

async function verify(token: string, remoteIp: string, action: string, account: Account): Promise<Verify> {
  const response = await fetch('https://nolgard.com/v1/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ secret: Deno.env.get('NOLGARD_SECRET'), token, remoteIp, action, account }),
    signal: AbortSignal.timeout(3000),
  });
  if (response.status >= 500) throw new Error('nolgard unavailable');
  return response.json();
}

Deno.serve({ port: 3000 }, async (req, info) => {
  if (req.method !== 'POST' || new URL(req.url).pathname !== '/signup') {
    return new Response('Not found', { status: 404 });
  }
  const { token = '', email = '' } = await req.json().catch(() => ({}));
  // Behind a proxy you control, read the visitor address it sets instead.
  const remoteIp = info.remoteAddr.hostname;
  let result: Verify;
  try {
    result = await verify(token, remoteIp, 'signup', { email });
  } catch {
    result = { success: false, error: 'unavailable' }; // timeout or 5xx
  }

  if (!result.success) {
    switch (result.error) {
      case 'invalid-secret':
      case 'invalid-input': // config error: fail closed and alert yourself
        return Response.json({ error: 'try-again' }, { status: 503 });
      case 'missing-token': // script blocked: not a bot verdict
        await createAccount(email, { trialDays: 3, confirmEmailFirst: true });
        return Response.json({ ok: true, confirmEmail: true });
      case 'over-quota':
      case 'unavailable': // fail open with a reduced grant
        await createAccount(email, { trialDays: 3 });
        return Response.json({ ok: true });
      default: // invalid-token, expired, replayed, site-mismatch, action-mismatch
        return Response.json({ error: 'retry-token' }, { status: 409 }); // the form retries once
    }
  }

  switch (result.decision) {
    case 'allow':
      await createAccount(email);
      return Response.json({ ok: true });
    case 'limit':
      await createAccount(email, { trialDays: 3 });
      return Response.json({ ok: true });
    case 'shadow':
      return Response.json({ ok: true }); // same response, nothing created
    case 'challenge':
      return Response.json({ error: 'confirm-email' }, { status: 403 });
    default:
      return Response.json({ error: 'rejected' }, { status: 403 });
  }
});
```

Run with `--allow-net` and `--allow-env` so the handler can read `NOLGARD_SECRET` and call the API. Supply your own `createAccount` function; `confirmEmailFirst` holds the trial until the user clicks your confirmation link. Without a token (ad blocker, Brave Shields, no JavaScript) the user goes through email confirmation instead of a rejection. `info.remoteAddr` is the direct peer, so behind a proxy use the address set by your trusted proxy. Do not trust unchecked forwarding headers.

## Bun

```
// server.ts (Bun 1.1+): bun run server.ts
type Verify = { success: boolean; error?: string; decision?: string };
type Account = { email: string };

// Your own function. confirmEmailFirst holds the trial until the email is confirmed.
declare function createAccount(email: string, options?: { trialDays?: number; confirmEmailFirst?: boolean }): Promise<void>;

async function verify(token: string, remoteIp: string, action: string, account: Account): Promise<Verify> {
  const response = await fetch('https://nolgard.com/v1/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ secret: Bun.env.NOLGARD_SECRET, token, remoteIp, action, account }),
    signal: AbortSignal.timeout(3000),
  });
  if (response.status >= 500) throw new Error('nolgard unavailable');
  return response.json() as Promise<Verify>;
}

Bun.serve({
  port: 3000,
  async fetch(req, server) {
    if (req.method !== 'POST' || new URL(req.url).pathname !== '/signup') {
      return new Response('Not found', { status: 404 });
    }
    const { token = '', email = '' } = await req.json().catch(() => ({}));
    // Behind a proxy you control, read the visitor address it sets instead.
    const remoteIp = server.requestIP(req)?.address ?? '';
    let result: Verify;
    try {
      result = await verify(token, remoteIp, 'signup', { email });
    } catch {
      result = { success: false, error: 'unavailable' }; // timeout or 5xx
    }

    if (!result.success) {
      switch (result.error) {
        case 'invalid-secret':
        case 'invalid-input': // config error: fail closed and alert yourself
          return Response.json({ error: 'try-again' }, { status: 503 });
        case 'missing-token': // script blocked: not a bot verdict
          await createAccount(email, { trialDays: 3, confirmEmailFirst: true });
          return Response.json({ ok: true, confirmEmail: true });
        case 'over-quota':
        case 'unavailable': // fail open with a reduced grant
          await createAccount(email, { trialDays: 3 });
          return Response.json({ ok: true });
        default: // invalid-token, expired, replayed, site-mismatch, action-mismatch
          return Response.json({ error: 'retry-token' }, { status: 409 }); // the form retries once
      }
    }

    switch (result.decision) {
      case 'allow':
        await createAccount(email);
        return Response.json({ ok: true });
      case 'limit':
        await createAccount(email, { trialDays: 3 });
        return Response.json({ ok: true });
      case 'shadow':
        return Response.json({ ok: true }); // same response, nothing created
      case 'challenge':
        return Response.json({ error: 'confirm-email' }, { status: 403 });
      default:
        return Response.json({ error: 'rejected' }, { status: 403 });
    }
  },
});
```

Bun reads `NOLGARD_SECRET` from the environment or a `.env` file. `server.requestIP(req)` is the direct peer, so behind a proxy use the address set by your trusted proxy. Supply your own `createAccount` function, as in the Deno example.

## Cloudflare Workers and Hono

Cloudflare Workers and Hono use the same `fetch` call to `/v1/verify` and the same branches. In a Worker, read the secret from `env.NOLGARD_SECRET` instead of `Deno.env` or `Bun.env`, and the visitor address from the `CF-Connecting-IP` header.
