# Proteção contra spam em formulários Astro com Turnstile

> Proteja um formulário Astro contra spam com uma Action: honeypot, Turnstile validado com segredos do astro:env, limite por IP e a CSP embutida do Astro.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/spam-protection/astro

Coloque as verificações numa Astro Action para a qual o formulário envia sem JavaScript: limite por `context.clientAddress`, valide o token do Turnstile com um segredo de `astro:env/server` e depois repasse os campos com o honeypot. A página de contato precisa renderizar sob demanda, então adicione um adapter e `prerender = false` nela. Se preferir manter o site todo estático, envie o formulário simples ao Ikiform e deixe que ele rode as mesmas verificações.

## O que muda no Astro

O Astro gera HTML estático por padrão, e HTML estático não guarda segredos. Um segredo do Turnstile, uma chave secreta do Ikiform e um contador de limite de taxa precisam de código que rode a cada requisição, então a primeira decisão num site Astro é se a página de contato vai renderizar sob demanda.

Diagrama: Escolhendo onde as verificações de spam rodam num site Astro

1. Decida como a página de contato renderiza.
2. Com suas próprias chaves do Turnstile, a página renderiza sob demanda por meio de um adapter.
3. O formulário envia a uma Astro Action, que limita por IP, valida o token e repassa os campos.
4. O Ikiform guarda a mensagem e pontua o honeypot.
5. Para manter o site estático, o formulário simples envia direto ao Ikiform, que roda o honeypot, a verificação Turnstile gerenciada e o limite de taxa.

Os dois caminhos barram os mesmos bots. O de cima dá a você seu próprio widget e seu próprio limite; o de baixo não publica código de servidor nenhum.

O Astro também confere por padrão a origem dos envios de formulário para páginas sob demanda, via `security.checkOrigin`. Isso impede que a página de outro site envie para a sua. Não impede um script, que pode mandar os cabeçalhos que quiser, então as verificações abaixo continuam valendo.

## Configuração do projeto

`Arquivos`:

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

Adicione um adapter com `npx astro add vercel`, `netlify` ou `cloudflare`. Declare cada variável no schema `astro:env`, para que um segredo ausente falhe na importação e um segredo nunca possa ser importado em código do cliente:

`.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"` mantém um valor fora do bundle. `astro:env/server` se recusa a carregar num script do cliente.
- `scriptDirective.resources` substitui os padrões do Astro, então liste `'self'` de novo ao lado da Cloudflare. O Astro continua gerando hashes dos próprios scripts.
- A CSP não roda em `astro dev`. Confira com `astro build` e `astro preview`.

## Um limite por IP

Numa Action, `context.clientAddress` é o IP do visitante como o adapter o recebe da plataforma, então você nunca precisa interpretar `X-Forwarded-For`. Ele só existe em rotas sob demanda e lança erro nas pré-renderizadas. Este contador mantém uma chave do Redis por IP por minuto, então funciona entre instâncias serverless:

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

Na Cloudflare, o Astro 7 lê bindings de `cloudflare:workers` em vez de `Astro.locals.runtime`. Um binding de rate limiting do Workers declarado em `wrangler.jsonc` substitui o Redis sem chamada de rede, ao custo de uma contagem aproximada por localidade:

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

## Valide o 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;
  }
}
```

- O siteverify aceita corpos form-encoded, então `URLSearchParams` não precisa de cabeçalhos.
- O hostname é comparado com `context.url.hostname`, o host para onde o visitante realmente enviou. Tokens de teste trazem um hostname fictício, então a verificação é pulada em `astro dev`.
- Um token vale por 300 segundos e funciona uma vez. Um envio de formulário simples recarrega a página, e o Turnstile emite um novo a cada vez.

## A 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 };
    },
  }),
};
```

- O Zod roda antes do handler, então um envio malformado não custa nenhuma chamada ao Redis. Os erros de campo voltam à página por `isInputError`.
- O honeypot `_gotcha` é opcional no schema e repassado sem alteração. O Ikiform pontua um preenchido como spam, então você ainda pode revisá-lo.
- O token é removido antes do repasse. O Ikiform não tem uso para um token que você já validou.
- O `Origin` do visitante vai junto porque um fetch do lado do servidor não envia nenhum, e o Ikiform o lê para reconhecer testes gratuitos em `localhost`.
- `TOO_MANY_REQUESTS` vira um `429` e `FORBIDDEN` um `403`.

> As versões do lado do servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana dele. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário enviado por um servidor e mantenha `IKIFORM_SECRET_KEY` em variáveis de ambiente só do servidor.

## A página

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

O formulário funciona sem nenhum JavaScript no cliente. `action={actions.sendMessage}` envia para a mesma página com o nome da action na query string; o Astro roda a action e `Astro.getActionResult()` lê o resultado durante a renderização. Só esta página renderiza sob demanda. O resto do site continua estático.

- Atualizar a página após um envio que falhou pede para reenviar o formulário. A documentação do Astro sugere o padrão POST, redirect, GET se isso incomodar, levando o resultado numa sessão.
- O script do Turnstile usa `is:inline`, então o Astro não mexe nele. Ele precisa ser carregado da URL da própria Cloudflare.
- Chamar a action a partir de um script do cliente numa página estática também funciona, mas aí você mesmo precisa chamar `turnstile.reset()` após cada tentativa.

