# Perlindungan spam formulir Nuxt dengan Turnstile

> Lindungi formulir Nuxt dari spam: server route Nitro dengan Turnstile, limit per IP di Nitro storage, secret di runtimeConfig, dan CSP yang diatur di routeRules.

Terbit 2026-10-25 · https://ikiform.com/id/guides/spam-protection/nuxt

Kirim formulir dengan `$fetch` ke server route yang menghitung percobaan per IP client di Nitro storage, memverifikasi token Turnstile dengan siteverify, dan meneruskan field-nya bersama honeypot. Simpan secret di `runtimeConfig` privat, yang diisi dari environment variable `NUXT_` saat runtime. Kirim CSP untuk halaman kontak dari `routeRules`, dengan mengizinkan `challenges.cloudflare.com` untuk skrip dan frame-nya.

## Apa yang berbeda di Nuxt

Server Nuxt adalah Nitro, dan Nitro membawa dua hal yang dibutuhkan pemeriksaan spam. Server route di `server/api` adalah endpoint biasa yang sudah terhubung dengan `$fetch`, dan `useStorage()` memberi Anda key-value store yang backend-nya Anda pilih per environment: memori saat development, Redis atau driver lain di production. Itu membuat rate limit cukup diubah lewat konfigurasi, bukan dependensi.

Dua kebiasaan Nuxt menimbulkan masalah di sini. `getRequestIP(event, { xForwardedFor: true })` mengembalikan entri `X-Forwarded-For` pertama, yang oleh dokumentasi h3 sendiri disebut input dari client. Dan file `.env` tidak dibaca di production, jadi secret harus diatur sebagai environment variable platform.

Diagram: Kiriman kontak melewati server route Nuxt dan Nitro storage

1. Komponen mengirim field dan token Turnstile ke /api/contact dengan $fetch.
2. Server route membaca IP client dari header yang disebut di runtimeConfig.
3. Route menghitung kiriman dari IP itu di menit berjalan dalam Nitro storage. Jika lebih dari lima, route melempar 429 dengan sisa detiknya.
4. Di bawah limit, route memverifikasi token dengan siteverify, memeriksa action dan host-nya.
5. Lalu route meneruskan field ke Ikiform, yang menyimpannya dan memberi skor pada honeypot.

Saat development, mount storage ada di memori. Di production kode yang sama berbicara dengan Redis, karena nuxt.config me-mount driver yang berbeda.

## Setup proyek

`File`:

```text
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
```

`.env`:

```bash
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-for
```

`nuxt.config.ts`:

```ts
const policy = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export default defineNuxtConfig({
  runtimeConfig: {
    turnstileSecretKey: "",
    ikiformSecretKey: "",
    clientIpHeader: "x-forwarded-for",
    public: {
      turnstileSiteKey: "",
    },
  },
  routeRules: {
    "/contact": { headers: { "Content-Security-Policy": policy } },
  },
  $production: {
    nitro: {
      storage: {
        ratelimit: { driver: "upstash" },
      },
    },
  },
});
```

- Setiap key `runtimeConfig` bisa ditimpa saat runtime oleh variabel `NUXT_`: `turnstileSecretKey` oleh `NUXT_TURNSTILE_SECRET_KEY`, `public.turnstileSiteKey` oleh `NUXT_PUBLIC_TURNSTILE_SITE_KEY`. Key di luar `public` tidak pernah sampai ke browser.
- `routeRules` mengirim CSP hanya di `/contact`. Nuxt menyisipkan skrip payload dan hydration secara inline, jadi policy ini mempertahankan `'unsafe-inline'`; modul komunitas `nuxt-security` bisa membuat nonce jika Anda butuh policy yang lebih ketat.
- Driver `upstash` hanya di-mount di production dan membaca `UPSTASH_REDIS_REST_URL` serta `UPSTASH_REDIS_REST_TOKEN` dari environment. Driver ini butuh paket `@upstash/redis`, jadi instal dengan `npm install @upstash/redis`.

## Utilitas server

File di `server/utils` diimpor otomatis ke setiap server route:

`server/utils/spam.ts`:

```ts
import type { H3Event } from "h3";

const WINDOW_SECONDS = 60;
const LIMIT = 5;

export function clientIp(event: H3Event) {
  const header = useRuntimeConfig(event).clientIpHeader;
  return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}

export async function secondsToWait(ip: string) {
  const storage = useStorage("ratelimit");
  const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `contact:${ip}:${window}`;
  const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
  await storage.setItem(key, count);
  if (count <= LIMIT) return 0;
  return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}

export async function isHuman(event: H3Event, token: string, ip: string) {
  if (!token || token.length > 2048) return false;
  try {
    const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
    });
    return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
  } catch {
    return false;
  }
}
```

