문의 폼 스팸 막기6분 분량
Turnstile을 이용한 Next.js 폼 스팸 방지
허니팟, 서버 액션에서 확인하는 Turnstile, Vercel·Netlify·Cloudflare에서의 IP별 제한, 그리고 이를 위한 CSP로 Next.js 폼을 스팸으로부터 보호하세요.
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 스크립트는 소프트 내비게이션 뒤에 위젯을 다시 렌더링하지 않습니다.
- 폼이나 폼을 재전송하는 스크립트가 서버 액션으로 POST합니다.
- 액션이 Vercel Firewall 규칙으로 방문자의 횟수를 확인합니다. 제한을 넘으면 오류 상태를 반환하고 멈춥니다.
- Turnstile 토큰과 클라이언트 IP를 siteverify로 보내고, 응답의 action 이름과 호스트 이름을 확인합니다.
- 그다음에야 허니팟을 포함한 필드를 Ikiform으로 전달하며, Ikiform이 메시지에 점수를 매기고 저장합니다.
프로젝트 설정
이 페이지가 App Router 프로젝트에 추가하는 파일입니다.
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localCloudflare 대시보드에서 Turnstile 위젯을 만들고 폼이 실행되는 모든 호스트 이름을 등록하세요. 개발하는 동안에는 localhost를 포함해 어떤 호스트에서든 통과하는 Cloudflare의 테스트 키를 쓰세요.
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 읽기
방문자가 쓸 수 있는 헤더를 키로 하는 레이트 리밋은 제한이 아닙니다. 호스트마다 요청이 들어올 때 덮어쓰는 헤더가 있으며, 신뢰할 수 있는 것은 그 헤더뿐입니다.
- clientIp()는 CLIENT_IP_HEADER에 지정된 헤더 하나만 읽고 나머지는 무시합니다.
- Vercel은 x-forwarded-for와 x-real-ip를 방문자의 주소로 덮어씁니다.
- Netlify는 주소를 x-nf-client-connection-ip에 넣습니다.
- OpenNext로 배포한 Next.js를 포함해 Cloudflare는 cf-connecting-ip를 설정합니다.
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 검증하기
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에 연결할 수 없으면 방문자는 오류를 보고, 봇은 통과하지 못합니다.
서버 액션
"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은 규칙마다 리전별로 집계하므로, 여러 리전에 분산된 트래픽은 설정한 수치를 넘을 수 있습니다. 문의 폼이라면 괜찮습니다. 전역적으로 정확한 집계가 필요하다면 레이트 리밋 페이지처럼 Redis를 쓰세요.
- 서버 측 fetch는
Origin을 보내지 않으므로 방문자의 이 헤더를 그대로 넘기며, Ikiform은 이를 읽어 무료localhost테스트를 알아봅니다. - Vercel이 아니라면 Firewall 호출은 건너뜁니다. 그 자리에
clientIp()를 키로 하는 Redis나 Workers 리미터를 두세요.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.폼과 위젯
"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를 거부합니다. 사람 확인에는 브라우저가 필요합니다.- 허니팟 가이드의
.hp클래스가 허니팟을 화면 밖으로 숨깁니다.
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 파일을 씁니다.
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 변경도, 액션도 필요 없습니다.
"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 설정 가이드에서 다룹니다.
작동 확인하기
- 테스트 키로
next dev를 실행하고 메시지를 보내세요. Submissions에 도착합니다. 플랜이 없는 워크스페이스는localhost에서 보낸 테스트 제출을 매월 50건까지 받습니다. TURNSTILE_SECRET_KEY를 항상 실패하는 시크릿으로 설정하고 제출하세요. 폼은 사람 확인 오류를 표시하고 입력한 텍스트를 유지합니다.- Firewall 규칙이 있는 Vercel 미리보기에서 1분 안에 여섯 번 제출하세요. 여섯 번째에 제한 메시지가 표시됩니다.
- 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - 콘솔을 연 채로 페이지를 여세요.
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/script로 스크립트를 불러오고, 서버 액션에서 cf-turnstile-response 필드를 읽으세요. 시크릿 키와 함께 siteverify로 보내고, 응답이 예상한 action과 호스트 이름과 함께 success일 때만 계속하세요.
next/headers의 headers()로 호스트가 설정하는 헤더에서 읽으세요. Vercel은 x-real-ip 또는 x-forwarded-for, Netlify는 x-nf-client-connection-ip, Cloudflare는 cf-connecting-ip입니다. App Router에는 request.ip가 없습니다.
네. nonce는 요청마다 만들어지므로 페이지는 요청마다 렌더링되어야 합니다. 문의 페이지를 동적으로 유지하거나, 호스트로 challenges.cloudflare.com을 허용하는 nonce 없는 정책을 쓰세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.