Stop form spam in Astro

Updated

This guide adds Bot403 to a contact form in Astro. The browser solves a small proof of work. Then an Astro endpoint checks the token with one call, before it accepts the message. The visitor sees no puzzle, and the script sets no cookie.

The example uses Astro 7.4 with the Node adapter. It was run on 23 October 2026 against a local copy of the verify API. The tests covered a good token, a reused token and an expired token. The tests also covered a filled honeypot, a capped month and an API that does not answer.

Before you start

  • Sign in. Create a site. Copy its public key (pk_) and its secret key (sk_).
  • Add a server adapter. Put the secret key in .env. In production, set the same variable in your host's settings.
Terminal
npx astro add node
echo "BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY" >> .env
npm run dev

1. The form

The page stays static. The script tag protects every form on the page. Before the browser sends the form, the script adds a hidden _bot403 field with the token.

src/pages/contact.astro
---
// This page stays static. Only the endpoint that gets the POST runs on the server
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Contact</title>
    <script is:inline src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_YOUR_PUBLIC_KEY" async></script>
  </head>
  <body>
    <form method="POST" action="/api/contact">
      <label>Email <input name="email" type="email" required /></label>
      <label>Message <textarea name="message" required></textarea></label>
      <button type="submit">Send</button>
    </form>
  </body>
</html>

The is:inline directive tells Astro to keep the tag as it is. Astro does not bundle the script. As a result, the script still knows its own URL and its key.

2. The endpoint

The endpoint gets the POST and sends the form fields to Bot403. It accepts the message only when the answer is ok: true.

src/pages/api/contact.ts
import type { APIRoute } from "astro";
import { BOT403_SECRET_KEY } from "astro:env/server";

// Render on demand. Without this line the route is built as a static file
export const prerender = false;

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();

  // 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
  const body = new URLSearchParams();
  for (const [name, value] of form) if (typeof value === "string") body.append(name, value);

  let result = { ok: true, reason: "" };
  try {
    const res = await fetch("https://bot403.com/api/v1/verify", {
      method: "POST",
      headers: { Authorization: `Bearer ${BOT403_SECRET_KEY}` },
      body,
      signal: AbortSignal.timeout(5000),
    });
    result = await res.json();
  } catch (err) {
    console.error("bot403 verify failed, letting the form through", err);
  }

  // 2. Refuse when ok is false, except for reason "quota"
  if (!result.ok && result.reason !== "quota") {
    return new Response("Try again", { status: 400 });
  }

  // 3. A real visitor: send the email or store the message here
  return redirect("/thanks", 303);
};

The endpoint sends the fields as application/x-www-form-urlencoded. Do not pass the FormData object as the body. If you do, fetch sends multipart/form-data. The verify API does not read this format, and every token fails with invalid.

If Bot403 does not answer in 5 seconds, the endpoint accepts the form and logs the error. If you want to refuse the form in this case, change the catch block.

3. The configuration

astro.config.mjs
// @ts-check
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  // Any server adapter works: Node, Netlify, Vercel or Cloudflare
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      BOT403_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

The astro:env module reads the secret key on the server at run time. The key never goes into the browser bundle.

The trap: the endpoint needs a server

By default, Astro builds every route as a static file. A static file cannot answer a POST. For this reason, the endpoint has export const prerender = false, and the project needs a server adapter.

The trap is that astro dev works without an adapter. You send the form and the endpoint answers. The work looks finished. Then astro build stops with this error:

[NoAdapterInstalled] Cannot use server-rendered pages without an adapter.

Without prerender = false, the build tries to render the endpoint one time, at build time. As a result, there is no endpoint at all in production. Any adapter works: Node, Netlify, Vercel or Cloudflare.

Astro has one more default that you must know. The security.checkOrigin option refuses a form that another site sends. The answer is HTTP 403. A form on your own domain passes this check. When you test with curl, do not send the Origin header.

Test it

  1. Open http://localhost:4321/contact. Send the form. The browser goes to /thanks, and the site in your dashboard shows one check.
  2. Send a form without a token. The endpoint refuses it, and the dashboard lists the failure with the reason invalid.
Terminal
# A form without a token: the endpoint refuses it
curl -i -d "[email protected]&message=hi" http://localhost:4321/api/contact
# HTTP/1.1 400 Bad Request
# Try again

If you get a failure that you do not expect, read the troubleshooting page. It lists every reason and its fix.