Stop contact form spam

Stop contact form spam7 min read

Astro form spam protection with Turnstile

Protect an Astro form from spam with an Action: a honeypot, Turnstile checked with astro:env secrets, a per-IP limit and Astro's built-in CSP.

Preet SutharFounder, Ikiform

Put the checks in an Astro Action that the form posts to without JavaScript: limit by context.clientAddress, verify the Turnstile token with a secret from astro:env/server, then forward the fields with the honeypot. The contact page must render on demand, so add an adapter and prerender = false there. If you'd rather keep the whole site static, post the plain form to Ikiform and let it run the same checks.

What's different in Astro

Astro builds static HTML by default, and static HTML can't keep a secret. A Turnstile secret, an Ikiform secret key and a rate-limit counter all need code that runs per request, so the first decision on an Astro site is whether the contact page renders on demand at all.

Choosing where the spam checks run on an Astro sitecontactpageondemandadapter +prerender= falseAstroActionlimit,siteverify,forwardIkiformsecretkey,storesplainHTMLformnoadapter,noJavaScriptIkiformhoneypot,Turnstile,limityour own keysform POSTJSONkeep it staticPOSTChoosing where the spam checks run on an Astro sitecontact pageon demandadapter + prerender =falseAstro Actionlimit, siteverify,forwardIkiformsecret key, storesplainHTMLformno adapter,noJavaScriptIkiformhoneypot,Turnstile,limityour own keysform POSTJSONkeep it static
  1. Decide how the contact page renders.
  2. With your own Turnstile keys, the page renders on demand through an adapter.
  3. The form posts to an Astro Action, which limits by IP, verifies the token and forwards the fields.
  4. Ikiform stores the message and scores the honeypot.
  5. To keep the site static, the plain form posts straight to Ikiform, which runs the honeypot, its managed Turnstile check and the rate limit.
Both routes stop the same bots. The top one gives you your own widget and your own limit; the bottom one ships no server code at all.

Astro also checks the origin of form posts to on-demand pages by default, through security.checkOrigin. That stops another site's page from posting to yours. It doesn't stop a script, which can send any headers it likes, so the checks below still apply.

Project setup

Files
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env

Add an adapter with npx astro add vercel, netlify or cloudflare. Declare every variable in the astro:env schema, so a missing secret fails at import time and a secret can never be imported into client code:

.env
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
astro.config.mjs
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
  • access: "secret" keeps a value out of the bundle. astro:env/server refuses to load in a client script.
  • scriptDirective.resources replaces Astro's defaults, so list 'self' again next to Cloudflare. Astro still hashes its own scripts.
  • The CSP doesn't run under astro dev. Check it with astro build and astro preview.

A per-IP limit

In an Action, context.clientAddress is the visitor's IP as your adapter reports it from the platform, so you never parse X-Forwarded-For yourself. It only exists on on-demand routes and throws on prerendered ones. This counter keeps one Redis key per IP per minute, so it works across serverless instances:

src/lib/limit.ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}

On Cloudflare, Astro 7 reads bindings from cloudflare:workers instead of Astro.locals.runtime. A Workers rate limiting binding declared in wrangler.jsonc replaces Redis with no network call, at the cost of an approximate count per location:

src/lib/limit.ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}

Verify the token

src/lib/turnstile.ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
  • Siteverify accepts form-encoded bodies, so URLSearchParams needs no headers.
  • The hostname is compared with context.url.hostname, the host the visitor actually posted to. Test tokens carry a dummy hostname, so the check is skipped under astro dev.
  • A token is valid for 300 seconds and works once. A plain form post reloads the page, and Turnstile issues a fresh one each time.

The Action

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
  • Zod runs before the handler, so a malformed post costs no Redis call. Field errors come back to the page through isInputError.
  • The honeypot _gotcha is optional in the schema and forwarded untouched. Ikiform scores a filled one as spam, so you can still review it.
  • The token is removed before forwarding. Ikiform has no use for a token you already verified.
  • The visitor's Origin goes along because a server-side fetch sends none, and Ikiform reads it to recognise free localhost tests.
  • TOO_MANY_REQUESTS maps to a 429 and FORBIDDEN to a 403.
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

The page

src/pages/contact.astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <div class="hp" aria-hidden="true">
        <label for="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
      </div>
      <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>

The form works without any client JavaScript. action={actions.sendMessage} posts to the same page with the action name in the query string; Astro runs the action and Astro.getActionResult() reads the outcome while rendering. Only this page renders on demand. The rest of the site stays static.

  • Refreshing after a failed post asks to resend the form. Astro's docs suggest the POST, redirect, GET pattern if that bothers you, carrying the result in a session.
  • The Turnstile script uses is:inline, so Astro leaves it alone. It must load from Cloudflare's own URL.
  • Calling the action from a client script on a static page also works, but then you must call turnstile.reset() after each attempt yourself.

Deploying

HostAdapterWhere secrets liveLimit
Vercel@astrojs/vercelProject environment variablesRedis
Netlify@astrojs/netlifySite environment variablesRedis
Cloudflare@astrojs/cloudflarewrangler secret putWorkers rate limiting binding

Add your production hostname to the Turnstile widget before you deploy, and add preview hostnames too if you test there.

Keep it static and let Ikiform check

Most Astro sites are static, and adding an adapter only for a contact form is a real cost. Without one, post the form straight to your Ikiform endpoint:

src/components/Contact.astro
---
---

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>

Set the form's Spam check to Ikiform. A post without a token gets a short check page on ikiform.com that runs Turnstile and sends the fields on, so the page needs no script, no keys and no CSP change. Choose the Strict Rate limit, 2 every 10 minutes per visitor, and add your domain under Allowed domains. Spam gets the same answer as a real message and is kept for 30 days so you can rescue a false positive. The Astro setup guide shows the rest.

Check it works

  1. Run astro dev with the test keys and send a message. It appears under Submissions. A workspace without a plan accepts 50 localhost tests a month.
  2. Leave the name empty with the required attribute removed in devtools. The field error shows under the input.
  3. Set TURNSTILE_SECRET_KEY to the always-fail secret and submit. The page shows the human-check error.
  4. Submit six times in a minute. The sixth answers 429 with the limit message.
  5. Post with the honeypot filled in, _gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
  6. Run astro build and astro preview, open the page and check the console shows no CSP violations.

Gotchas

ProblemWhat to do
clientAddress is not available on prerendered pagesThe page or the action ran at build time. Add export const prerender = false to the contact page.
The action result is always undefinedThe form posted to a prerendered page. Form actions only run on pages rendered on demand.
The widget is blank in production but fine in devThe CSP blocks it. scriptDirective.resources must list https://challenges.cloudflare.com, and frame-src must allow it too.
403 with no message on every post from a custom domainsecurity.checkOrigin saw a different origin, often a proxy rewriting the host. Make sure the host header reaching Astro matches the page's domain.
The submission is in the dashboard but no email or webhook arrivedIt was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.