# Turnstile을 이용한 Next.js 폼 스팸 방지

> 허니팟, 서버 액션에서 확인하는 Turnstile, Vercel·Netlify·Cloudflare에서의 IP별 제한, 그리고 이를 위한 CSP로 Next.js 폼을 스팸으로부터 보호하세요.

2026-10-25 게시 · https://ikiform.com/ko/guides/spam-protection/nextjs

Next.js에서는 서버 액션 안에서 스팸을 확인하세요. 서버 액션은 여러분의 폼 없이도 누구나 호출할 수 있는 공개 POST 엔드포인트이기 때문입니다. 호스트가 설정하는 클라이언트 IP로 레이트 리밋을 걸고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟을 그대로 둔 채 필드를 전달하세요. Turnstile 스크립트는 CSP nonce와 함께 `next/script`로 불러오고, `frame-src`에 `challenges.cloudflare.com`을 허용하세요.

## Next.js에서 다른 점

서버 액션은 함수 호출처럼 보이지만, Next.js는 이를 페이지 JavaScript에 ID가 들어 있는 POST 엔드포인트로 바꿉니다. 봇에게는 여러분의 폼이 필요 없습니다. 원하는 필드로, 원하는 속도로 그 POST를 재전송할 수 있습니다. 그래서 모든 검사는 컴포넌트에만 두지 말고 반드시 액션 안에 두어야 합니다.

Next.js의 세 가지 세부 사항이 이 페이지의 나머지 내용을 결정합니다.

- **`request.ip`가 없습니다.** 클라이언트 IP는 헤더에서 오며, 어떤 헤더를 신뢰할 수 있는지는 호스트에 따라 다릅니다.
- **nonce는 동적 렌더링을 뜻합니다.** 요청별 nonce는 빌드 시점에 만들어진 페이지에 적용할 수 없습니다.
- **클라이언트 내비게이션은 페이지를 살려 둡니다.** 한 번 불러온 Turnstile 스크립트는 소프트 내비게이션 뒤에 위젯을 다시 렌더링하지 않습니다.

다이어그램: Next.js 서버 액션 안에서 검사가 실행되는 순서

1. 폼이나 폼을 재전송하는 스크립트가 서버 액션으로 POST합니다.
2. 액션이 Vercel Firewall 규칙으로 방문자의 횟수를 확인합니다. 제한을 넘으면 오류 상태를 반환하고 멈춥니다.
3. Turnstile 토큰과 클라이언트 IP를 siteverify로 보내고, 응답의 action 이름과 호스트 이름을 확인합니다.
4. 그다음에야 허니팟을 포함한 필드를 Ikiform으로 전달하며, Ikiform이 메시지에 점수를 매기고 저장합니다.

가장 저렴한 검사가 먼저 실행됩니다. 허니팟은 그대로 전달되므로, 채워진 경우 조용히 버려지지 않고 스팸으로 저장됩니다.

## 프로젝트 설정

이 페이지가 App Router 프로젝트에 추가하는 파일입니다.

`파일`:

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

Cloudflare 대시보드에서 Turnstile 위젯을 만들고 폼이 실행되는 모든 호스트 이름을 등록하세요. 개발하는 동안에는 `localhost`를 포함해 어떤 호스트에서든 통과하는 Cloudflare의 테스트 키를 쓰세요.

`.env.local`:

```bash
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
```

- 브라우저에 전달되는 것은 `NEXT_PUBLIC_TURNSTILE_SITE_KEY`뿐입니다. Next.js는 빌드 시점에 `NEXT_PUBLIC_` 값을 인라인하므로, site key를 바꾸려면 다시 빌드해야 합니다.
- 테스트 토큰에는 여러분의 호스트 이름이 없으므로 로컬에서는 `TURNSTILE_HOSTNAME`을 비워 둡니다. 프로덕션에서는 실제 도메인으로 설정하세요.
- 시크릿을 `2x0000000000000000000000000000000AA`로 바꾸면 모든 검사가 실패하는 것을 볼 수 있습니다.

