# Como limitar a taxa de envios de formulário

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

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

Conte os envios por IP do visitante num armazenamento que todas as instâncias do servidor compartilham, como o Redis, permita uma pequena rajada e responda `429` com um cabeçalho `Retry-After` quando a contagem acabar. Um contador em memória só funciona num servidor único de longa duração, nunca em hosts serverless. Defina o limite pelo jeito como pessoas reais usam o formulário e deixe folga para escritórios e eventos em que muita gente compartilha um IP.

## O que os limites de taxa têm de diferente

Um limite de taxa não distingue spam de mensagem legítima. Ele barra volume: um script enviando em loop, um bot circulando pelo seu formulário ou uma pessoa martelando o botão. Isso o torna a camada mais barata, já que roda antes de você ler o corpo, e a mais bruta, já que não distingue um atacante de um escritório movimentado. Três coisas decidem se ele ajuda ou atrapalha: onde fica a contagem, pelo que você conta e com que rapidez a cota volta.

Diagrama: Uma rajada de envios de um visitante contra um limite suave por visitante

1. Um visitante manda 3 envios em 10 segundos. Todos são aceitos.
2. O seguinte é recusado com 429 e um cabeçalho Retry-After dizendo quanto esperar.
3. Depois disso, mais um envio é permitido a cada 12 segundos.
4. Após um minuto inteiro de silêncio, a cota volta por completo.

Estes são os números do Standard do Ikiform: 5 por minuto, no máximo 3 a cada 10 segundos. Uma recarga suave significa que ninguém espera uma janela inteira por mais um envio.

## Onde fica a contagem

| Armazenamento | Funciona em | Cuidado com |
| --- | --- | --- |
| Um `Map` em memória | Um servidor único de longa duração | Some ao reiniciar. Cada instância serverless tem seu próprio map, então o limite real é desconhecido |
| Redis, como o Upstash | Qualquer lugar, inclusive serverless e edge | Uma chamada de rede por envio. Escolha uma região perto do seu servidor |
| Binding de rate limiting do Cloudflare Workers | Workers | Aproximado e contado por localidade da Cloudflare, só com períodos de 10 ou 60 segundos |
| Um proxy reverso, como o `limit_req` do nginx | Servidores atrás do seu próprio proxy | Conta por proxy. Precisa do IP real do cliente |

O algoritmo importa menos que o armazenamento, mas molda o que os visitantes sentem. Uma janela fixa zera na virada do minuto, então um script pode mandar uma cota inteira no fim de um minuto e outra no começo do seguinte. Uma janela deslizante suaviza essa borda pesando a janela anterior. Um token bucket, ou o algoritmo parecido que o Ikiform usa, recarrega um envio por vez, o que deixa quem cometeu um erro de digitação enviar de novo poucos segundos depois sem abrir a porta para uma rajada. Para um formulário de contato, qualquer um deles funciona se os números combinarem com o uso real.

Os exemplos abaixo usam o `@upstash/ratelimit` do Upstash, porque funciona igual em todo framework e em hosts serverless. Defina `UPSTASH_REDIS_REST_URL` e `UPSTASH_REDIS_REST_TOKEN` e salve o helper uma vez:

`lib/limit.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

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

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}
```

`reset` é um horário Unix em milissegundos, então o helper o converte em segundos inteiros para o `Retry-After`. Envios sem IP compartilham um único bucket, o que impede que um cabeçalho ausente vire passe livre.

## Aplique no seu framework

**HTML**

Um formulário HTML simples não tem servidor para contar nada. Envie-o ao Ikiform e escolha um preset no formulário; um visitante acima do limite vê uma breve página pedindo para ir mais devagar em vez de um erro.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

**Next.js**

Confira o limite no topo da server action, antes de qualquer outro trabalho.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/limit";

export async function sendMessage(_previous: { message: string }, formData: FormData) {
  const forwarded = (await headers()).get("x-forwarded-for");
  const limit = await allow(forwarded?.split(",")[0]?.trim());
  if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
  await saveMessage(Object.fromEntries(formData));
  return { message: "Thanks. We'll reply by email." };
}
```

**Astro**

`context.clientAddress` dá o IP do visitante numa página sob demanda. `TOO_MANY_REQUESTS` vira um `429`.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({ email: z.email(), message: z.string().min(1) }),
    handler: async (input, context) => {
      const limit = await allow(context.clientAddress);
      if (!limit.ok) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
      }
      await saveMessage(input);
      return { sent: true };
    },
  }),
};
```

