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

> Proteja um formulário SvelteKit contra spam: form action com getClientAddress, limite por IP, Turnstile com use:enhance e uma CSP que o permita.

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

Faça as verificações numa form action: limite por `getClientAddress()`, valide o token do Turnstile com o siteverify e depois repasse os campos com o honeypot. Renderize o widget com uma Svelte action e reinicie-o no callback do `use:enhance`, porque cada token funciona uma vez. Permita `challenges.cloudflare.com` na opção `csp` do plugin `sveltekit()` e, no `adapter-node`, diga ao SvelteKit qual cabeçalho do proxy traz o IP real.

## O que muda no SvelteKit

O SvelteKit entrega o IP do visitante diretamente: `getClientAddress()` no evento da requisição. De onde vem esse valor depende do adapter, e no `adapter-node` o padrão está errado atrás de qualquer proxy. Acerte isso e o resto das verificações se encaixa direitinho numa form action.

Diagrama: De onde getClientAddress() tira o IP do visitante em cada adapter

1. A form action pede ao evento da requisição o endereço do cliente.
2. O SvelteKit delega a resposta ao adapter com que você faz o deploy.
3. O adapter-node usa o endereço remoto do socket, a menos que você defina ADDRESS_HEADER e XFF_DEPTH.
4. Os adapters da Vercel, Netlify e Cloudflare leem o endereço que a plataforma fornece, então não há nada a configurar.

Atrás do nginx ou de um load balancer, o adapter-node vê o endereço do proxy para todo visitante até você definir ADDRESS_HEADER, e um endereço compartilhado transforma um limite por IP num limite do site inteiro.

O outro detalhe do SvelteKit é o `use:enhance`. Ele envia sem recarregar a página, o que é melhor para as pessoas, mas significa que o Turnstile nunca renderiza de novo sozinho. O widget precisa ser reiniciado à mão após cada resposta.

## Configuração do projeto

`Arquivos`:

```text
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
```

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

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
```

- O SvelteKit 3 lê as variáveis que você declara em `src/env.ts`. Só a site key tem `public: true`, então é a única que `$app/env/public` pode entregar ao navegador. Nada num diretório `server`, como `src/lib/server`, pode ser importado por código do cliente; o SvelteKit falha o build se você tentar.
- Variáveis declaradas são lidas quando a aplicação inicia, a menos que você defina `static: true`, então uma chave trocada exige reiniciar, não refazer o build. Os módulos `$env/*` ainda funcionam, mas estão obsoletos no SvelteKit 3.
- As chaves de teste da Cloudflare passam em qualquer host, inclusive `localhost`.

`vite.config.ts`:

```ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});
```

O SvelteKit 3 recebe as opções em `vite.config.ts`, passadas ao plugin `sveltekit()`; o `svelte.config.js` não é mais lido. Com `mode: "auto"`, o SvelteKit adiciona nonces aos próprios scripts inline em páginas renderizadas no servidor e hashes nas pré-renderizadas, então a política não precisa de `'unsafe-inline'`. O Turnstile carrega do host dele, que `script-src` permite, e desenha um iframe que `frame-src` permite.

## Os helpers só do servidor

`src/lib/server/spam.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

const limiter = new Ratelimit({
  redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
```

- O limite é uma janela deslizante no Redis, compartilhada por todas as instâncias do servidor, cinco mensagens por minuto por IP. `reset` volta em milissegundos e vira segundos inteiros de espera.
- `dev` de `$app/env` pula a verificação de hostname localmente, porque tokens de teste trazem um hostname fictício.
- O siteverify responde `timeout-or-duplicate` para um token com mais de 300 segundos ou já usado, e é por isso que a página reinicia o widget.

## A form action

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
```

- O limite roda antes de o corpo ser interpretado. `fail(429)` mantém o visitante na página com a mensagem.
- Toda outra falha retorna `values`, então os campos voltam preenchidos após um erro.
- O `FormData` original é repassado, honeypot incluído, sem o token. O Ikiform pontua um honeypot preenchido e guarda a mensagem como spam.
- O `Origin` do visitante é repassado, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em `localhost`.

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

## O widget como uma Svelte action

`src/lib/turnstile.ts`:

```ts
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface Api {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = SCRIPT;
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}
```

Uma Svelte action é o lugar natural para um widget de terceiros: ela roda quando o elemento é montado, e `destroy` roda quando ele sai. O ID do widget fica no elemento, então a página pode reiniciar exatamente aquele widget.

## A página

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
    <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 bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

- `update({ reset: false })` aplica o resultado e mantém o que o visitante digitou. O padrão limparia o formulário, como faz um navegador.
- `resetTurnstile` roda após cada resposta, com sucesso ou não. O token usado some e um novo fica pronto.
- Sem JavaScript, o formulário ainda envia e a action ainda roda, mas nenhum widget é renderizado, então o siteverify o recusa com uma mensagem clara.
- Uma página com form actions não pode ser pré-renderizada, então esta rota sempre renderiza no servidor. As outras páginas podem continuar pré-renderizadas.
- A classe `.hp` esconde o honeypot fora da tela, como no [guia do honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md).

## Teste localmente

Em `vite dev` não há adapter na frente da aplicação, então `getClientAddress()` retorna seu endereço de loopback e todas as abas locais compartilham um único bucket. Isso serve para conferir a mensagem, não a configuração do proxy. Para testar isso, faça o build com `adapter-node` e rode a saída com as mesmas variáveis que você vai usar em produção:

`Terminal`:

```bash
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build
```

Envie algumas vezes com valores diferentes de `X-Forwarded-For`. Com `XFF_DEPTH=1`, o SvelteKit lê o endereço mais à direita, aquele que seu proxy adicionaria, então um valor que o cliente inventa à esquerda não muda nada.

## Deploy

No seu próprio servidor com `adapter-node` atrás de um proxy, diga ao SvelteKit onde está o IP real:

`Ambiente`:

```bash
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1
```

A documentação do SvelteKit avisa que cabeçalhos são fáceis de falsificar e que ler o endereço mais à esquerda de `X-Forwarded-For` é o erro clássico. `XFF_DEPTH` conta proxies confiáveis a partir da direita, então defina-o com o número de proxies que você roda e garanta que a aplicação não possa ser acessada contornando-os. O SvelteKit 3 removeu a variável `ORIGIN` do adapter-node. Se seu proxy não repassa o host original, defina `paths.origin` nas opções do `sveltekit()` para que a verificação de formulário do próprio SvelteKit conheça sua URL pública.

| Adapter | IP do cliente | Segredos |
| --- | --- | --- |
| `adapter-node` | `ADDRESS_HEADER` e `XFF_DEPTH` | Ambiente do processo |
| `adapter-vercel` | Automático | Variáveis de ambiente do projeto |
| `adapter-netlify` | Automático | Variáveis de ambiente do site |
| `adapter-cloudflare` | Automático | `wrangler secret put` |

## Deixe o Ikiform rodar as verificações

Para não rodar nada disso você mesmo, apague `+page.server.ts` e aponte o `action` do formulário para o seu endpoint do Ikiform, sem `use:enhance`. Aí a página pode ser pré-renderizada como o resto de um site estático.

Defina o **Spam check** do formulário como **Ikiform**, para que um envio sem token receba uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos. Escolha o **Rate limit** Strict, 2 a cada 10 minutos por visitante, e liste seu domínio em **Allowed domains**. O Ikiform guarda o spam barrado por 30 dias, então um falso positivo se resolve com um clique para restaurar. O [guia de configuração do SvelteKit](https://ikiform.com/pt-br/docs/guides/sveltekit.md) cobre o endpoint e as chaves.

## Confira se funciona

1. Rode `vite 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. Envie uma segunda mensagem sem recarregar. Ela funciona, o que mostra que o reinício do widget deu certo.
3. Envie seis mensagens em um minuto. A sexta mostra quantos segundos esperar.
4. Atrás do seu proxy, registre `getClientAddress()` a partir de duas redes. Você vê dois endereços diferentes, não o do seu proxy.
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.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| Todos os visitantes são limitados juntos no `adapter-node` | `getClientAddress()` retorna o endereço do seu proxy. Defina `ADDRESS_HEADER` e `XFF_DEPTH`. |
| O segundo envio sempre falha na verificação humana | O widget manteve o token já usado. Chame `resetTurnstile` no callback do `use:enhance` depois de `update`. |
| O build falha porque uma página com actions está pré-renderizada | A rota de contato herdou `prerender = true` de um layout. Páginas com actions não podem ser pré-renderizadas; defina `export const prerender = false` no `+page.server.ts` dela. |
| A área do widget fica vazia e o console aponta uma violação de CSP | Falta `https://challenges.cloudflare.com` em `script-src` ou `frame-src` em `csp.directives` no `vite.config.ts`. |

## Perguntas

### Como obtenho o IP do cliente no SvelteKit?

Chame getClientAddress() no evento da requisição numa form action, função load ou hook. O adapter o fornece. No adapter-node atrás de um proxy, defina ADDRESS_HEADER e XFF_DEPTH, senão você recebe o endereço do proxy.

### Por que o Turnstile falha no segundo envio com use:enhance?

O use:enhance não recarrega a página, então o widget mantém o token que já usou. Reinicie o widget no callback do enhance após cada resposta.

### A CSP do SvelteKit funciona com o Turnstile?

Sim. Adicione https://challenges.cloudflare.com a script-src e frame-src em csp.directives, que o SvelteKit 3 recebe nas opções do plugin sveltekit() em vite.config.ts. Com mode auto, o SvelteKit adiciona nonces ou hashes aos próprios scripts.

### 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.
- [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.
- [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.
- [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.
- [Alternativas ao reCAPTCHA](https://ikiform.com/pt-br/guides/spam-protection/recaptcha-alternatives.md): Compare alternativas ao reCAPTCHA para formulários, do Turnstile e hCaptcha a prova de trabalho e nenhum captcha, e migre com uma mudança pequena e segura.
