# Cara menghentikan spam formulir tanpa captcha

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

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

Gabungkan empat pemeriksaan tak terlihat dan jumlahkan temuannya: honeypot, timestamp bertanda tangan yang menunjukkan berapa lama formulir dibuka, skor untuk link dan frasa spam, serta rate limit per pengunjung. Anggap kiriman sebagai spam hanya jika totalnya melewati ambang, sehingga tidak ada satu sinyal lunak yang memblokir orang sungguhan. Simpan yang tertangkap, dan tambahkan captcha nanti hanya jika folder spam menunjukkan bot masih lolos.

## Apa yang berbeda tanpa captcha

Captcha memberi satu jawaban ya atau tidak yang kuat. Tanpanya, Anda punya beberapa sinyal lemah, dan masing-masing mudah dihindari atau mudah terpicu tanpa sengaja. Kiriman cepat bisa berasal dari bot, atau dari orang yang menempelkan pesan yang sudah disiapkan. Link bisa berarti spam, atau portofolio. Kuncinya adalah memberi skor: beri bobot pada setiap sinyal, jumlahkan, dan anggap spam hanya jika melewati ambang. Satu-satunya sinyal keras, honeypot yang terisi, mendapat bobot cukup besar untuk melewati ambang sendirian.

Diagram: Bagaimana sinyal tak terlihat dijumlahkan menjadi keputusan untuk tiga kiriman berbeda

1. Bot skrip mengisi honeypot, yang menambah 5, dan mengirim dalam waktu kurang dari satu detik, yang menambah 3. Totalnya 8, jadi spam.
2. Tawaran SEO yang ditempel punya link di field nama, 3, dan frasa spam yang dikenal, 2. Totalnya 5, mencapai ambang 5.
3. Pesan asli dengan tiga link mendapat skor 2 dan tidak ada yang lain, jadi diterima.

Bobotnya milik Ikiform, dengan ambang 5. Dua sinyal lunak bersamaan melewatinya; satu sinyal tidak pernah.

## Waktu render bertanda tangan

Orang butuh beberapa detik untuk mengisi formulir. Jebakan waktu mengukur jeda itu. Versi lemahnya menaruh `Date.now()` di field tersembunyi, yang bisa diubah bot mana pun. Versi kuatnya menandatangani waktu render di server dengan secret, sehingga bot tidak bisa mengklaim halaman dibuka lebih awal dari kenyataannya. File ini berjalan di mana saja yang punya Web Crypto: Node 20 ke atas, Deno, Bun, Workers, dan setiap runtime serverless.

`lib/render-token.ts`:

```ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}
```

Tanda tangani saat halaman dirender, jadi halaman harus dirender per request, bukan di-cache sebagai HTML statis. Di Astro:

`src/pages/contact.astro`:

```astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

- Tiga detik adalah batas minimum yang aman untuk formulir kontak. Orang yang menempelkan pesan siap pakai masih bisa butuh dua detik.
- Tolak token yang berumur lebih dari sehari. Jika tidak, bot memuat halaman sekali dan memakai ulang token yang sama selama berminggu-minggu.
- Anggap token yang hilang atau palsu sebagai sinyal, bukan bukti. Halaman yang di-cache atau tab lama bisa menghasilkannya.

## Beri skor pada kiriman

Gabungkan sinyal-sinyal itu dalam satu function. Pemeriksaan link dan frasa menangkap yang tidak bisa ditangkap honeypot dan waktu: pesan yang diketik atau ditempel manusia. Buat daftar frasa singkat dan spesifik; kata umum seperti `free` akan menandai pelanggan sungguhan.

`lib/score.ts`:

```ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
```

`src/pages/api/contact.ts`:

```ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};
```

Handler menyimpan semuanya dengan status alih-alih membuang spam, dan menjawab keduanya dengan redirect yang sama. Anda meninjau spam nanti, dan bot tidak pernah tahu sinyal mana yang menangkapnya. Tambahkan [rate limit per pengunjung](https://ikiform.com/id/guides/spam-protection/rate-limiting.md) di depan route ini untuk menghentikan banjir kiriman sebelum semua itu berjalan.

## Ikiform menjalankan semua ini tanpa captcha

Setiap formulir Ikiform menjalankan honeypot, waktu pengisian, skor konten, dan rate limit tanpa setup, dan **Spam check**-nya dimulai dalam posisi **Off**. Honeypot-nya `_gotcha`. Skor konten memeriksa link, 22 frasa spam yang dikenal, domain email sekali pakai, dan kiriman identik berulang dalam 24 jam. Rate limit dimulai dari 5 per menit per pengunjung. Pada skor 5 atau lebih, kiriman dianggap spam.

| Sinyal | Bobot |
| --- | --- |
| Honeypot terisi | +5 |
| Dikirim kurang dari 2 detik setelah render bertanda tangan | +3 |
| Waktu render dipalsukan atau berumur lebih dari 24 jam | +3 |
| Link di field nama atau subjek | +3 |
| Field identik dalam 24 jam | +3 |
| Tiga link atau lebih | +2 |
| Frasa spam yang dikenal | +2 |
| Domain email sekali pakai | +2 |

Waktu render bertanda tangan dari Ikiform berasal dari schema publik formulir. [Komponen shadcn/ui](https://ikiform.com/id/docs/shadcn.md) mengirimkannya untuk Anda. Formulir yang Anda render sendiri bisa mengambilnya dengan `getSchema()` dari `@ikiform/sdk` dan mengirimnya kembali sebagai `_ikiform_ts`:

`contact.js`:

```js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});
```

Formulir HTML biasa tidak mengirim waktu render dan tidak dikenai penalti karenanya, jadi ketiadaannya tidak berpengaruh. Kiriman yang dinilai spam mendapat jawaban sukses yang sama dengan kiriman asli, jadi bot tidak belajar apa-apa. Kiriman itu disimpan dengan status `spam`, tidak mengirim email notifikasi maupun webhook, tidak dihitung dalam pemakaian Anda, dan dihapus setelah 30 hari. Setiap Senin, workspace yang memblokir spam menerima satu email ringkasan berisi total, formulir tersibuk, dan alasan teratas. [Endpoint schema](https://ikiform.com/id/docs/shadcn.md#schema-endpoint) dan [dokumentasi spam](https://ikiform.com/id/docs/spam.md#scoring) berisi detailnya.

> Jika masih ada beberapa bot yang lolos, ubah **Spam check** ke **Ikiform**. Pengaturan ini menambahkan Cloudflare Turnstile tanpa key, dan kebanyakan pengunjung tidak melihat apa pun. Itulah saatnya menambahkan captcha: ketika folder spam menunjukkannya, bukan sebelumnya.

## Pastikan berfungsi

1. Muat formulir dan kirim dalam satu detik. Dengan handler Anda sendiri, kiriman disimpan sebagai spam dengan `too_fast`.
2. Ubah nilai tersembunyi `rendered` di dev tools browser lalu kirim. Kiriman mendapat skor sebagai `invalid_render`.
3. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
4. Kirim pesan dengan tiga link dan tidak ada hal mencurigakan lainnya. Pesan diterima.
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

- **Halaman statis.** Halaman yang dibangun sekali saat deploy punya satu waktu render untuk setiap pengunjung. Tanda tangani per request, atau ambil token baru dengan JavaScript saat formulir muncul.
- **Password manager dan tempel.** Sebagian orang mengisi formulir dalam satu detik dengan autofill. Jadikan waktu sebagai sinyal lunak agar tidak pernah memblokir sendirian.
- **Daftar frasa cepat basi.** Frasa spam berubah. Tinjau yang lolos setiap beberapa minggu dan tambahkan frasa yang spesifik, jangan pernah kata umum.
- **Pengulangan itu wajar.** Orang mengirim ulang saat tidak mendapat balasan. Beri bobot pada pengulangan identik, tetapi jangan langsung memblokirnya.

## Pertanyaan

### Bisakah saya menghentikan spam formulir tanpa captcha?

Sebagian besar. Honeypot, waktu render bertanda tangan, skor konten, dan rate limit menghentikan sebagian besar spam yang diterima situs kecil, tanpa biaya bagi pengunjung sungguhan. Bot yang menjalankan browser penuh dan menunggu mungkin masih lolos, dan saat itulah pemeriksaan tak terlihat seperti Turnstile membantu.

### Berapa lama jebakan waktu sebaiknya menunggu?

Sekitar tiga detik untuk formulir kontak. Lebih singkat akan melewatkan bot cepat; lebih lama mulai menangkap orang yang menempelkan pesan siap pakai atau memakai autofill. Pakai sebagai salah satu dari beberapa sinyal, bukan blokir keras.

### Mengapa timestamp perlu ditandatangani?

Timestamp biasa di field tersembunyi bisa diubah bot mana pun agar terlihat lebih lama. Menandatanganinya di server dengan secret berarti hanya server Anda yang bisa menghasilkan waktu render yang valid.

### Apakah Ikiform butuh captcha untuk menyaring spam?

Tidak. Honeypot, waktu bertanda tangan, skor konten, dan rate limit berjalan di setiap formulir meskipun spam check dimatikan. Pemeriksaan Turnstile terkelola adalah tambahan yang Anda aktifkan per formulir jika spam masih lolos.

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