Hentikan spam formulir kontak

Hentikan spam formulir kontak8 menit baca

Perlindungan spam formulir Next.js dengan Turnstile

Lindungi formulir Next.js dari spam: honeypot, Turnstile yang dicek di server action, limit per IP di Vercel, Netlify, atau Cloudflare, dan CSP-nya.

Preet SutharPendiri, Ikiform

Di Next.js, periksa spam di dalam server action, karena action adalah endpoint POST publik yang bisa dipanggil siapa pun tanpa formulir Anda. Batasi berdasarkan IP client yang diatur hosting Anda, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya dengan honeypot utuh. Muat skrip Turnstile lewat next/script dengan nonce CSP Anda, dan izinkan challenges.cloudflare.com di frame-src.

Apa yang berbeda di Next.js

Server action terlihat seperti pemanggilan fungsi, tetapi Next.js mengubahnya menjadi endpoint POST dengan ID di JavaScript halaman. Bot tidak butuh formulir Anda. Bot bisa mengulang POST itu dengan field apa saja, secepat apa pun. Jadi setiap pemeriksaan harus ada di dalam action, jangan hanya di komponen.

Tiga detail Next.js membentuk sisa halaman ini:

  • Tidak ada request.ip. IP client datang dari header, dan header mana yang bisa dipercaya bergantung pada hosting.
  • Nonce berarti rendering dinamis. Nonce per request tidak bisa diterapkan pada halaman yang dibangun saat build.
  • Navigasi client membuat halaman tetap hidup. Skrip Turnstile yang dimuat sekali tidak akan merender widget lagi setelah soft navigation.
Urutan pemeriksaan di dalam server action Next.jsserveractionPOSTyangbisadikirimsiapapunratelimitaturanVercelFirewall,per IPsiteverifytoken,action,hostnameIkiformskorhoneypot,simpanstateerrortak adayangditeruskandi bawah limitmanusiamelewati limitUrutan pemeriksaan di dalam server action Next.jsserver actionPOST yang bisadikirim siapa punrate limitaturan VercelFirewall, per IPsiteverifytoken, action,hostnameIkiformskor honeypot, simpanstateerrortak ada yangditeruskandi bawah limitmanusiamelewati limit
  1. Formulir, atau skrip yang mengulangnya, mengirim ke server action.
  2. Action memeriksa hitungan pengunjung terhadap aturan Vercel Firewall. Jika melewati batas, action mengembalikan state error dan berhenti.
  3. Action mengirim token Turnstile dan IP client ke siteverify, lalu memeriksa nama action dan hostname di jawabannya.
  4. Baru setelah itu action meneruskan field, termasuk honeypot, ke Ikiform, yang memberi skor dan menyimpan pesannya.
Pemeriksaan paling murah berjalan lebih dulu. Honeypot diteruskan tanpa diubah, sehingga honeypot yang terisi disimpan sebagai spam alih-alih dibuang diam-diam.

Setup proyek

File yang ditambahkan halaman ini ke proyek App Router:

File
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local

Buat widget Turnstile di dashboard Cloudflare dan daftarkan setiap hostname tempat formulir berjalan. Selama pengembangan, pakai test key Cloudflare, yang lolos di host mana pun, termasuk localhost:

.env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
  • Hanya NEXT_PUBLIC_TURNSTILE_SITE_KEY yang sampai ke browser. Next.js menyisipkan nilai NEXT_PUBLIC_ saat build, jadi mengganti site key butuh build baru.
  • TURNSTILE_HOSTNAME dibiarkan kosong secara lokal, karena token uji tidak membawa hostname Anda. Atur ke domain asli Anda di production.
  • Ganti secret dengan 2x0000000000000000000000000000000AA untuk melihat setiap pemeriksaan gagal.

Baca IP client yang diatur hosting Anda

Rate limit yang memakai header yang bisa ditulis pengunjung sebagai kunci bukanlah limit. Setiap hosting mengatur header yang ditimpanya saat request masuk, dan hanya header itu yang bisa dipercaya:

Header mana yang membawa IP client asli di setiap hostingrequest masukclientIp()bacaCLIENT_IP_HEADERVercelx-real-ip ataux-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ipHeader mana yang membawa IP client asli di setiap hostingrequestmasukclientIp()bacaCLIENT_IP_HEADERVercelx-real-ip ataux-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip
  1. clientIp() membaca satu header yang disebut di CLIENT_IP_HEADER dan mengabaikan sisanya.
  2. Vercel menimpa x-forwarded-for dan x-real-ip dengan alamat pengunjung.
  3. Netlify menaruh alamatnya di x-nf-client-connection-ip.
  4. Cloudflare, termasuk Next.js yang di-deploy dengan OpenNext, mengatur cf-connecting-ip.
Vercel mendokumentasikan bahwa ia menimpa X-Forwarded-For untuk mencegah spoofing. Dokumentasi Netlify hanya menyebut context.ip di Functions; x-nf-client-connection-ip adalah header yang ditunjuk staf Netlify.
lib/client-ip.ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}

Verifikasi Turnstile di helper khusus server

lib/turnstile.ts
import "server-only";

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

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
  • Token berlaku selama 300 detik dan hanya bisa diverifikasi sekali, setelah itu siteverify mengembalikan timeout-or-duplicate.
  • success saja tidak cukup. Token yang dibuat di situs lain dengan site key Anda juga berbunyi success, jadi helper memeriksa action dan, di production, hostname.
  • Helper ini gagal secara tertutup. Jika Cloudflare tidak bisa dihubungi, pengunjung melihat error dan tidak ada bot yang lolos.

Server action

app/contact/actions.ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}

Di Vercel, limit datang dari Firewall. Buka Firewall proyek Anda, tambahkan aturan dengan kondisi @vercel/firewall, atur Rate limit ID ke contact-form, lalu pilih jendela waktu seperti 5 request per menit. checkRateLimit menerima header request, jadi bisa dipakai di dalam server action yang tidak punya objek Request, dan mengelompokkan berdasarkan IP client kecuali Anda memberikan rateLimitKey.

  • Vercel menghitung setiap aturan per region, jadi traffic yang tersebar di beberapa region bisa melebihi angka yang Anda atur. Untuk formulir kontak itu tidak masalah; untuk hitungan global yang tepat, pakai Redis, seperti di halaman rate limiting.
  • Header Origin pengunjung diteruskan karena fetch dari sisi server tidak mengirimnya, dan Ikiform membacanya untuk mengenali uji gratis dari localhost.
  • Di luar Vercel, panggilan Firewall dilewati. Taruh limiter Redis atau Workers di sana, dengan clientIp() sebagai kuncinya.
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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

Formulir dan widget

app/contact/contact-form.tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

interface Turnstile {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") 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.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.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>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
  • ?render=explicit mencegah skrip memindai halaman untuk mencari widget. onReady berjalan begitu skrip dimuat dan setiap kali komponen di-mount, yang mencakup navigasi sisi client.
  • Token hanya berlaku sekali. Saat action mengembalikan error, effect mereset widget agar percobaan berikutnya membawa token baru.
  • Formulir juga bisa mengirim sebelum hydration, karena action adalah server action itu sendiri. Tanpa JavaScript tidak ada widget, jadi siteverify menolak kiriman itu; pemeriksaan manusia butuh browser.
  • Class .hp dari panduan honeypot menyembunyikan honeypot di luar layar.
app/contact/page.tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}

CSP yang membiarkan Turnstile berjalan

Next.js membaca nonce dari header request Content-Security-Policy dan menambahkannya ke skripnya sendiri serta ke setiap <Script> yang diberi prop nonce. Cloudflare menyatakan Turnstile meneruskan nonce ke apa yang dimuatnya, dan iframe-nya butuh frame-src https://challenges.cloudflare.com. Di Next.js 16 file-nya adalah proxy.ts, yang menggantikan middleware.ts:

proxy.ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
  • Dengan 'strict-dynamic', browser modern memercayai nonce dan mengabaikan host, yang tetap dipertahankan untuk browser lama.
  • Membaca headers() di halaman membuatnya dirender per request, yang memang dibutuhkan nonce. Halaman kontak yang dibangun statis tidak bisa membawa nonce; pakai policy tanpa nonce di headers() milik next.config sebagai gantinya, dengan konsekuensi 'unsafe-inline'.

Deploy

HostingCLIENT_IP_HEADERRate limitCatatan
Vercelx-real-ipAturan Firewall dan checkRateLimitTambahkan aturan untuk setiap environment yang Anda uji
Netlifyx-nf-client-connection-ipRedis, seperti UpstashServer action berjalan dalam satu Netlify function
Cloudflare, dengan OpenNextcf-connecting-ipBinding rate limiting WorkersBinding-nya bersifat perkiraan dan dihitung per lokasi

Atur TURNSTILE_HOSTNAME ke domain asli Anda di production. Deployment preview punya hostname sendiri, jadi tambahkan ke widget atau matikan pemeriksaan di preview.

Biarkan Ikiform menjalankan pemeriksaannya

Semua yang di atas ada karena server action meneruskan dengan secret key, yang mematikan pemeriksaan manusia dan limit per pengunjung milik Ikiform. Kirim dari browser saja, dan Ikiform menjalankan honeypot, Turnstile, dan rate limit untuk Anda. Tanpa key Cloudflare, tanpa perubahan proxy.ts selain mengizinkan https://ikiform.com di frame-src dan connect-src, dan tanpa action:

app/contact/contact-form.tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}

Atur Spam check formulir ke Ikiform dan Rate limit-nya ke Strict, 2 per 10 menit per pengunjung, lalu tambahkan domain Anda ditambah localhost di Allowed domains. challenge() memasang frame kecil ikiform.com yang tetap tak terlihat kecuali Turnstile butuh klik. Spam mendapat jawaban sukses yang sama dengan pesan asli, disimpan beserta alasan tertangkapnya, dan disimpan selama 30 hari sebelum dihapus. Panduan setup Next.js membahas cara membuat formulir dan key-nya.

Pastikan berfungsi

  1. Jalankan next dev dengan test key dan kirim pesan. Pesan masuk di Submissions. Tanpa paket, workspace menerima 50 kiriman uji per bulan dari localhost.
  2. Atur TURNSTILE_SECRET_KEY ke secret yang selalu gagal lalu kirim. Formulir menampilkan error pemeriksaan manusia dan mempertahankan teks Anda.
  3. Di preview Vercel dengan aturan Firewall, kirim enam kali dalam satu menit. Kiriman keenam menampilkan pesan limit.
  4. Kirim dengan honeypot terisi, _gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
  5. Buka halaman dengan console terbuka. Tidak ada pelanggaran CSP untuk challenges.cloudflare.com.

Hal yang sering menjebak

MasalahYang harus dilakukan
Widget tidak pernah muncul setelah navigasi ke halamanSkrip dimuat di halaman sebelumnya dan tidak akan memindai lagi. Render dari onReady, seperti di contoh, bukan dari onLoad.
Setiap pesan gagal pemeriksaan manusia di productionTURNSTILE_HOSTNAME tidak cocok dengan hostname yang dikembalikan siteverify, sering kali www. versus domain apex. Catat check.reason di log untuk melihat yang mana.
Semua pengunjung berbagi satu bucket rate limitCLIENT_IP_HEADER menyebut header yang tidak diatur hosting Anda, sehingga clientIp() mengembalikan null. Pilih header dari tabel di atas.
checkRateLimit mengembalikan error: "not-found"Tidak ada aturan Firewall dengan Rate limit ID contact-form di environment ini. Tambahkan, lalu publikasikan perubahan Firewall.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.