# Cara menambahkan Cloudflare Turnstile ke formulir HTML

> Tambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.

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

Tambahkan skrip Turnstile dan elemen `cf-turnstile` di dalam formulir Anda; widget menaruh token di field tersembunyi `cf-turnstile-response`. Lalu kirim token itu dan secret key Anda ke endpoint siteverify Cloudflare dari server, dan terima kiriman hanya jika jawabannya `success`. Tanpa server, biarkan form backend Anda yang memverifikasinya, atau pakai pemeriksaan terkelola Ikiform, yang sama sekali tidak butuh key.

## Apa yang berbeda dengan Turnstile

Turnstile punya dua bagian, dan formulir HTML biasa hanya punya tempat untuk satu. Widget berjalan di browser dengan **site key** publik Anda dan menghasilkan token. Pemeriksaan berjalan di server dengan **secret key** Anda, yang tidak boleh pernah sampai ke halaman. Formulir yang menampilkan widget tetapi tidak pernah memverifikasi token hanyalah hiasan: bot mengirim langsung ke endpoint Anda dan melewati widget sepenuhnya.

Jadi pertanyaan sebenarnya untuk formulir HTML adalah di mana secret disimpan. Halaman ini membahas tiga jawaban: server kecil milik Anda sendiri, form backend yang menyimpan key Anda, dan pemeriksaan terkelola di mana tak ada yang memegang key selain backend.

Diagram: Perjalanan token Turnstile, dari widget hingga keputusan

1. Widget Turnstile berjalan di browser dengan site key publik Anda. Kebanyakan pengunjung tidak pernah melihatnya melakukan apa pun.
2. Widget menerbitkan token dan menaruhnya di input tersembunyi cf-turnstile-response di dalam formulir.
3. Formulir dikirim, dan token ikut bersama field lainnya.
4. Server Anda mengirim secret key, token, dan IP pengunjung ke endpoint siteverify Cloudflare.
5. Server memeriksa bahwa jawabannya success dan hostname serta action sesuai yang Anda harapkan.
6. Jika semuanya cocok, kiriman diterima. Jika ada yang janggal, kiriman ditolak.

Token berlaku 300 detik dan hanya bisa diverifikasi sekali. Pemeriksaan kedua atas token yang sama gagal dengan timeout-or-duplicate.

## Tambahkan widget

Buat widget di dashboard Cloudflare di bagian **Turnstile**, daftarkan hostname tempat formulir Anda berjalan, lalu salin site key-nya. Kemudian tambahkan skrip dan elemen `cf-turnstile` di dalam formulir. Elemen itu harus berada di dalam `<form>`, atau field token tersembunyi akan berada di luarnya dan tidak pernah ikut terkirim.

`contact.html`:

```html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

- `data-action="contact"` memberi label pada widget. Siteverify mengembalikannya, sehingga server Anda bisa menolak token yang dibuat untuk formulir lain.
- Skrip harus dimuat dari URL milik Cloudflare sendiri. Menurut Cloudflare, salinan yang di-proxy atau di-host sendiri merusak widget.
- Jika situs Anda mengirim Content Security Policy, izinkan `https://challenges.cloudflare.com` di `script-src` dan `frame-src`.
- Tanpa biaya, Cloudflare mengizinkan hingga 20 widget, masing-masing di hingga 10 hostname, jadi daftarkan juga host staging.

## Verifikasi token di server Anda sendiri

Server mana pun bisa memverifikasi token: satu `POST` ke siteverify dengan secret dan token. Berikut contohnya sebagai Cloudflare Worker yang menjadi tujuan kiriman formulir statis. Worker memeriksa token, hostname, dan action, membuang token, lalu meneruskan field ke Ikiform dengan secret key. Ganti bagian penerusan dengan apa pun yang menyimpan atau mengirim email pesan itu.

`src/index.ts`:

```ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
```

