# Protección antispam para formularios de SvelteKit con Turnstile

> Protege un formulario de SvelteKit del spam: una form action con getClientAddress, un límite por IP, Turnstile con use:enhance y una CSP que lo permite.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/spam-protection/sveltekit

Haz las comprobaciones en una form action: limita por `getClientAddress()`, verifica el token de Turnstile con siteverify y luego reenvía los campos con el honeypot. Renderiza el widget con una action de Svelte y reinícialo en el callback de `use:enhance`, porque cada token sirve una vez. Permite `challenges.cloudflare.com` en la opción `csp` del plugin `sveltekit()`, y en `adapter-node` indica a SvelteKit qué cabecera del proxy lleva la IP real.

## Qué cambia en SvelteKit

SvelteKit te da directamente la IP del visitante: `getClientAddress()` en el request event. De dónde sale ese valor depende del adapter, y en `adapter-node` el valor por defecto es incorrecto detrás de cualquier proxy. Resuelve eso y el resto de las comprobaciones encajan sin problema en una form action.

Diagrama: De dónde saca getClientAddress() la IP del visitante en cada adapter

1. La form action pide la dirección del cliente al request event.
2. SvelteKit delega la respuesta en el adapter con el que despliegas.
3. adapter-node usa la dirección remota del socket salvo que definas ADDRESS_HEADER y XFF_DEPTH.
4. Los adapters de Vercel, Netlify y Cloudflare leen la dirección que da su plataforma, así que no hay nada que configurar.

Detrás de nginx o un balanceador de carga, adapter-node ve la dirección del proxy para todos los visitantes hasta que defines ADDRESS_HEADER, y una dirección compartida convierte un límite por IP en uno para todo el sitio.

El otro detalle de SvelteKit es `use:enhance`. Envía sin recargar la página, lo que es más cómodo para las personas pero significa que Turnstile nunca se vuelve a renderizar por sí solo. Hay que reiniciar el widget a mano después de cada respuesta.

## Configuración del proyecto

`Archivos`:

```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: {},
});
```

- SvelteKit 3 lee las variables que declaras en `src/env.ts`. Solo la site key tiene `public: true`, así que es la única que `$app/env/public` puede pasar al navegador. Nada de un directorio `server`, como `src/lib/server`, puede importarse desde código de cliente; SvelteKit hace fallar el build si lo intentas.
- Las variables declaradas se leen al arrancar la app salvo que pongas `static: true`, así que una clave rotada necesita un reinicio, no un nuevo build. Los módulos `$env/*` siguen funcionando pero están obsoletos en SvelteKit 3.
- Las claves de prueba de Cloudflare pasan en cualquier host, incluido `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"],
        },
      },
    }),
  ],
});
```

SvelteKit 3 recibe sus opciones en `vite.config.ts`, pasadas al plugin `sveltekit()`; `svelte.config.js` ya no se lee. Con `mode: "auto"`, SvelteKit añade nonces a sus propios scripts en línea en las páginas renderizadas en el servidor y hashes en las prerenderizadas, así que la política no necesita `'unsafe-inline'`. Turnstile se carga desde su host, que `script-src` permite, y dibuja un iframe que `frame-src` permite.

## Los helpers solo de 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;
  }
}
```

- El límite es una ventana deslizante en Redis, compartida por todas las instancias del servidor: cinco mensajes por minuto por IP. `reset` vuelve en milisegundos y se convierte en segundos enteros de espera.
- `dev` de `$app/env` omite la comprobación del hostname en local, porque los tokens de prueba llevan un hostname ficticio.
- Siteverify responde `timeout-or-duplicate` para un token de más de 300 segundos o ya usado, y por eso la página reinicia el widget.

## La 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;
```

- El límite se ejecuta antes de leer el cuerpo. `fail(429)` mantiene al visitante en la página con el mensaje.
- Cualquier otro fallo devuelve `values`, así que los inputs se vuelven a rellenar tras un error.
- Se reenvía el `FormData` original, honeypot incluido, sin el token. Ikiform puntúa un honeypot relleno y guarda el mensaje como spam.
- El `Origin` del visitante se reenvía, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde `localhost`.

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## El widget como action de Svelte

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

Una action de Svelte es el lugar natural para un widget de terceros: se ejecuta cuando se monta el elemento, y `destroy` se ejecuta cuando sale. El ID del widget vive en el elemento, así que la página puede reiniciar exactamente ese widget.

## La 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 el resultado y conserva lo que escribió el visitante. Por defecto se vaciaría el formulario, como hace un navegador.
- `resetTurnstile` se ejecuta después de cada respuesta, con éxito o no. El token usado desaparece y hay uno nuevo listo.
- Sin JavaScript, el formulario se envía igualmente y la action se ejecuta, pero no se renderiza ningún widget, así que siteverify lo rechaza con un mensaje claro.
- Una página con form actions no puede prerenderizarse, así que esta ruta siempre se renderiza en el servidor. Las demás páginas pueden seguir prerenderizadas.
- La clase `.hp` oculta el honeypot fuera de pantalla, como en la [guía del honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md).

## Pruébalo en local

Con `vite dev` no hay adapter delante de la app, así que `getClientAddress()` devuelve tu dirección de loopback y todas las pestañas locales comparten un mismo bucket. Sirve para comprobar el mensaje, no la configuración del proxy. Para probar eso, haz el build con `adapter-node` y ejecuta el resultado con las mismas variables que usarás en producción:

`Terminal`:

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

Envía varias veces con distintos valores de `X-Forwarded-For`. Con `XFF_DEPTH=1`, SvelteKit lee la dirección de más a la derecha, la que añadiría tu proxy, así que un valor que el cliente invente a la izquierda no cambia nada.

## Despliegue

En tu propio servidor con `adapter-node` detrás de un proxy, indica a SvelteKit dónde está la IP real:

`Entorno`:

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

La documentación de SvelteKit advierte que las cabeceras son fáciles de falsificar y que leer la dirección de más a la izquierda de `X-Forwarded-For` es el error clásico. `XFF_DEPTH` cuenta los proxies de confianza desde la derecha, así que ponle el número de proxies que tienes, y asegúrate de que no se pueda llegar a la app sin pasar por ellos. SvelteKit 3 eliminó la variable `ORIGIN` de adapter-node. Si tu proxy no pasa el host original, define `paths.origin` en las opciones de `sveltekit()` para que la comprobación de formularios propia de SvelteKit conozca tu URL pública.

| Adapter | IP del cliente | Secretos |
| --- | --- | --- |
| `adapter-node` | `ADDRESS_HEADER` y `XFF_DEPTH` | Entorno del proceso |
| `adapter-vercel` | Automático | Variables de entorno del proyecto |
| `adapter-netlify` | Automático | Variables de entorno del sitio |
| `adapter-cloudflare` | Automático | `wrangler secret put` |

## Deja que Ikiform haga las comprobaciones

Para no gestionar nada de esto tú, elimina `+page.server.ts` y apunta el `action` del formulario a tu endpoint de Ikiform, sin `use:enhance`. La página puede entonces prerenderizarse como el resto de un sitio estático.

Pon el **Spam check** del formulario en **Ikiform**, para que un envío sin token reciba una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos. Elige el **Rate limit** Strict, 2 cada 10 minutos por visitante, e indica tu dominio en **Allowed domains**. Ikiform conserva el spam atrapado durante 30 días, así que un falso positivo se arregla con un clic para restaurarlo. La [guía de configuración de SvelteKit](https://ikiform.com/es/docs/guides/sveltekit.md) cubre el endpoint y las claves.

## Comprueba que funciona

1. Ejecuta `vite dev` con las claves de prueba y envía un mensaje. Aparece en **Submissions**. Un espacio de trabajo sin plan acepta 50 pruebas desde `localhost` al mes.
2. Envía un segundo mensaje sin recargar. Funciona, lo que demuestra que el reinicio del widget funcionó.
3. Envía seis mensajes en un minuto. El sexto muestra cuántos segundos esperar.
4. Detrás de tu proxy, registra `getClientAddress()` desde dos redes. Ves dos direcciones distintas, no la de tu proxy.
5. Envía con el honeypot relleno, `_gotcha=bot`. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.

## Trampas habituales

| Problema | Qué hacer |
| --- | --- |
| Todos los visitantes comparten el límite de frecuencia en `adapter-node` | `getClientAddress()` devuelve la dirección de tu proxy. Define `ADDRESS_HEADER` y `XFF_DEPTH`. |
| El segundo envío siempre falla la verificación humana | El widget conservó su token usado. Llama a `resetTurnstile` en el callback de `use:enhance` después de `update`. |
| El build falla porque una página con actions está prerenderizada | La ruta de contacto heredó `prerender = true` de un layout. Las páginas con actions no pueden prerenderizarse; pon `export const prerender = false` en su `+page.server.ts`. |
| La zona del widget está vacía y la consola muestra una infracción de CSP | A `script-src` o a `frame-src` les falta `https://challenges.cloudflare.com` en `csp.directives` en `vite.config.ts`. |

## Preguntas

### ¿Cómo obtengo la IP del cliente en SvelteKit?

Llama a getClientAddress() en el request event dentro de una form action, una función load o un hook. La proporciona el adapter. En adapter-node detrás de un proxy, define ADDRESS_HEADER y XFF_DEPTH o recibirás la dirección del proxy.

### ¿Por qué Turnstile falla en el segundo envío con use:enhance?

use:enhance no recarga la página, así que el widget conserva el token que ya usó. Reinicia el widget en el callback de enhance después de cada respuesta.

### ¿La CSP de SvelteKit funciona con Turnstile?

Sí. Añade https://challenges.cloudflare.com a script-src y frame-src en csp.directives, que SvelteKit 3 recibe en las opciones del plugin sveltekit() en vite.config.ts. Con el modo auto, SvelteKit añade nonces o hashes a sus propios scripts.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
- [Cloudflare Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md): Añade Cloudflare Turnstile a un formulario HTML básico: el widget, siteverify en el servidor, claves de prueba y una forma de usarlo sin claves ni servidor propios.
- [Límites de frecuencia](https://ikiform.com/es/guides/spam-protection/rate-limiting.md): Limita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.
- [Sin captcha](https://ikiform.com/es/guides/spam-protection/without-captcha.md): Frena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.
- [Campo honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md): Añade a cualquier formulario un honeypot que atrape bots sin molestar a lectores de pantalla, teclado ni autocompletado, con la comprobación en el servidor para cada stack.
- [Alternativas a reCAPTCHA](https://ikiform.com/es/guides/spam-protection/recaptcha-alternatives.md): Compara alternativas a reCAPTCHA para formularios, de Turnstile y hCaptcha a proof-of-work o ningún captcha, y cambia con un ajuste pequeño y seguro.