## Deploy

| Host | Adapter | Onde ficam os segredos | Limite |
| --- | --- | --- | --- |
| Vercel | `@astrojs/vercel` | Variáveis de ambiente do projeto | Redis |
| Netlify | `@astrojs/netlify` | Variáveis de ambiente do site | Redis |
| Cloudflare | `@astrojs/cloudflare` | `wrangler secret put` | Binding de rate limiting do Workers |

Adicione seu hostname de produção ao widget do Turnstile antes do deploy, e também os hostnames de preview se testar neles.

## Mantenha estático e deixe o Ikiform verificar

A maioria dos sites Astro é estática, e adicionar um adapter só por causa de um formulário de contato tem um custo real. Sem um, envie o formulário direto ao seu endpoint do Ikiform:

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

Defina o **Spam check** do formulário como **Ikiform**. Um envio sem token recebe uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos, então a página não precisa de script, chaves nem mudança na CSP. Escolha o **Rate limit** Strict, 2 a cada 10 minutos por visitante, e adicione seu domínio em **Allowed domains**. O spam recebe a mesma resposta que uma mensagem real e fica guardado por 30 dias para você resgatar um falso positivo. O [guia de configuração do Astro](https://ikiform.com/pt-br/docs/guides/astro.md) mostra o resto.

## Confira se funciona

1. Rode `astro dev` com as chaves de teste e envie uma mensagem. Ela aparece em **Submissions**. Um workspace sem plano aceita 50 testes por mês a partir de `localhost`.
2. Deixe o nome vazio depois de remover o atributo `required` nas devtools. O erro do campo aparece embaixo do input.
3. Defina `TURNSTILE_SECRET_KEY` com o segredo que sempre falha e envie. A página mostra o erro da verificação humana.
4. Envie seis vezes em um minuto. O sexto responde `429` com a mensagem de limite.
5. Envie com o honeypot preenchido, `_gotcha=bot`. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook.
6. Rode `astro build` e `astro preview`, abra a página e confira se o console não mostra violações de CSP.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `clientAddress` não está disponível em páginas pré-renderizadas | A página ou a action rodou em tempo de build. Adicione `export const prerender = false` à página de contato. |
| O resultado da action é sempre undefined | O formulário enviou para uma página pré-renderizada. Form actions só rodam em páginas renderizadas sob demanda. |
| O widget fica em branco em produção, mas funciona em dev | A CSP o bloqueia. `scriptDirective.resources` precisa listar `https://challenges.cloudflare.com`, e `frame-src` também precisa permiti-lo. |
| `403` sem mensagem em todo envio a partir de um domínio personalizado | `security.checkOrigin` viu uma origem diferente, muitas vezes um proxy reescrevendo o host. Garanta que o cabeçalho host que chega ao Astro bata com o domínio da página. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi guardado como spam. Spam não dispara nada. Abra-o no filtro **Spam** para ver os motivos e marque-o como não spam para disparar o e-mail e os webhooks. |

## Perguntas

### Astro Actions funcionam num site estático?

Não como form action. Um formulário que envia a uma action precisa da página renderizada sob demanda por meio de um adapter. Num site totalmente estático, envie o formulário a um backend de formulários como o Ikiform.

### Como obtenho o IP do visitante no Astro?

Use context.clientAddress numa Action ou Astro.clientAddress numa página. O adapter o preenche a partir da plataforma. Ele só existe em rotas renderizadas sob demanda.

### A CSP do Astro suporta o Turnstile?

Sim, com security.csp, estável desde o Astro 6. Adicione https://challenges.cloudflare.com a scriptDirective.resources ao lado de 'self', e adicione uma diretiva frame-src para o mesmo host. Teste com um build de produção, já que ela não roda em dev.

### O Ikiform é gratuito?

Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.

## Mais guias

- [Acabe com o spam no formulário de contato](https://ikiform.com/pt-br/guides/spam-protection.md): Acabe com o spam no formulário de contato com camadas que não custam nada a pessoas reais: honeypot, tempo, pontuação de conteúdo, limites de taxa e verificação humana só quando precisar.
- [Sites estáticos](https://ikiform.com/pt-br/guides/spam-protection/static-site.md): Acabe com o spam num formulário de site estático mesmo sem guardar segredos nem contar envios, com um partial de honeypot, um script seguro e verificações no servidor.
- [Cloudflare Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md): Adicione o Cloudflare Turnstile a um formulário HTML simples: o widget, o siteverify no servidor, chaves de teste e um jeito de rodá-lo sem chaves nem servidor próprio.
- [Limite de taxa](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md): Limite a taxa de envios por visitante com um armazenamento compartilhado, responda 429 com Retry-After e evite bloquear escritórios que compartilham um IP.
- [Campo honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md): Adicione a qualquer formulário um honeypot que pega bots sem atrapalhar leitores de tela, teclados ou preenchimento automático, com a verificação no servidor para cada stack.
- [Sem captcha](https://ikiform.com/pt-br/guides/spam-protection/without-captcha.md): Barre a maior parte do spam em formulários sem captcha nenhum: honeypot, horário de renderização assinado, pontuação de conteúdo e limite de taxa, somados em vez de usados sozinhos.