- Siteverify menerima body JSON atau form-encoded dan selalu menjawab dengan JSON. `remoteip` opsional; `idempotency_key` memungkinkan Anda mengulang pemeriksaan dengan aman setelah error jaringan.
- Periksa `hostname` dan `action`, bukan hanya `success`. Token yang dibuat di halaman orang lain dengan site key Anda juga menyatakan `success`.
- Simpan kedua secret dengan `wrangler secret put`, jangan pernah di `wrangler.toml`.
- Worker menjawab dengan redirect `303`, jadi formulir biasa tidak butuh JavaScript. Arahkan `action` formulir ke route Worker.
- Worker meneruskan header `Origin` milik halaman, karena fetch dari sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`.

Untuk menampilkan error di halaman formulir setelah pemeriksaan gagal, baca query string yang diatur Worker:

`contact.html`:

```html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
```

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

## Biarkan Ikiform yang memverifikasi

Jika Anda sama sekali tidak ingin menjalankan server, formulir bisa mengirim langsung ke Ikiform dan Ikiform yang melakukan panggilan siteverify. Pengaturan **Spam check** di setiap formulir menentukan caranya.

| Rute | Key yang Anda kelola | Widget di halaman Anda | Server sendiri |
| --- | --- | --- | --- |
| Server atau Worker Anda sendiri, di atas | Site key dan secret key | Ya, milik Anda | Ya |
| Ikiform dengan **Your own Turnstile keys** | Site key dan secret key, ditempel ke Ikiform | Ya, milik Anda | Tidak |
| Ikiform dengan **Ikiform** | Tidak ada | Tidak. Halaman pemeriksaan di ikiform.com, atau frame kecil | Tidak |

Dengan **Your own Turnstile keys**, tempel site key dan secret key ke pengaturan formulir. Secret-nya dienkripsi saat disimpan. Pertahankan markup widget yang sama, tetapi arahkan formulir ke endpoint Ikiform Anda. Ikiform memverifikasi `cf-turnstile-response` dan mencocokkan hostname token dengan **Allowed domains** formulir. Kiriman tanpa token yang valid mendapat `403 captcha_failed`, bahkan dengan secret API key.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

Dengan **Ikiform**, Anda tidak menambahkan apa pun ke halaman. Kiriman HTML biasa tanpa token mendapat halaman singkat **Checking you're human** di ikiform.com yang menjalankan Turnstile lalu meneruskan field-nya. Kiriman `fetch` mendapat `403 challenge_required`; panggil `challenge()` dari `@ikiform/sdk` untuk mendapatkan token terlebih dulu. Tidak ada akun Cloudflare yang terlibat. [Dokumentasi spam](https://ikiform.com/id/docs/spam.md#human-check) menunjukkan kedua alur.

Jika Cloudflare tidak bisa dihubungi, Ikiform tidak membuang kiriman: kiriman disimpan sebagai spam dengan alasan `captcha_unavailable`, sehingga Anda bisa memulihkannya dari dashboard. Server Anda sendiri sebaiknya mengambil keputusan yang sama secara sadar, entah gagal secara tertutup seperti Worker di atas atau menyimpan kiriman untuk ditinjau.

## Key uji

Cloudflare menerbitkan key dummy yang berfungsi di hostname apa pun, termasuk `localhost`. Pakai selama membangun, lalu ganti dengan key asli Anda.

| Key | Nilai | Perilaku |
| --- | --- | --- |
| Site key | `1x00000000000000000000AA` | Selalu lolos, widget terlihat |
| Secret key | `1x0000000000000000000000000000000AA` | Siteverify selalu menjawab success |
| Secret key | `2x0000000000000000000000000000000AA` | Siteverify selalu menjawab gagal |

Token uji tidak membawa hostname asli Anda, jadi pemeriksaan hostname di Worker menolaknya. Longgarkan pemeriksaan itu hanya di environment pengembangan, jangan pernah di production.

## Pastikan berfungsi

1. Kirim formulir seperti biasa. Kiriman diterima dan widget tidak menampilkan apa-apa, atau hanya satu checkbox.
2. Hapus elemen `cf-turnstile` lalu kirim. Kiriman ditolak: Worker me-redirect dengan `?error=check`, atau Ikiform menjawab `403 captcha_failed`.
3. Kirim, tekan Back, lalu kirim halaman yang sama lagi. Token yang dipakai ulang gagal, yang menunjukkan server benar-benar memverifikasinya.
4. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
5. Tidak ada yang masuk? Ubah filter status di dashboard ke **Spam**. Spam mendapat jawaban sukses yang sama dengan kiriman asli.

## Hal yang sering menjebak

- **Satu token, satu percobaan.** Setelah pengiriman gagal yang ditangani dengan `fetch`, panggil `turnstile.reset()` untuk mendapatkan token baru. Memuat ulang halaman penuh otomatis mendapat token baru.
- **Pengetik lambat.** Token kedaluwarsa setelah 300 detik. Pengaturan `data-refresh-expired` pada widget menentukan apakah widget mengambil token baru sendiri atau menunggu klik.
- **www dan domain apex.** `www.example.com` dan `example.com` adalah hostname berbeda bagi siteverify. Bandingkan dengan yang benar-benar dipakai formulir Anda, atau izinkan keduanya.
- **Navigasi single-page.** Skrip implisit merender widget sekali, saat dimuat. Saat route berubah di sisi client, render secara eksplisit dengan `?render=explicit` dan `turnstile.render()`.
- **Kiriman server dengan key Ikiform sendiri.** Jika server Anda meneruskan ke Ikiform dengan secret key, Ikiform melewati pemeriksaan terkelolanya. Verifikasi token sendiri terlebih dulu, seperti yang dilakukan Worker.

## Pertanyaan

### Bisakah saya memakai Cloudflare Turnstile tanpa server?

Widget bisa berjalan di mana saja, tetapi token harus diverifikasi dengan secret key Anda di server. Tanpa server, kirim formulir ke form backend yang memverifikasinya untuk Anda, atau pakai pemeriksaan terkelola Ikiform, yang tidak butuh key.

### Apakah Cloudflare Turnstile gratis?

Cloudflare menyediakannya tanpa biaya untuk hingga 20 widget dengan masing-masing hingga 10 hostname, dan halaman paketnya mencantumkan tantangan tanpa batas. Cek halaman paket Cloudflare yang berlaku sebelum mengandalkannya.

### Mengapa siteverify menjawab timeout-or-duplicate?

Token berumur lebih dari 300 detik atau sudah pernah diverifikasi sekali. Render widget baru, atau reset, setelah setiap percobaan pengiriman.

### Apakah saya butuh akun Cloudflare untuk memakai Turnstile dengan Ikiform?

Tidak. Atur Spam check formulir ke Ikiform dan Ikiform menjalankan Turnstile di ikiform.com. Anda hanya butuh akun untuk memakai key dan widget Anda sendiri.

## 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.
- [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.
- [Situs statis](https://ikiform.com/id/guides/spam-protection/static-site.md): Hentikan spam di formulir situs statis meskipun situs itu tidak bisa menyimpan secret atau menghitung kiriman, dengan partial honeypot, skrip yang aman, dan pemeriksaan sisi server.
- [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.
- [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.