**TanStack Start**

Defina o status e o `Retry-After` na server function, para que quem chama com `fetch` possa lê-los.

`src/server/send-message.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";

export const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
  })
  .handler(async ({ data }) => {
    const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
    if (!limit.ok) {
      setResponseStatus(429);
      setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
      return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
    }
    await saveMessage(data);
    return { ok: true, message: "Thanks. We'll reply by email." };
  });
```

**React Router**

`data()` permite que a action retorne um `429` com cabeçalhos junto com a mensagem para o formulário.

`app/routes/contact.tsx`:

```ts
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
  if (!limit.ok) {
    return data(
      { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
      { status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
    );
  }
  await saveMessage(Object.fromEntries(await request.formData()));
  return { message: "Thanks. We'll reply by email." };
}
```

**SvelteKit**

`getClientAddress()` lê o IP pelo seu adapter. `fail(429)` mantém o formulário na página com a mensagem. Salve o helper como `src/lib/limit.ts`; o SvelteKit 3 o importa pelo alias `#lib` em `package.json`.

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

```ts
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress }) => {
    const limit = await allow(getClientAddress());
    if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
    await saveMessage(Object.fromEntries(await request.formData()));
    return { sent: true };
  },
} satisfies Actions;
```

**Nuxt**

`getRequestIP` com `xForwardedFor` lê o IP do cliente atrás de um proxy. Salve o helper em `server/utils` para importá-lo automaticamente.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
  if (!limit.ok) {
    setResponseHeader(event, "Retry-After", limit.retryAfter);
    throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
  }
  await saveMessage(await readBody(event));
  return { sent: true };
});
```

**React**

Um app de página única não consegue impor um limite, mas deve respeitá-lo. Leia o `Retry-After` e faça uma contagem regressiva em vez de deixar as pessoas apertarem o botão de novo. Um `429 quota_exceeded` não tem `Retry-After`, então cai na mensagem de erro.

`contact.js`:

```js
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  });
  const retryAfter = response.headers.get("Retry-After");
  if (response.status === 429 && retryAfter) {
    let wait = Number(retryAfter);
    button.disabled = true;
    const timer = setInterval(() => {
      status.textContent = `Too many messages. Try again in ${wait} seconds.`;
      wait -= 1;
      if (wait < 0) {
        clearInterval(timer);
        button.disabled = false;
        status.textContent = "";
      }
    }, 1000);
    return;
  }
  const result = await response.json();
  status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});
```

**Vue**

O mesmo tratamento num componente Vue: leia o `Retry-After`, desative o botão e faça a contagem regressiva num ref.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;

function countDown(seconds: number) {
  wait.value = seconds;
  clearInterval(timer);
  timer = setInterval(() => {
    wait.value -= 1;
    if (wait.value <= 0) clearInterval(timer);
  }, 1000);
}

async function send(event: SubmitEvent) {
  notice.value = "";
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(event.target as HTMLFormElement),
  });
  const retryAfter = Number(response.headers.get("Retry-After"));
  if (response.status === 429 && retryAfter > 0) {
    countDown(retryAfter);
    return;
  }
  const result = await response.json();
  notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}

onBeforeUnmount(() => clearInterval(timer));
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" required />
    <p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
    <button :disabled="wait > 0">Send</button>
  </form>
</template>
```

No Cloudflare Workers, o binding embutido é a opção mais barata:

`wrangler.toml`:

```toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
```

`src/index.ts`:

```ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });
```

A Cloudflare documenta esse binding como aproximado e contado separadamente em cada localidade, então um ataque espalhado pode ultrapassá-lo. Para um limite exato entre localidades, a Cloudflare recomenda um Durable Object. Atrás do seu próprio nginx, o `limit_req` faz a contagem antes de sua aplicação ver o envio:

`nginx.conf`:

```text
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}
```

## Pelo que contar

