# Perlindungan spam formulir SvelteKit dengan Turnstile

> Lindungi formulir SvelteKit dari spam: form action dengan getClientAddress, limit per IP, Turnstile di balik use:enhance, dan CSP yang mengizinkannya.

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

Lakukan pemeriksaan di form action: batasi berdasarkan `getClientAddress()`, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya bersama honeypot. Render widget dengan Svelte action dan reset di callback `use:enhance`, karena setiap token hanya berlaku sekali. Izinkan `challenges.cloudflare.com` di opsi `csp` plugin `sveltekit()`, dan di `adapter-node` beri tahu SvelteKit header proxy mana yang berisi IP asli.

## Apa yang berbeda di SvelteKit

SvelteKit memberi Anda IP pengunjung secara langsung: `getClientAddress()` di request event. Asal nilai itu bergantung pada adapter, dan di `adapter-node` nilai default-nya salah jika ada proxy di depannya. Begitu bagian ini benar, pemeriksaan lainnya pas dengan rapi di dalam form action.

Diagram: Dari mana getClientAddress() mendapatkan IP pengunjung di setiap adapter

1. Form action meminta alamat client dari request event.
2. SvelteKit menyerahkan jawabannya ke adapter yang Anda pakai untuk deploy.
3. adapter-node memakai remote address dari socket kecuali Anda mengatur ADDRESS_HEADER dan XFF_DEPTH.
4. Adapter Vercel, Netlify, dan Cloudflare membaca alamat yang disediakan platformnya, jadi tidak ada yang perlu dikonfigurasi.

Di belakang nginx atau load balancer, adapter-node melihat alamat proxy untuk setiap pengunjung sampai Anda mengatur ADDRESS_HEADER, dan satu alamat bersama mengubah limit per IP menjadi limit untuk seluruh situs.

Detail SvelteKit lainnya adalah `use:enhance`. Fitur ini mengirim tanpa memuat ulang halaman, yang lebih nyaman bagi orang tetapi berarti Turnstile tidak pernah merender ulang dengan sendirinya. Widget harus di-reset secara manual setelah setiap jawaban.

## Setup proyek

`File`:

```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 membaca variabel yang Anda deklarasikan di `src/env.ts`. Hanya site key yang punya `public: true`, jadi hanya itu yang bisa diberikan `$app/env/public` ke browser. Apa pun di direktori `server`, seperti `src/lib/server`, sama sekali tidak bisa diimpor oleh kode client; SvelteKit menggagalkan build jika Anda mencobanya.
- Variabel yang dideklarasikan dibaca saat aplikasi dimulai kecuali Anda mengatur `static: true`, jadi key yang dirotasi butuh restart, bukan rebuild. Modul `$env/*` masih berfungsi tetapi sudah deprecated di SvelteKit 3.
- Test key Cloudflare lolos di host mana pun, termasuk `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 menerima opsinya di `vite.config.ts`, yang diberikan ke plugin `sveltekit()`; `svelte.config.js` tidak lagi dibaca. Dengan `mode: "auto"`, SvelteKit menambahkan nonce ke skrip inline miliknya di halaman yang dirender server dan hash di halaman yang di-prerender, jadi policy tidak butuh `'unsafe-inline'`. Turnstile dimuat dari host-nya, yang diizinkan `script-src`, dan menggambar iframe yang diizinkan `frame-src`.

## Helper khusus server

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

- Limit-nya berupa sliding window di Redis, dipakai bersama oleh setiap instance server, lima pesan per menit per IP. `reset` kembali dalam milidetik dan diubah menjadi detik penuh untuk menunggu.
- `dev` dari `$app/env` melewati pemeriksaan hostname secara lokal, karena token uji membawa hostname tiruan.
- Siteverify menjawab `timeout-or-duplicate` untuk token yang lebih tua dari 300 detik atau sudah terpakai, itulah sebabnya halaman me-reset widget.

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

- Limit berjalan sebelum body di-parsing. `fail(429)` membuat pengunjung tetap di halaman dengan pesannya.
- Setiap kegagalan lain mengembalikan `values`, sehingga input terisi kembali setelah error.
- `FormData` asli diteruskan, termasuk honeypot, tanpa token. Ikiform memberi skor pada honeypot yang terisi dan menyimpan pesannya sebagai spam.
- `Origin` pengunjung diteruskan, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`.

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

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

Svelte action adalah tempat yang alami untuk widget pihak ketiga: berjalan saat elemen di-mount, dan `destroy` berjalan saat elemen dilepas. ID widget disimpan di elemen, sehingga halaman bisa me-reset widget yang tepat.

## Halamannya

`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 })` menerapkan hasilnya dan mempertahankan ketikan pengunjung. Default-nya akan mengosongkan formulir, seperti yang dilakukan browser.
- `resetTurnstile` berjalan setelah setiap jawaban, berhasil atau tidak. Token yang terpakai hilang dan token baru sudah siap.
- Tanpa JavaScript, formulir tetap terkirim dan action tetap berjalan, tetapi widget tidak dirender, jadi siteverify menolaknya dengan pesan yang jelas.
- Halaman dengan form action tidak bisa di-prerender, jadi route ini selalu dirender di server. Halaman lain tetap bisa di-prerender.
- Class `.hp` menyembunyikan honeypot di luar layar, seperti di [panduan honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md).

## Uji secara lokal

Di `vite dev` tidak ada adapter di depan aplikasi, jadi `getClientAddress()` mengembalikan alamat loopback Anda dan setiap tab lokal berbagi satu bucket. Itu cukup untuk memeriksa pesannya, tetapi tidak untuk memeriksa setup proxy. Untuk mengujinya, build dengan `adapter-node` dan jalankan hasilnya dengan variabel yang sama yang akan Anda pakai di production:

`Terminal`:

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

Kirim beberapa kali dengan nilai `X-Forwarded-For` yang berbeda. Dengan `XFF_DEPTH=1`, SvelteKit membaca alamat paling kanan, yang akan ditambahkan proxy Anda, jadi nilai karangan client di sebelah kiri tidak mengubah apa pun.

## Deploy

Di server Anda sendiri dengan `adapter-node` di belakang satu proxy, beri tahu SvelteKit di mana IP aslinya:

`Environment`:

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

Dokumentasi SvelteKit memperingatkan bahwa header mudah dipalsukan dan bahwa membaca alamat `X-Forwarded-For` paling kiri adalah kesalahan klasik. `XFF_DEPTH` menghitung proxy tepercaya dari kanan, jadi atur ke jumlah proxy yang Anda jalankan, dan pastikan aplikasi tidak bisa dijangkau tanpa melewatinya. SvelteKit 3 menghapus variabel `ORIGIN` di adapter-node. Jika proxy Anda tidak meneruskan host asli, atur `paths.origin` di opsi `sveltekit()` agar pemeriksaan formulir milik SvelteKit mengetahui URL publik Anda.

| Adapter | IP client | Secret |
| --- | --- | --- |
| `adapter-node` | `ADDRESS_HEADER` dan `XFF_DEPTH` | Environment proses |
| `adapter-vercel` | Otomatis | Environment variable proyek |
| `adapter-netlify` | Otomatis | Environment variable situs |
| `adapter-cloudflare` | Otomatis | `wrangler secret put` |

## Biarkan Ikiform menjalankan pemeriksaannya

Agar tidak perlu menjalankan semua ini sendiri, hapus `+page.server.ts` dan arahkan `action` formulir ke endpoint Ikiform Anda, tanpa `use:enhance`. Halaman lalu bisa di-prerender seperti sisa situs statis.

Atur **Spam check** formulir ke **Ikiform**, agar kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile dan meneruskan field-nya. 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, jadi false positive bisa dipulihkan dengan satu klik. [Panduan setup SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md) membahas endpoint dan key-nya.

## Pastikan berfungsi

1. Jalankan `vite dev` dengan test key dan kirim pesan. Pesan muncul di **Submissions**. Workspace tanpa paket menerima 50 uji dari `localhost` per bulan.
2. Kirim pesan kedua tanpa memuat ulang. Pesan berhasil, yang menunjukkan reset widget bekerja.
3. Kirim enam pesan dalam satu menit. Pesan keenam menampilkan berapa detik harus menunggu.
4. Di belakang proxy Anda, catat `getClientAddress()` di log dari dua jaringan. Anda melihat dua alamat berbeda, bukan alamat proxy 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 |
| --- | --- |
| Semua pengunjung terkena rate limit bersama-sama di `adapter-node` | `getClientAddress()` mengembalikan alamat proxy Anda. Atur `ADDRESS_HEADER` dan `XFF_DEPTH`. |
| Kiriman kedua selalu gagal pemeriksaan manusia | Widget masih memegang token yang sudah terpakai. Panggil `resetTurnstile` di callback `use:enhance` setelah `update`. |
| Build gagal karena halaman dengan action di-prerender | Route kontak mewarisi `prerender = true` dari layout. Halaman dengan action tidak bisa di-prerender; atur `export const prerender = false` di `+page.server.ts`-nya. |
| Area widget kosong dan console melaporkan pelanggaran CSP | `script-src` atau `frame-src` tidak memuat `https://challenges.cloudflare.com` di `csp.directives` dalam `vite.config.ts`. |

## Pertanyaan

### Bagaimana mendapatkan IP client di SvelteKit?

Panggil getClientAddress() di request event dalam form action, load function, atau hook. Adapter yang menyediakannya. Di adapter-node di belakang proxy, atur ADDRESS_HEADER dan XFF_DEPTH, atau Anda akan mendapat alamat proxy.

### Mengapa Turnstile gagal di kiriman kedua dengan use:enhance?

use:enhance tidak memuat ulang halaman, jadi widget masih memegang token yang sudah dipakai. Reset widget di callback enhance setelah setiap jawaban.

### Apakah CSP SvelteKit bisa dipakai dengan Turnstile?

Ya. Tambahkan https://challenges.cloudflare.com ke script-src dan frame-src di csp.directives, yang di SvelteKit 3 diberikan lewat opsi plugin sveltekit() di vite.config.ts. Dengan mode auto, SvelteKit menambahkan nonce atau hash untuk skripnya sendiri.

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