- `clientIp` membaca tepat satu header, yaitu header yang ditimpa hosting Anda. Atur `NUXT_CLIENT_IP_HEADER` ke `x-forwarded-for` di Vercel, `x-nf-client-connection-ip` di Netlify, dan `cf-connecting-ip` di Cloudflare.
- Penghitungnya berupa fixed window di Nitro storage. Baca-lalu-tulis tidak atomik, jadi dua kiriman di saat yang sama bisa sama-sama lolos di bawah limit; untuk formulir kontak itu bisa diterima. Key tidak kedaluwarsa dengan sendirinya, jadi beri database Redis kebijakan eviction.
- `$fetch` mengirim body siteverify sebagai JSON, yang diterima siteverify. Pemeriksaan host memakai `getRequestHost` dan dilewati saat development, karena token uji membawa hostname tiruan.

## Server route

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

```ts
interface ContactBody {
  name?: string;
  email?: string;
  company?: string;
  message?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

export default defineEventHandler(async (event) => {
  const ip = clientIp(event);
  const wait = await secondsToWait(ip);
  if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });

  const body = await readBody<ContactBody>(event);
  const fields = {
    name: body.name?.trim().slice(0, 120) ?? "",
    email: body.email?.trim() ?? "",
    company: body.company?.trim().slice(0, 120) ?? "",
    message: body.message?.trim().slice(0, 5000) ?? "",
    _gotcha: body._gotcha ?? "",
  };
  if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
  if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
    throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
  }

  const origin = getRequestHeader(event, "origin");
  await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: fields,
  }).catch((failure) => {
    throw createError({
      status: failure.status ?? 502,
      message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
    });
  });
  return { sent: true };
});
```

- Akhiran `.post.ts` membuat Nitro hanya menjawab `POST`. Selain itu mendapat `405`.
- Limit berjalan sebelum `readBody`, jadi banjir request tidak pernah memakan parsing body atau panggilan siteverify.
- Setiap kegagalan melempar `createError`. Body JSON membawa `message`, yang ditampilkan komponen.
- `Origin` pengunjung diteruskan, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`.
- Honeypot `_gotcha` ikut diteruskan tanpa diubah, sehingga Ikiform menyimpan honeypot yang terisi sebagai spam alih-alih membuangnya.

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

## Composable dan formulir

`app/composables/useTurnstile.ts`:

```ts
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<TurnstileApi> | undefined;

function load() {
  loading ??= new Promise<TurnstileApi>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: TurnstileApi }).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 useTurnstile(action: string) {
  const { turnstileSiteKey } = useRuntimeConfig().public;
  const box = ref<HTMLElement>();
  const token = ref("");
  let api: TurnstileApi | undefined;
  let widgetId: string | undefined;

  onMounted(async () => {
    api = await load();
    if (!box.value) return;
    widgetId = api.render(box.value, {
      sitekey: turnstileSiteKey,
      action,
      callback: (value) => (token.value = value),
      "expired-callback": () => (token.value = ""),
    });
  });

  onBeforeUnmount(() => {
    if (widgetId) api?.remove(widgetId);
  });

  function reset() {
    token.value = "";
    if (widgetId) api?.reset(widgetId);
  }

  return { box, token, reset };
}
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    reset();
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" type="email" autocomplete="email" required />
    <label for="company">Company <span>(optional)</span></label>
    <input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Composable hanya berjalan di `onMounted`, jadi render server tidak pernah menyentuh `window`.
- Tombol menunggu token, dan `reset()` meminta token baru setelah setiap kegagalan, karena token hanya berlaku sekali dan hidup selama 300 detik.
- Formulir ini butuh JavaScript. Untuk versi yang bisa mengirim tanpanya, lihat bagian berikutnya.
- Class `.hp` menyembunyikan honeypot di luar layar, seperti di [panduan honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md).

## Uji secara lokal

Saat development, mount `ratelimit` ada di memori, jadi hitungan ter-reset saat `nuxt dev` di-restart. Anda bisa melihat limit bekerja tanpa browser. Kiriman tanpa token gagal di siteverify dengan `403`, dan masing-masing tetap dihitung:

`Terminal`:

```bash
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done
```

Lima baris pertama mencetak `403` dan baris keenam mencetak `429`. Secara lokal tidak ada proxy yang mengatur header IP, jadi setiap request dihitung sebagai `unknown`; itu memang diharapkan, dan itulah sebabnya header ditaruh di konfigurasi, bukan di kode.

## Deploy

| Hosting | NUXT_CLIENT_IP_HEADER | Driver storage |
| --- | --- | --- |
| Vercel | `x-forwarded-for` | `upstash`, dari Vercel Marketplace |
| Netlify | `x-nf-client-connection-ip` | `upstash` atau `redis` |
| Cloudflare | `cf-connecting-ip` | `upstash`; Workers KV bersifat eventually consistent, jadi hitungan tertinggal |
| Server Node | Header yang diatur proxy Anda | `redis`, atau memori pada satu instance |

