Panduan15 menit baca
Cara menghentikan spam di formulir kontak
Hentikan spam formulir kontak dengan lapisan yang tidak membebani orang sungguhan: honeypot, waktu pengisian, skor konten, rate limit, dan pemeriksaan manusia hanya bila perlu.
Tumpuk pemeriksaan murah yang tak terlihat sebelum menambahkan hambatan apa pun: field honeypot tersembunyi, waktu minimum mengisi formulir, skor untuk link dan frasa spam, serta rate limit per pengunjung. Tambahkan pemeriksaan manusia seperti Cloudflare Turnstile hanya jika spam masih lolos, dan verifikasi token-nya di server. Simpan yang tertangkap alih-alih membuangnya, agar Anda bisa menyelamatkan pesan asli yang sesekali ikut tersaring.
Formulir kontak di halaman publik mulai kebanjiran sampah dalam beberapa hari setelah diindeks. Sebagian besar tidak ditujukan ke Anda. Skrip menjelajahi web mencari <form> apa pun, mengisi setiap input, lalu mengirimnya, berharap ada link yang tertanam di suatu tempat. Sebagian kecil datang dari browser sungguhan yang dikendalikan software, dan sebagian lagi dari orang yang dibayar untuk menempelkan tawaran yang sama ke ribuan formulir. Setiap jenis dihentikan oleh pemeriksaan yang berbeda, itulah sebabnya satu trik saja tidak pernah cukup.
Panduan ini menjelaskan setiap pertahanan, apa yang ditangkapnya, dan apa biayanya bagi pengunjung sungguhan, dengan kode yang bisa dijalankan di stack apa pun. Lalu panduan ini menunjukkan bagaimana Ikiform, sebuah form backend, menjalankan lapisan yang sama untuk Anda.
Lapisan, dari yang termurah
Urutkan pemeriksaan berdasarkan biayanya. Menghitung request hampir gratis, jadi dijalankan sebelum body dibaca. Pemeriksaan yang memanggil layanan lain, seperti captcha, dijalankan setelah yang murah lolos. Penilaian konten berjalan terakhir, karena butuh field yang sudah di-parse. Ikiform menjalankan pemeriksaannya dalam urutan ini untuk setiap kiriman.
- Sebuah kiriman tiba di endpoint formulir.
- Rate limit menghitung kiriman per pengunjung terlebih dulu. Banjir kiriman ditolak dengan 429 sebelum body dibaca.
- Domain yang diizinkan membandingkan halaman asal kiriman dengan domain yang Anda daftarkan.
- Jika pemeriksaan manusia aktif, kiriman harus membawa token Turnstile yang valid.
- Honeypot menambah 5 ke skor jika terisi.
- Waktu pengisian, link, frasa spam yang dikenal, alamat sekali pakai, dan kiriman berulang menambah nilai yang lebih kecil.
- Pada skor 5 atau lebih, kiriman disimpan sebagai spam. Di bawahnya, kiriman diterima dan email serta webhook Anda terkirim.
Perbandingan pertahanan
Setiap pertahanan mengorbankan sesuatu. Tabel ini jujur tentang apa yang terlewat oleh masing-masing dan apa yang dimintanya dari orang yang ingin Anda dengar.
| Pertahanan | Menghentikan | Melewatkan | Biaya bagi pengunjung sungguhan |
|---|---|---|---|
| Honeypot | Skrip yang mengisi setiap input | Bot yang membaca CSS atau melewati field tersembunyi, dan manusia | Tidak ada, asalkan tersembunyi dari screen reader dan autofill |
| Jebakan waktu | Kiriman yang lebih cepat dari kecepatan siapa pun mengetik | Bot lambat, dan bot yang sengaja menunggu | Tidak ada, selama batas minimumnya beberapa detik |
| Penilaian konten, seperti jumlah link, frasa spam, atau Akismet | Tawaran SEO, sebar link, spam kasino dan obat | Spam yang tampak bersih, dan bisa salah menandai pesan asli yang berisi link | Tidak ada, asalkan Anda meninjau yang tertangkap |
| Rate limiting | Banjir kiriman dan skrip yang berulang | Spam lambat yang tersebar | Kantor dan acara di balik satu IP bisa terkena batas yang ketat |
| Domain yang diizinkan | Kiriman yang dikirim langsung ke endpoint Anda dari tempat lain | Skrip yang memalsukan header Origin | Tidak ada |
| Cloudflare Turnstile | Sebagian besar browser otomatis | Spammer manusia bayaran | Biasanya tak terlihat. Kadang perlu satu klik, dan butuh skrip |
| reCAPTCHA atau hCaptcha | Sebagian besar browser otomatis | Spammer manusia bayaran dan layanan pemecah captcha | Teka-teki gambar untuk sebagian pengunjung, ditambah skrip pihak ketiga |
Mulailah dengan lima yang pertama. Semuanya tak terlihat dan menangkap sebagian besar spam yang diterima situs kecil. Tambahkan pemeriksaan manusia saat dashboard menunjukkan spam masih lolos, jangan sebelumnya: itu satu-satunya lapisan yang biayanya terasa oleh setiap pengunjung. Halaman tentang menghentikan spam tanpa captcha menunjukkan seberapa jauh lapisan tak terlihat bisa bekerja sendiri.
Baca spamnya sebelum melawannya
Lihat sepuluh pesan spam sebelum memilih solusi. Kesamaan di antara mereka memberi tahu siapa pengirimnya, dan itu menunjuk ke lapisan yang bisa menghentikannya.
- Teks acak di setiap field berasal dari skrip yang mengisi semua input. Honeypot menangkapnya.
- Puluhan kiriman per menit dari satu sumber berasal dari skrip yang berulang. Rate limit menghentikannya.
- Kiriman rapi yang dikirim dalam satu detik setelah halaman dimuat berasal dari headless browser. Jebakan waktu atau pemeriksaan manusia menghentikannya.
- Tawaran sopan berisi link berasal dari orang yang dibayar per formulir. Hanya penilaian konten dan tinjauan Anda sendiri yang bisa menangkapnya.
Formulir kontak dengan semua lapisan terpasang
Berikut satu formulir kontak, dibuat untuk setiap stack. Setiap versi punya honeypot di luar layar dan pemeriksaan manusia. Versi khusus browser memakai pemeriksaan Turnstile terkelola dari Ikiform, yang tidak butuh key. Versi server menunjukkan cara mandiri: server Anda memverifikasi token Turnstile dengan key Cloudflare Anda sendiri, lalu meneruskan field-nya. Pilih stack Anda di bawah; pilihan itu berlaku untuk setiap contoh kode di panduan ini.
Tab server berbagi dua helper kecil. verifyTurnstile menanyakan ke Cloudflare apakah token itu asli, dan sendToIkiform meneruskan field dengan secret key Anda dan header Origin milik pengunjung. Fetch dari sisi server tidak mengirim Origin sendiri, dan Ikiform membacanya untuk mengenali uji gratis dari localhost. Keduanya gagal secara tertutup: jika Cloudflare tidak bisa dihubungi, pengunjung melihat error, bukan lolos diam-diam.
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export interface Contact {
name: string;
email: string;
message: string;
_gotcha: string;
}
export function readContact(data: FormData): Contact {
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
}
export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
if (!input.token || input.token.length > 2048) return false;
const body = new URLSearchParams({ secret: input.secret, response: input.token });
if (input.ip) body.set("remoteip", input.ip);
try {
const response = await fetch(SITEVERIFY, { method: "POST", body });
const result = (await response.json()) as { success?: boolean };
return result.success === true;
} catch {
return false;
}
}
export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${secretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(contact),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true as const };
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.values(fields)[0]?.[0];
return {
ok: false as const,
status: response.status,
message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
};
} catch {
return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
}
}Formulir biasa tidak butuh skrip. Atur Spam check formulir Ikiform ke Ikiform, dan kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile lalu meneruskan field-nya. Orang yang lolos tanpa terlihat tidak akan menyadarinya.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<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 minlength="10" maxlength="5000"></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>
<button>Send message</button>
</form>
<style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>Honeypot diletakkan di luar layar, bukan di balik display: none, karena sebagian bot melewati field yang disembunyikan dengan cara itu. aria-hidden pada wrapper dan tabindex pada input menjauhkan screen reader dan keyboard darinya.
Server action memverifikasi token Turnstile dengan key Anda sendiri, lalu meneruskan field dengan secret key Ikiform Anda. Honeypot ikut diteruskan apa adanya, jadi yang terisi disimpan sebagai spam dan bisa Anda tinjau. Simpan helper di atas sebagai lib/spam.ts. Atur TURNSTILE_SECRET_KEY dan IKIFORM_SECRET_KEY sebagai environment variable khusus server, dan biarkan Spam check formulir Ikiform diatur ke Off: server Anda sudah menjalankan pemeriksaan manusia.
"use server";
import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";
export interface ContactState {
ok: boolean;
message: string;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const contact = readContact(formData);
const values = { name: contact.name, email: contact.email, message: contact.message };
const incoming = await headers();
const forwarded = incoming.get("x-forwarded-for");
const human = await verifyTurnstile({
secret: process.env.TURNSTILE_SECRET_KEY!,
token: String(formData.get("cf-turnstile-response") ?? ""),
ip: forwarded?.split(",")[0]?.trim(),
});
if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };
const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
return { ok: false, message: result.message, values };
}"use client";
import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { ok: false, message: "" };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.values?.name} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.message} />
<div className="hp" aria-hidden="true">
<label htmlFor="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
</div>
<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}"use client";
import { useEffect, useRef } from "react";
interface TurnstileApi {
render(container: HTMLElement, options: { sitekey: string; action?: string }): string;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
function loadTurnstile() {
return new Promise<TurnstileApi>((resolve) => {
if (window.turnstile) return resolve(window.turnstile);
let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
if (!script) {
script = document.createElement("script");
script.src = SCRIPT;
script.async = true;
document.head.append(script);
}
script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
});
}
export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
let widgetId: string | undefined;
let active = true;
loadTurnstile().then((turnstile) => {
if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
});
return () => {
active = false;
if (widgetId) window.turnstile?.remove(widgetId);
};
}, [siteKey, reset]);
return <div ref={box} />;
}Widget dirender secara eksplisit dan dirender ulang setiap kali action mengembalikan hasil, karena token Turnstile hanya berlaku sekali. defaultValue mengembalikan teks pengunjung setelah error, karena React mengosongkan formulir setelah action-nya berjalan.
Ingin lebih jauh dengan Next.js? Baca Perlindungan spam formulir Next.js dengan Turnstile.
Astro Action memvalidasi field dengan Zod, memeriksa token terhadap context.clientAddress, dan meneruskan kiriman. Simpan helper di atas sebagai src/lib/spam.ts dan baca kedua key dengan getSecret dari astro:env/server. Formulir mengirim tanpa JavaScript di client, jadi setiap percobaan memuat ulang halaman dan widget dirender baru. Tambahkan adapter dan pertahankan prerender = false. Atur TURNSTILE_SECRET_KEY dan IKIFORM_SECRET_KEY sebagai environment variable khusus server, dan biarkan Spam check formulir Ikiform diatur ke Off: server Anda sudah menjalankan pemeriksaan manusia.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1).max(120),
email: z.email(),
message: z.string().trim().min(10).max(5000),
_gotcha: z.string().nullish(),
"cf-turnstile-response": z.string().nullish(),
}),
handler: async (input, context) => {
const human = await verifyTurnstile({
secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
token: input["cf-turnstile-response"] ?? "",
ip: context.clientAddress,
});
if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });
const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
name: input.name,
email: input.email,
message: input.message,
_gotcha: input._gotcha ?? "",
}, context.request.headers.get("origin"));
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
return { sent: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---
{result?.data?.sent ? (
<p role="status">Thanks. We'll reply by email.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<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 minlength="10"></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 class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
)}
<button>Send message</button>
</form>
)}
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Ingin lebih jauh dengan Astro? Baca Perlindungan spam formulir Astro dengan Turnstile.
Function POST createServerFn membaca IP pengunjung dengan getRequestHeader, memverifikasi token, dan meneruskan field. Komponen widget-nya sama dengan yang ada di tab Next.js, di src/components/turnstile.tsx. Atur TURNSTILE_SECRET_KEY dan IKIFORM_SECRET_KEY sebagai environment variable khusus server, dan biarkan Spam check formulir Ikiform diatur ke Off: server Anda sudah menjalankan pemeriksaan manusia.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
})
.handler(async ({ data }) => {
const human = await verifyTurnstile({
secret: process.env.TURNSTILE_SECRET_KEY!,
token: data.token,
ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
});
if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await send({ data }));
} catch {
setState({ ok: false, message: "Couldn't send your message. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} />
<div className="hp" aria-hidden="true">
<label htmlFor="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
</div>
<Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Ingin lebih jauh dengan TanStack Start? Baca Perlindungan spam formulir TanStack Start dengan Turnstile.
action route memverifikasi token dan meneruskan kiriman. Memberikan actionData sebagai reset merender widget baru setelah setiap jawaban. Komponen widget-nya adalah yang dari tab Next.js. Atur TURNSTILE_SECRET_KEY dan IKIFORM_SECRET_KEY sebagai environment variable khusus server, dan biarkan Spam check formulir Ikiform diatur ke Off: server Anda sudah menjalankan pemeriksaan manusia.
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const human = await verifyTurnstile({
secret: process.env.TURNSTILE_SECRET_KEY!,
token: String(formData.get("cf-turnstile-response") ?? ""),
ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
});
if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });
const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
return data({ ok: false, message: result.message }, { status: result.status });
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} />
<div className="hp" aria-hidden="true">
<label htmlFor="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
</div>
<Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}Ingin lebih jauh dengan React Router? Baca Perlindungan spam formulir React Router dengan Turnstile.
Form action default memverifikasi token dengan getClientAddress() dan meneruskan kiriman. Versi ini sengaja tidak memakai use:enhance: setiap percobaan memuat ulang halaman, sehingga Turnstile merender widget baru dengan token baru. Simpan helper di atas sebagai src/lib/spam.ts. Deklarasikan key di src/env.ts dengan defineEnvVars dan beri PUBLIC_TURNSTILE_SITE_KEY nilai public: true, agar halaman bisa mengimpornya dari $app/env/public. Atur TURNSTILE_SECRET_KEY dan IKIFORM_SECRET_KEY sebagai environment variable khusus server, dan biarkan Spam check formulir Ikiform diatur ke Off: server Anda sudah menjalankan pemeriksaan manusia.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress, url }) => {
const data = await request.formData();
const contact = readContact(data);
const values = { name: contact.name, email: contact.email, message: contact.message };
const human = await verifyTurnstile({
secret: TURNSTILE_SECRET_KEY,
token: String(data.get("cf-turnstile-response") ?? ""),
ip: getClientAddress(),
});
if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });
const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
if (!result.ok) return fail(result.status, { values, error: result.message });
return { sent: true };
},
} satisfies Actions;<script lang="ts">
import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
</script>
<svelte:head>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>
{#if form?.sent}
<p role="status">Thanks. We'll reply by email.</p>
{:else}
<form method="POST">
<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 minlength="10" 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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
{#if form?.error}
<p role="alert">{form.error}</p>
{/if}
<button>Send message</button>
</form>
{/if}Ingin lebih jauh dengan SvelteKit? Baca Perlindungan spam formulir SvelteKit dengan Turnstile.
Server route memverifikasi token dan meneruskan kiriman. Simpan helper di atas sebagai server/utils/spam.ts dan Nuxt mengimpornya secara otomatis. Komponennya merender Turnstile secara eksplisit dan mengatur ulang setelah percobaan gagal. Atur TURNSTILE_SECRET_KEY dan IKIFORM_SECRET_KEY sebagai environment variable khusus server, dan biarkan Spam check formulir Ikiform diatur ke Off: server Anda sudah menjalankan pemeriksaan manusia.
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string | undefined>>(event);
const config = useRuntimeConfig(event);
const human = await verifyTurnstile({
secret: config.turnstileSecretKey,
token: body["cf-turnstile-response"] ?? "",
ip: getRequestIP(event, { xForwardedFor: true }),
});
if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });
const result = await sendToIkiform(config.ikiformSecretKey, {
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
_gotcha: body._gotcha ?? "",
}, getRequestHeader(event, "origin"));
if (!result.ok) throw createError({ status: result.status, message: result.message });
return { sent: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
turnstileSecretKey: "",
public: {
turnstileSiteKey: "",
},
},
});<script setup lang="ts">
interface TurnstileApi {
render(
container: HTMLElement,
options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
): string;
reset(widgetId: string): void;
}
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;
function api() {
return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}
function loadTurnstile() {
return new Promise<TurnstileApi>((resolve) => {
const ready = api();
if (ready) return resolve(ready);
let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
if (!script) {
script = document.createElement("script");
script.src = SCRIPT;
script.async = true;
document.head.append(script);
}
script.addEventListener("load", () => api() && resolve(api()!), { once: true });
});
}
onMounted(async () => {
const turnstile = await loadTurnstile();
if (!box.value) return;
widgetId = turnstile.render(box.value, {
sitekey: config.public.turnstileSiteKey,
action: "contact",
callback: (value) => (token.value = value),
"expired-callback": () => (token.value = ""),
});
});
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";
token.value = "";
if (widgetId) api()?.reset(widgetId);
}
}
</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" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" v-model="fields.message" name="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" name="_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>Ingin lebih jauh dengan Nuxt? Baca Perlindungan spam formulir Nuxt dengan Turnstile.
Single-page app tidak punya server untuk menyimpan secret Turnstile, jadi memakai pemeriksaan terkelola dari Ikiform. challenge() dari @ikiform/sdk memasang frame kecil ikiform.com yang tetap tak terlihat kecuali Turnstile butuh klik, lalu mengembalikan token untuk submit(). Atur Spam check ke Ikiform dan tambahkan domain Anda di Allowed domains.
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const check = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
const result = await submit(FORM_ID, data, { turnstileToken });
if (result.ok) {
setStatus("sent");
return;
}
setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
} catch {
setError("The human check didn't finish. Try again.");
}
setStatus("idle");
}
if (status === "sent") return <p role="status">Thanks. We'll reply by email.</p>;
return (
<form onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} />
<div className="hp" aria-hidden="true">
<label htmlFor="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div ref={check} />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
</form>
);
}Pemeriksaan terkelola yang sama dalam single-file component Vue. Frame muncul di dalam div kosong hanya saat pengunjung harus mengklik.
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: SubmitEvent) {
const data = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const turnstileToken = await challenge(FORM_ID, { container: check.value });
const result = await submit(FORM_ID, data, { turnstileToken });
if (result.ok) {
status.value = "sent";
return;
}
error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
} catch {
error.value = "The human check didn't finish. Try again.";
}
status.value = "idle";
}
</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" name="name" autocomplete="name" required maxlength="120" />
<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 minlength="10" />
<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 ref="check" />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
</form>
</template>- Setiap versi memakai class
.hpdari blok style di tab HTML. Taruh di CSS global Anda. - Honeypot diberi nama
_gotcha, default Ikiform. Versi server meneruskannya apa adanya, jadi yang terisi tetap masuk ke folder spam Anda dengan alasanhoneypot. - Turnstile menambahkan input tersembunyi
cf-turnstile-responseke formulir. Token berlaku 300 detik dan hanya bisa dipakai sekali, jadi setiap percobaan butuh token baru. - Key uji dari Cloudflare memungkinkan Anda menjalankan semua ini secara lokal. Panduan Turnstile mencantumkannya.
IKIFORM_SECRET_KEY di environment variable khusus server.Coba definisi field di bawah. Ini menjalankan validator Ikiform di browser Anda, dengan aturan yang sama yang diterapkan server sebelum skor spam dihitung.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
422 validation_failed.Honeypot
Honeypot adalah input yang tidak pernah dilihat orang tetapi diisi oleh bot. Jika ada nilainya, kiriman itu berasal dari software. Tidak ada biaya bagi pengunjung, dan honeypot menangkap bot tertua dan paling umum, yang mem-parse HTML dan mengisi setiap input yang ditemukannya.
Menyembunyikannya dengan baik lebih penting daripada triknya sendiri. Tiga hal sering salah pada honeypot yang dibuat sembarangan:
- Screen reader membacakannya. Pengunjung tunanetra mendengar field tambahan, mengisinya, lalu ditandai sebagai spam. Bungkus dengan
aria-hidden="true"dan beri label yang meminta field itu dikosongkan. - Keyboard berhenti di sana. Menekan Tab di formulir berhenti di input yang tak terlihat.
tabindex="-1"mengeluarkannya dari urutan tab. - Autofill mengisinya. Browser dan password manager mengisi field berdasarkan nama. Beri nama field
email2atauphonedan sebagian akan mengisinya untuk orang sungguhan. Pakai nama yang tidak dikenali autofill, seperti_gotcha, dan tambahkanautocomplete="off".
Panduan honeypot membahas teknik menyembunyikan, cara bot mendeteksinya, dan pemeriksaan sisi server untuk setiap stack. Dengan Ikiform, honeypot yang terisi menambah 5 ke skor, yang sudah mencapai ambang 5 dengan sendirinya. Anda bisa mengganti nama field per formulir di Honeypot field.
Waktu pengisian
Orang butuh beberapa detik untuk mengisi formulir kontak. Banyak bot hanya butuh milidetik. Jebakan waktu mencatat kapan formulir dirender dan memeriksa selisihnya saat dikirim. Versi naifnya menaruh timestamp di field tersembunyi:
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;Bot bisa mengirim angka apa saja, jadi timestamp biasa hanya menghentikan bot yang tidak repot-repot. Tanda tangani di server dengan secret, dan bot tidak bisa memalsukan waktu render yang lebih lambat. Halaman tentang menghentikan spam tanpa captcha membangun versi bertanda tangan.
Ikiform menandatangani waktu render untuk Anda saat formulir berasal dari SDK atau komponen shadcn/ui: keduanya mengirim _ikiform_ts, dan kiriman kurang dari 2 detik setelah render menambah 3 ke skor. Timestamp palsu atau yang berumur sehari juga menambah 3. Formulir HTML biasa tidak mengirimnya dan tidak dikenai penalti.
Menilai konten
Sebagian spam diketik atau ditempel oleh manusia, jadi lolos dari setiap pemeriksaan bot. Yang membongkarnya adalah kontennya: link, frasa jualan, alamat sekali pakai, dan teks yang sama dua kali. Beri skor pada setiap sinyal dan anggap total yang tinggi sebagai spam, alih-alih memblokir berdasarkan satu sinyal saja. Satu link dalam pesan itu wajar; link di field nama tidak pernah wajar.
| Sinyal | Ikiform menambah |
|---|---|
| Honeypot terisi | +5 |
| Link di field nama atau subjek | +3 |
| Field yang sama dikirim ke formulir ini dalam 24 jam terakhir | +3 |
| Dikirim kurang dari 2 detik setelah waktu render bertanda tangan | +3 |
| Tiga link atau lebih di semua field | +2 |
| Salah satu dari 22 frasa spam yang dikenal, seperti "seo services" | +2 |
| Alamat di domain email sekali pakai | +2 |
Pada skor 5 atau lebih, kiriman dianggap spam. Tidak ada satu sinyal lunak yang mencapainya sendirian: pesan asli dengan tiga link mendapat skor 2. Dua sinyal bersamaan sering mencapainya, seperti link di nama dan frasa spam. Jika Anda menilai konten sendiri, Akismet adalah opsi hosted yang sudah lama ada; banyak tim memulai dengan beberapa regular expression seperti di atas.
Pemeriksaan manusia
Pemeriksaan manusia meminta browser membuktikan bahwa ia dikendalikan oleh manusia. Yang modern, seperti Cloudflare Turnstile, sebagian besar berjalan tanpa terlihat dan hanya menampilkan checkbox jika ragu. Widget memberi browser sebuah token; server Anda harus mengirim token itu ke penyedia untuk diverifikasi. Token yang tidak pernah diverifikasi di server tidak menghentikan apa pun, karena bot bisa melewati widget dan mengirim tanpanya.
Ikiform punya tiga pengaturan di Spam check pada setiap formulir:
| Pengaturan | Yang terjadi | Perubahan kode |
|---|---|---|
| Off (the default) | Tanpa tantangan. Honeypot, waktu pengisian, dan skor tetap berjalan. | Tidak ada |
| Ikiform | Turnstile dijalankan Ikiform di ikiform.com, tanpa akun atau key Cloudflare | Tidak ada untuk kiriman HTML. Kiriman fetch memanggil challenge() dari @ikiform/sdk terlebih dulu |
| Your own Turnstile keys (key Turnstile Anda sendiri) | Widget dan secret key Anda. Ikiform memverifikasi token dan mencocokkan hostname-nya dengan domain yang diizinkan | Render widget di formulir Anda agar mengirim cf-turnstile-response |
Kiriman fetch tanpa token mendapat 403 challenge_required. Jika Cloudflare tidak bisa dihubungi, Ikiform tetap menerima kiriman dan menyimpannya sebagai spam dengan alasan captcha_unavailable, sehingga Anda bisa memulihkannya. Dokumentasi spam menunjukkan kedua alur. Jika Anda menimbang Turnstile dibandingkan reCAPTCHA dari Google atau hCaptcha, lihat alternatif reCAPTCHA.
Rate limit dan domain yang diizinkan
Rate limit membatasi seberapa sering satu pengunjung bisa mengirim. Ia tidak membedakan spam dari pesan asli; ia menghentikan volume. Hitung per IP dan formulir, izinkan lonjakan kecil, dan jawab 429 dengan header Retry-After. Panduan rate limiting membangunnya untuk setiap stack dan menjelaskan mengapa penghitung in-memory gagal di hosting serverless.
Ikiform menghitung setiap kiriman secara tepat, sebelum membaca body. Setiap formulir memilih preset: Standard, 5 per menit per pengunjung, Relaxed, 20 per menit per pengunjung, atau Strict, 2 per 10 menit per pengunjung. Standard juga mengizinkan maksimal 3 per 10 detik, dan seluruh formulir menerima 300 per menit. Formulir kontak cocok dengan Strict, karena tidak ada orang jujur yang menulis dua kali dalam sepuluh menit. Angkanya ada di Rate limit.
Allowed domains mencantumkan situs tempat formulir Anda berada. Kiriman dari halaman lain ditandai sebagai spam, atau ditolak dengan 403 origin_not_allowed jika Anda memilih Reject. Ini mencegah orang mengarahkan formulir mereka sendiri ke endpoint Anda. Skrip yang gigih bisa memalsukan header yang diperiksanya, jadi anggap ini satu lapisan, bukan kunci.
Apa yang terjadi pada spam
Jangan pernah memberi tahu bot bahwa ia tertangkap. Error mengajarinya apa yang harus diubah; halaman sukses biasa tidak mengajarinya apa-apa. Dan jangan pernah menghapus spam begitu masuk: setiap filter sesekali menangkap pesan asli, dan yang sudah dihapus hilang selamanya.
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.
Buka filter status di dashboard dan pilih Spam untuk melihat masing-masing beserta skor dan alasannya. Not spam memindahkannya kembali, dan email serta webhook-nya terkirim saat itu. Matikan email hari Senin di Weekly spam summary pada pengaturan workspace. Spam disimpan 30 hari, jadi cek minimal sebulan sekali.
business_name dinilai sebagai link di nama; ganti nama field menjadi business dan field itu tidak lagi dihitung sebagai field nama.Pilih situasi Anda
Setiap halaman ini membahas lebih dalam satu pertahanan atau satu jenis situs, dengan kode untuk kasus itu.
- Field honeypotTambahkan honeypot ke formulir apa pun yang menangkap bot tanpa mengganggu screen reader, keyboard, atau autofill, dengan pemeriksaan server untuk setiap stack.
- Cloudflare TurnstileTambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.
- Tanpa captchaHentikan 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 reCAPTCHABandingkan alternatif reCAPTCHA untuk formulir, dari Turnstile dan hCaptcha hingga proof-of-work dan tanpa captcha sama sekali, lalu beralih dengan perubahan kecil yang aman.
- Rate limitingBatasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.
- Situs statisHentikan 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.
- Spam formulir WebflowLindungi formulir Webflow yang mengirim ke form backend, tempat pengaturan spam Webflow tidak pernah berjalan, dengan honeypot di luar layar dan pemeriksaan manusia.
- Next.jsLindungi formulir Next.js dari spam: honeypot, Turnstile yang dicek di server action, limit per IP di Vercel, Netlify, atau Cloudflare, dan CSP-nya.
- AstroLindungi formulir Astro dari spam dengan Action: honeypot, Turnstile yang dicek dengan secret astro:env, limit per IP, dan CSP bawaan Astro.
- TanStack StartLindungi formulir TanStack Start dari spam: function middleware untuk IP client, token bucket, Turnstile yang diverifikasi di server function, dan CSP.
- React RouterLindungi formulir React Router dari spam: route action dengan Turnstile, rate limit Workers berdasarkan cf-connecting-ip, modul .server, dan header CSP.
- SvelteKitLindungi formulir SvelteKit dari spam: form action dengan getClientAddress, limit per IP, Turnstile di balik use:enhance, dan CSP yang mengizinkannya.
- NuxtLindungi formulir Nuxt dari spam: server route Nitro dengan Turnstile, limit per IP di Nitro storage, secret di runtimeConfig, dan CSP yang diatur di routeRules.
Pastikan berfungsi
Jalankan ini pada formulir uji sebelum Anda mengandalkannya. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari localhost. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat harga.
- Di Allowed domains formulir, tambahkan domain situs Anda, ditambah
localhostselama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja. - Isi formulir di halaman Anda dan kirim. Anda mendarat di halaman terima kasih Anda, atau halaman hosted milik Ikiform.
- Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
- Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Kirim pesan dengan link di field nama dan frasa
seo servicesdi pesan. Kiriman disimpan sebagai spam dengan alasanurl_in_namedankeyword. - Ubah Spam check ke Ikiform lalu kirim formulir HTML biasa. Anda melihat halaman pemeriksaan sebentar, lalu halaman terima kasih Anda.
- Kirim formulir beberapa kali berturut-turut. Setelah melewati batas, formulir menjawab
429dan pengunjung melihat halaman perlambat dari Ikiform. - Tidak ada yang masuk? Ubah filter status di dashboard ke Spam. Spam mendapat jawaban sukses yang sama dengan kiriman asli.
Pemecahan masalah
| Masalah | Yang harus dilakukan |
|---|---|
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter Spam untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
403 challenge_required | Spam check formulir diatur ke Ikiform dan kiriman fetch tidak membawa token. Dapatkan token dengan challenge(formId) dari @ikiform/sdk sebelum setiap pengiriman, atau matikan pemeriksaannya. |
403 origin_not_allowed, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di Allowed domains formulir. Tambahkan, ditambah localhost untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |
429 rate_limited | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai Retry-After, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
403 captcha_failed | Token Turnstile tidak ada, kedaluwarsa, atau dipakai dua kali. Token berlaku 300 detik dan hanya sekali pakai, jadi render widget baru, atau panggil challenge() lagi, setelah setiap percobaan. |
| Spam masih masuk sebagai diterima | Buka salah satunya dan cari kesamaannya dengan yang lain. Spam dari skrip perlu Spam check diatur ke Ikiform; tawaran yang diketik manusia perlu rate limit yang lebih ketat dan tinjauan mingguan. |
| Pengunjung sungguhan melaporkan tidak bisa mengirim | Pastikan honeypot tersembunyi dari screen reader dan diberi nama yang dilewati autofill, dan rate limit yang ketat tidak menjebak orang di balik satu IP kantor. |
Pertanyaan
Lapisan, bukan satu trik. Honeypot tersembunyi, waktu minimum mengisi formulir, skor konten, dan rate limit menghentikan sebagian besar spam tanpa biaya bagi pengunjung sungguhan. Tambahkan pemeriksaan manusia seperti Cloudflare Turnstile jika spam masih lolos, dan verifikasi token-nya di server.
Tidak di awal. Sebagian besar spam formulir kontak berasal dari bot sederhana yang bisa dihentikan honeypot dan rate limit. Tambahkan captcha saat folder spam menunjukkan bot masih lolos, dan pilih yang tak terlihat seperti Turnstile daripada teka-teki gambar.
Sebagian spam ditempel oleh orang yang dibayar per formulir, biasanya tawaran SEO atau jasa pengembangan. Pemeriksaan bot tidak bisa menghentikan manusia. Penilaian konten menangkap banyak di antaranya dari link dan frasanya; sisanya Anda hapus di folder spam.
Tidak. Spam mendapat jawaban sukses yang sama dengan pesan asli, jadi bot tidak belajar apa-apa. Spam disimpan 30 hari, tidak mengirim email atau webhook, dan Anda bisa menandainya sebagai bukan spam dari dashboard.
Tidak. Atur Spam check formulir ke Ikiform dan Turnstile berjalan di ikiform.com tanpa key yang perlu dikelola. Pakai key Turnstile sendiri hanya jika Anda ingin widget sendiri di halaman Anda.
Bisa, jika disembunyikan sembarangan. Sembunyikan di luar layar dengan aria-hidden pada wrapper-nya, keluarkan dari urutan tab dengan tabindex -1, dan beri nama yang tidak diisi otomatis oleh browser.
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.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.