## 호스트가 설정하는 클라이언트 IP 읽기

방문자가 쓸 수 있는 헤더를 키로 하는 레이트 리밋은 제한이 아닙니다. 호스트마다 요청이 들어올 때 덮어쓰는 헤더가 있으며, 신뢰할 수 있는 것은 그 헤더뿐입니다.

다이어그램: 호스트별로 실제 클라이언트 IP를 담는 헤더

1. clientIp()는 CLIENT_IP_HEADER에 지정된 헤더 하나만 읽고 나머지는 무시합니다.
2. Vercel은 x-forwarded-for와 x-real-ip를 방문자의 주소로 덮어씁니다.
3. Netlify는 주소를 x-nf-client-connection-ip에 넣습니다.
4. OpenNext로 배포한 Next.js를 포함해 Cloudflare는 cf-connecting-ip를 설정합니다.

Vercel은 위조를 막기 위해 X-Forwarded-For를 덮어쓴다고 문서화하고 있습니다. Netlify 문서는 Functions의 context.ip만 언급하며, x-nf-client-connection-ip는 Netlify 직원이 안내하는 헤더입니다.

`lib/client-ip.ts`:

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

## 서버 전용 헬퍼에서 Turnstile 검증하기

`lib/turnstile.ts`:

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

- 토큰은 300초 동안 유효하며 한 번만 검증할 수 있고, 그 뒤 siteverify는 `timeout-or-duplicate`를 반환합니다.
- `success`만으로는 충분하지 않습니다. 다른 사이트에서 여러분의 site key로 발급된 토큰도 `success`라고 응답하므로, 헬퍼는 `action`과 프로덕션에서는 `hostname`도 확인합니다.
- 실패 시 닫힘 방식입니다. Cloudflare에 연결할 수 없으면 방문자는 오류를 보고, 봇은 통과하지 못합니다.

## 서버 액션

`app/contact/actions.ts`:

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

Vercel에서는 제한이 Firewall에서 옵니다. 프로젝트의 **Firewall**을 열고, 조건이 `@vercel/firewall`인 규칙을 추가해 **Rate limit ID**를 `contact-form`으로 설정한 뒤, 분당 5건 같은 기간을 고르세요. `checkRateLimit`은 요청 헤더를 받으므로 `Request` 객체가 없는 서버 액션 안에서도 작동하며, `rateLimitKey`를 넘기지 않으면 클라이언트 IP별로 버킷을 나눕니다.

- Vercel은 규칙마다 리전별로 집계하므로, 여러 리전에 분산된 트래픽은 설정한 수치를 넘을 수 있습니다. 문의 폼이라면 괜찮습니다. 전역적으로 정확한 집계가 필요하다면 [레이트 리밋 페이지](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md)처럼 Redis를 쓰세요.
- 서버 측 fetch는 `Origin`을 보내지 않으므로 방문자의 이 헤더를 그대로 넘기며, Ikiform은 이를 읽어 무료 `localhost` 테스트를 알아봅니다.
- Vercel이 아니라면 Firewall 호출은 건너뜁니다. 그 자리에 `clientIp()`를 키로 하는 Redis나 Workers 리미터를 두세요.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform은 **Allowed domains**와 자체 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5건인 Standard 제한이 모든 방문자를 합쳐서 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

## 폼과 위젯

`app/contact/contact-form.tsx`:

```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`는 스크립트가 페이지에서 위젯을 찾지 않게 합니다. `onReady`는 스크립트가 로드될 때 한 번, 그리고 컴포넌트가 마운트될 때마다 실행되므로 클라이언트 측 내비게이션도 처리됩니다.
- 토큰은 한 번만 쓸 수 있습니다. 액션이 오류를 반환하면 이펙트가 위젯을 초기화해 다음 시도에 새 토큰이 실리게 합니다.
- `action`이 서버 액션 자체이므로 폼은 하이드레이션 전에도 POST됩니다. JavaScript가 없으면 위젯도 없으므로 siteverify가 POST를 거부합니다. 사람 확인에는 브라우저가 필요합니다.
- [허니팟 가이드](https://ikiform.com/ko/guides/spam-protection/honeypot.md)의 `.hp` 클래스가 허니팟을 화면 밖으로 숨깁니다.

`app/contact/page.tsx`:

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

## Turnstile이 실행되도록 하는 CSP

Next.js는 `Content-Security-Policy` 요청 헤더에서 nonce를 읽어 자체 스크립트와 `nonce` prop을 받은 모든 `<Script>`에 추가합니다. Cloudflare에 따르면 Turnstile은 불러오는 리소스에 nonce를 넘기며, iframe에는 `frame-src https://challenges.cloudflare.com`이 필요합니다. Next.js 16에서는 `middleware.ts`를 대체한 `proxy.ts` 파일을 씁니다.

`proxy.ts`:

```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" },
      ],
    },
  ],
};
```

- `'strict-dynamic'`을 쓰면 최신 브라우저는 nonce를 신뢰하고 호스트를 무시합니다. 호스트는 구형 브라우저를 위해 남겨 둡니다.
- 페이지에서 `headers()`를 읽으면 요청마다 렌더링되며, nonce에는 이것이 필요합니다. 정적으로 빌드된 문의 페이지에는 nonce를 넣을 수 없으므로, 대신 `next.config`의 `headers()`에서 nonce 없는 정책을 쓰세요. 대가로 `'unsafe-inline'`이 필요합니다.

## 배포

| 호스트 | CLIENT_IP_HEADER | 레이트 리밋 | 참고 |
| --- | --- | --- | --- |
| Vercel | `x-real-ip` | Firewall 규칙과 `checkRateLimit` | 테스트하는 환경마다 규칙을 추가 |
| Netlify | `x-nf-client-connection-ip` | Upstash 같은 Redis | 서버 액션은 하나의 Netlify 함수에서 실행 |
| OpenNext를 쓴 Cloudflare | `cf-connecting-ip` | Workers 레이트 리밋 바인딩 | 바인딩은 근사치이며 위치별로 집계 |

프로덕션에서는 `TURNSTILE_HOSTNAME`을 실제 도메인으로 설정하세요. 미리보기 배포에는 별도의 호스트 이름이 있으므로, 위젯에 추가하거나 미리보기에서는 검사를 꺼 두세요.

## Ikiform에 검사 맡기기

위의 모든 내용은 서버 액션이 시크릿 키로 전달하기 때문에 필요하며, 시크릿 키를 쓰면 Ikiform 자체의 사람 확인과 방문자별 제한이 꺼집니다. 대신 브라우저에서 POST하면 Ikiform이 허니팟, Turnstile, 레이트 리밋을 대신 실행합니다. Cloudflare 키도, `frame-src`와 `connect-src`에 `https://ikiform.com`을 허용하는 것 외의 `proxy.ts` 변경도, 액션도 필요 없습니다.

`app/contact/contact-form.tsx`:

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

폼의 **Spam check**를 **Ikiform**으로, **Rate limit**을 방문자당 10분에 2건인 Strict로 설정하고, **Allowed domains**에 도메인과 `localhost`를 추가하세요. `challenge()`는 Turnstile에 클릭이 필요할 때만 보이는 작은 ikiform.com 프레임을 마운트합니다. 스팸은 실제 메시지와 같은 성공 응답을 받고, 걸린 사유와 함께 저장되며, 30일 동안 보관된 뒤 삭제됩니다. 폼과 키를 만드는 방법은 [Next.js 설정 가이드](https://ikiform.com/ko/docs/guides/nextjs.md)에서 다룹니다.

## 작동 확인하기

1. 테스트 키로 `next dev`를 실행하고 메시지를 보내세요. **Submissions**에 도착합니다. 플랜이 없는 워크스페이스는 `localhost`에서 보낸 테스트 제출을 매월 50건까지 받습니다.
2. `TURNSTILE_SECRET_KEY`를 항상 실패하는 시크릿으로 설정하고 제출하세요. 폼은 사람 확인 오류를 표시하고 입력한 텍스트를 유지합니다.
3. Firewall 규칙이 있는 Vercel 미리보기에서 1분 안에 여섯 번 제출하세요. 여섯 번째에 제한 메시지가 표시됩니다.
4. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
5. 콘솔을 연 채로 페이지를 여세요. `challenges.cloudflare.com`에 대한 CSP 위반이 없습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 페이지로 이동한 뒤 위젯이 나타나지 않음 | 스크립트가 이전 페이지에서 로드되어 다시 탐색하지 않습니다. 예제처럼 `onLoad`가 아니라 `onReady`에서 렌더링하세요. |
| 프로덕션에서 모든 메시지가 사람 확인에 실패함 | `TURNSTILE_HOSTNAME`이 siteverify가 반환하는 호스트 이름과 맞지 않습니다. 흔히 `www.`와 루트 도메인의 차이입니다. `check.reason`을 로그로 남겨 확인하세요. |
| 모든 방문자가 하나의 레이트 리밋 버킷을 공유함 | `CLIENT_IP_HEADER`가 호스트가 설정하지 않는 헤더를 지정하고 있어 `clientIp()`가 null을 반환합니다. 위 표에서 헤더를 고르세요. |
| `checkRateLimit`이 `error: "not-found"`를 반환함 | 이 환경에 Rate limit ID가 `contact-form`인 Firewall 규칙이 없습니다. 규칙을 추가한 뒤 Firewall 변경 사항을 게시하세요. |

## 질문

### Next.js 서버 액션에 Cloudflare Turnstile을 어떻게 추가하나요?

클라이언트 컴포넌트에서 위젯을 렌더링하고, next/script로 스크립트를 불러오고, 서버 액션에서 cf-turnstile-response 필드를 읽으세요. 시크릿 키와 함께 siteverify로 보내고, 응답이 예상한 action과 호스트 이름과 함께 success일 때만 계속하세요.

### Next.js 서버 액션에서 클라이언트 IP를 어떻게 얻나요?

next/headers의 headers()로 호스트가 설정하는 헤더에서 읽으세요. Vercel은 x-real-ip 또는 x-forwarded-for, Netlify는 x-nf-client-connection-ip, Cloudflare는 cf-connecting-ip입니다. App Router에는 request.ip가 없습니다.

### CSP nonce를 쓰면 문의 페이지가 정적일 수 없나요?

네. nonce는 요청마다 만들어지므로 페이지는 요청마다 렌더링되어야 합니다. 문의 페이지를 동적으로 유지하거나, 호스트로 challenges.cloudflare.com을 허용하는 nonce 없는 정책을 쓰세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [문의 폼 스팸 막기](https://ikiform.com/ko/guides/spam-protection.md): 허니팟, 시간 검사, 콘텐츠 점수, 레이트 리밋, 필요한 곳에만 사람 확인까지, 실제 방문자에게 부담 없는 여러 겹의 검사로 문의 폼 스팸을 막으세요.
- [Cloudflare Turnstile](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md): 일반 HTML 폼에 Cloudflare Turnstile을 추가하세요. 위젯, 서버 측 siteverify, 테스트 키, 그리고 키나 서버 없이 실행하는 방법까지 다룹니다.
- [레이트 리밋](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md): 공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.
- [허니팟 필드](https://ikiform.com/ko/guides/spam-protection/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [캡차 없이](https://ikiform.com/ko/guides/spam-protection/without-captcha.md): 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
- [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md): Turnstile과 hCaptcha부터 작업 증명, 캡차 없는 방식까지 폼용 reCAPTCHA 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
