# 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.

Published 2026-10-25 · https://ikiform.com/guides/spam-protection/astro

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.

Diagram: Choosing where the spam checks run on an Astro site

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`:

```text
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`:

```bash
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`:

```js
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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

## The page

`src/pages/contact.astro`:

```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

| Host | Adapter | Where secrets live | Limit |
| --- | --- | --- | --- |
| Vercel | `@astrojs/vercel` | Project environment variables | Redis |
| Netlify | `@astrojs/netlify` | Site environment variables | Redis |
| Cloudflare | `@astrojs/cloudflare` | `wrangler secret put` | Workers 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`:

```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](https://ikiform.com/docs/guides/astro.md) 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

| Problem | What to do |
| --- | --- |
| `clientAddress` is not available on prerendered pages | The page or the action ran at build time. Add `export const prerender = false` to the contact page. |
| The action result is always undefined | The form posted to a prerendered page. Form actions only run on pages rendered on demand. |
| The widget is blank in production but fine in dev | The 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 domain | `security.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 arrived | It 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

### Do Astro Actions work on a static site?

Not as a form action. A form that posts to an action needs the page rendered on demand through an adapter. On a fully static site, post the form to a form backend such as Ikiform instead.

### How do I get the visitor's IP in Astro?

Use context.clientAddress in an Action or Astro.clientAddress in a page. The adapter fills it from the platform. It only exists on routes rendered on demand.

### Does Astro's CSP support Turnstile?

Yes, with security.csp, stable since Astro 6. Add https://challenges.cloudflare.com to scriptDirective.resources next to 'self', and add a frame-src directive for the same host. Test it with a production build, since it doesn't run in dev.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
