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.
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.
- Decide how the contact page renders.
- With your own Turnstile keys, the page renders on demand through an adapter.
- The form posts to an Astro Action, which limits by IP, verifies the token and forwards the fields.
- Ikiform stores the message and scores the honeypot.
- To keep the site static, the plain form posts straight to Ikiform, which runs the honeypot, its managed Turnstile check and the rate limit.
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
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envAdd 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:
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_tokenimport 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/serverrefuses to load in a client script.scriptDirective.resourcesreplaces 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 withastro buildandastro 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:
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:
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
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
URLSearchParamsneeds 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 underastro 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
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
_gotchais 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
Origingoes along because a server-side fetch sends none, and Ikiform reads it to recognise freelocalhosttests. TOO_MANY_REQUESTSmaps to a429andFORBIDDENto a403.
IKIFORM_SECRET_KEY in server-only environment variables.The page
---
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:
---
---
<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
- Run
astro devwith the test keys and send a message. It appears under Submissions. A workspace without a plan accepts 50localhosttests a month. - Leave the name empty with the
requiredattribute removed in devtools. The field error shows under the input. - Set
TURNSTILE_SECRET_KEYto the always-fail secret and submit. The page shows the human-check error. - Submit six times in a minute. The sixth answers
429with the limit message. - 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. - Run
astro buildandastro 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.