Frena el spam en formularios de contacto8 min de lectura
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.
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.
- La form action pide la dirección del cliente al request event.
- SvelteKit delega la respuesta en el adapter con el que despliegas.
- adapter-node usa la dirección remota del socket salvo que definas ADDRESS_HEADER y XFF_DEPTH.
- Los adapters de Vercel, Netlify y Cloudflare leen la dirección que da su plataforma, así que no hay nada que configurar.
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
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_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 { 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 tienepublic: true, así que es la única que$app/env/publicpuede pasar al navegador. Nada de un directorioserver, comosrc/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.
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
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.
resetvuelve en milisegundos y se convierte en segundos enteros de espera. devde$app/envomite la comprobación del hostname en local, porque los tokens de prueba llevan un hostname ficticio.- Siteverify responde
timeout-or-duplicatepara un token de más de 300 segundos o ya usado, y por eso la página reinicia el widget.
La form action
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
FormDataoriginal, honeypot incluido, sin el token. Ikiform puntúa un honeypot relleno y guarda el mensaje como spam. - El
Origindel visitante se reenvía, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desdelocalhost.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El widget como action de Svelte
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
<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.resetTurnstilese 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
.hpoculta el honeypot fuera de pantalla, como en la guía del honeypot.
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:
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildEnví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:
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1La 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 cubre el endpoint y las claves.
Comprueba que funciona
- Ejecuta
vite devcon las claves de prueba y envía un mensaje. Aparece en Submissions. Un espacio de trabajo sin plan acepta 50 pruebas desdelocalhostal mes. - Envía un segundo mensaje sin recargar. Funciona, lo que demuestra que el reinicio del widget funcionó.
- Envía seis mensajes en un minuto. El sexto muestra cuántos segundos esperar.
- Detrás de tu proxy, registra
getClientAddress()desde dos redes. Ves dos direcciones distintas, no la de tu proxy. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.