Atur setiap variabel `NUXT_` di dashboard hosting. Nuxt membacanya saat server dimulai, jadi mengganti key butuh restart, bukan rebuild. Tambahkan setiap hostname production dan preview ke widget Turnstile.

## Biarkan Ikiform menjalankan pemeriksaannya

Tanpa server route, halaman kontak bisa di-prerender bersama sisa situs `nuxt generate`. Arahkan formulir biasa ke endpoint Ikiform Anda dengan `method="post"` dan `action` berisi URL endpoint Anda, lalu hapus `server/api/contact.post.ts` dan composable-nya.

Atur **Spam check** formulir ke **Ikiform**. Kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile dan meneruskan field-nya, jadi CSP Anda tidak butuh entri Cloudflare. Pilih **Rate limit** Strict, 2 per 10 menit per pengunjung, dan daftarkan domain Anda di **Allowed domains**. Ikiform menyimpan spam yang tertangkap selama 30 hari beserta alasannya. [Panduan setup Nuxt](https://ikiform.com/id/docs/guides/nuxt.md) membahas endpoint dan key-nya.

## Pastikan berfungsi

1. Jalankan `nuxt dev` dengan test key dan kirim pesan. Pesan muncul di **Submissions**. Workspace tanpa paket menerima 50 uji dari `localhost` per bulan.
2. Kirim enam pesan dalam satu menit. Pesan keenam menampilkan sisa detiknya.
3. Atur `NUXT_TURNSTILE_SECRET_KEY` ke secret yang selalu gagal lalu restart. Setiap kiriman menampilkan error pemeriksaan manusia.
4. Request `/contact` dengan `curl -I` setelah `nuxt build`. Responsnya membawa `Content-Security-Policy` Anda.
5. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Secret kosong di production | `.env` tidak dibaca saat runtime di production. Atur `NUXT_TURNSTILE_SECRET_KEY` dan yang lainnya di environment hosting. |
| Limit ter-reset di setiap deploy atau instance | Mount storage masih di memori. Pastikan `$production.nitro.storage` me-mount driver bersama dan kredensialnya sudah diatur. |
| Skrip lolos dari limit dengan mengubah header | Anda membaca `getRequestIP` dengan `xForwardedFor: true` di hosting yang tidak menimpa header itu. Baca header milik hosting lewat `clientIp`. |
| `window is not defined` saat server rendering | Kode Turnstile berjalan di luar `onMounted`. Simpan setiap akses `window` di dalam lifecycle hook composable. |

## Pertanyaan

### Bagaimana menambahkan Turnstile ke formulir Nuxt?

Render widget secara eksplisit di onMounted, simpan token-nya di ref, lalu kirim bersama field ke server route. Route mengirimnya bersama secret key dari runtimeConfig ke siteverify dan hanya melanjutkan jika berhasil.

### Bagaimana membuat rate limit untuk server route Nuxt?

Hitung request per IP client dengan useStorage milik Nitro, yang di-mount ke Redis atau Upstash di production agar setiap instance berbagi hitungan. Lempar 429 dengan createError saat hitungan melewati limit Anda.

### Apakah getRequestIP aman untuk rate limiting di Nuxt?

Tidak dengan xForwardedFor true di hosting yang meneruskan X-Forwarded-For milik client apa adanya, karena entri pertamanya adalah apa pun yang dikirim client. Baca header yang ditimpa hosting Anda sebagai gantinya.

### Apakah Ikiform gratis?

Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.

## Panduan lainnya

- [Hentikan spam formulir kontak](https://ikiform.com/id/guides/spam-protection.md): Hentikan spam formulir kontak dengan lapisan yang tidak membebani orang sungguhan: honeypot, waktu pengisian, skor konten, rate limit, dan pemeriksaan manusia hanya bila perlu.
- [Cloudflare Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md): Tambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.
- [Rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md): Batasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.
- [Field honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md): Tambahkan honeypot ke formulir apa pun yang menangkap bot tanpa mengganggu screen reader, keyboard, atau autofill, dengan pemeriksaan server untuk setiap stack.
- [Tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md): Hentikan sebagian besar spam formulir tanpa captcha sama sekali: honeypot, waktu render bertanda tangan, skor konten, dan rate limit, digabungkan alih-alih dipakai sendiri-sendiri.
- [Alternatif reCAPTCHA](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md): Bandingkan alternatif reCAPTCHA untuk formulir, dari Turnstile dan hCaptcha hingga proof-of-work dan tanpa captcha sama sekali, lalu beralih dengan perubahan kecil yang aman.