- **IP e formulário juntos.** A escolha usual. Um visitante não consegue inundar este formulário, e usar outro formulário do seu site não gasta a mesma cota.
- **Muitas pessoas, um IP.** Escritórios, escolas, operadoras de celular e Wi-Fi de conferência colocam muita gente atrás de um endereço. Mantenha o limite por IP folgado o bastante para elas, ou aumente-o em dias de evento.
- **O IP real do cliente.** Atrás de um proxy ou CDN, leia o cabeçalho que seu host define e confie só nele. Um valor bruto de `X-Forwarded-For` pode ser definido pelo cliente.
- **O IP do seu próprio servidor.** Se seu servidor repassa envios a outro serviço, todo visitante compartilha o IP do seu servidor lá. Limite do seu lado, pelo IP do visitante, antes de repassar.

## Os limites de taxa do Ikiform

O Ikiform conta cada envio com exatidão, por IP do visitante e formulário, antes de ler o corpo, com recarga suave em vez de janelas fixas. Cada formulário escolhe um preset em **Rate limit**. Uma proteção contra enxurradas de 600 por minuto por IP em todos os formulários roda antes de qualquer um deles.

| Preset | Por visitante | Indicado para |
| --- | --- | --- |
| Standard | 5 por minuto, no máximo 3 a cada 10 segundos | A maioria dos formulários |
| Relaxed | 20 por minuto | Eventos em Wi-Fi compartilhado, formulários atrás de login |
| Strict | 2 a cada 10 minutos, mais um a cada 5 minutos | Formulários de contato e de vendas |
| Custom | De 1 a 1.000 por 10 segundos, minuto, 10 minutos, hora ou dia | Uma inscrição por pessoa, quiosques |

O formulário inteiro também aceita 300 por minuto somando todos os visitantes, a menos que você defina um teto Custom. Acima do limite, um envio pelo navegador vê uma página pedindo para ir mais devagar e o `fetch` recebe `429 rate_limited` com `Retry-After`, que o Ikiform expõe via CORS para o código acima poder lê-lo. A [documentação de limites de taxa](https://ikiform.com/pt-br/docs/rate-limits.md#refill) explica a recarga.

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

## Confira se funciona

1. Envie o formulário várias vezes seguidas. Passado o limite, você recebe `429` com um cabeçalho `Retry-After`, ou a página do Ikiform pedindo para ir mais devagar.
2. Espere os segundos indicados em `Retry-After` e envie de novo. O envio é aceito.
3. Envie de uma segunda rede, como seu celular fora do Wi-Fi. Ela tem sua própria cota.
4. Se seu servidor repassa envios, envie rapidamente de dois aparelhos e confira que nenhum compartilha a contagem do outro.

## Pegadinhas

- **Envios recusados não devem gastar cota.** Conte antes do trabalho e não deixe um envio recusado empurrar a janela para a frente, senão um visitante ansioso se tranca do lado de fora.
- **Não limite o carregamento da página.** Conte envios, não visualizações do formulário, senão uma pessoa recarregando gasta seus envios.
- **`429 rate_limited`** Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o `Retry-After`, ou escolha Relaxed ou Custom para formulários que enviam de um IP compartilhado.
- **IPv6.** Uma pessoa pode ter muitos endereços IPv6. Para um limite rígido, conte pelo prefixo `/64` em vez do endereço completo.

## Perguntas

### Quantos envios de formulário por minuto devo permitir?

Para um formulário de contato, um punhado por visitante é bastante: o preset Strict do Ikiform permite 2 a cada 10 minutos. Formulários de inscrição e de eventos usados em redes compartilhadas precisam de mais, uns 20 por minuto.

### Posso limitar a taxa em memória na Vercel ou na Netlify?

Não de forma confiável. Cada instância serverless tem sua própria memória e as instâncias vêm e vão, então a contagem zera aleatoriamente. Use um armazenamento compartilhado como o Redis.

### O que um formulário com taxa limitada deve mostrar?

Uma mensagem simples dizendo quanto esperar, tirada do cabeçalho Retry-After, com o botão de envio desativado até lá. Não apague o que a pessoa digitou.

### O Ikiform limita a taxa dos formulários por padrão?

Sim. Todo formulário começa no preset Standard, 5 por minuto por visitante, e você pode mudar para Relaxed, Strict ou Custom por formulário.

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