문의 폼 스팸 막기6분 분량
Turnstile을 이용한 Nuxt 폼 스팸 방지
Turnstile을 쓰는 Nitro 서버 라우트, Nitro 스토리지의 IP별 제한, runtimeConfig 시크릿, routeRules로 설정한 CSP로 Nuxt 폼을 스팸으로부터 보호하세요.
$fetch로 폼을 서버 라우트에 POST하세요. 서버 라우트는 Nitro 스토리지에서 클라이언트 IP별 시도를 세고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. 시크릿은 런타임에 NUXT_ 환경 변수로 설정되는 비공개 runtimeConfig에 두세요. 문의 페이지의 CSP는 routeRules에서 보내고, 스크립트와 프레임에 challenges.cloudflare.com을 허용하세요.
Nuxt에서 다른 점
Nuxt의 서버는 Nitro이며, Nitro는 스팸 검사에 필요한 두 가지를 제공합니다. server/api의 서버 라우트는 $fetch가 이미 연결된 평범한 엔드포인트이고, useStorage()는 환경마다 백엔드를 고를 수 있는 키-값 저장소를 줍니다. 개발 환경에서는 메모리, 프로덕션에서는 Redis나 다른 드라이버입니다. 그래서 레이트 리밋은 의존성이 아니라 설정 변경이 됩니다.
여기서 문제를 일으키는 Nuxt 습관이 두 가지 있습니다. getRequestIP(event, { xForwardedFor: true })는 첫 번째 X-Forwarded-For 항목을 반환하는데, h3 문서조차 이를 클라이언트 입력이라고 부릅니다. 그리고 .env 파일은 프로덕션에서 읽히지 않으므로 시크릿은 플랫폼 환경 변수로 설정해야 합니다.
- 컴포넌트가 $fetch로 필드와 Turnstile 토큰을 /api/contact에 POST합니다.
- 서버 라우트가 runtimeConfig에 지정된 헤더에서 클라이언트 IP를 읽습니다.
- Nitro 스토리지에서 현재 1분 동안 해당 IP의 POST 수를 셉니다. 5건을 넘으면 남은 초와 함께 429를 던집니다.
- 제한 이내라면 siteverify로 토큰을 검증하며 action과 호스트를 확인합니다.
- 그런 다음 필드를 Ikiform으로 전달하고, Ikiform이 저장하고 허니팟 점수를 매깁니다.
프로젝트 설정
app/
components/
ContactForm.vue
composables/
useTurnstile.ts
server/
api/
contact.post.ts
utils/
spam.ts
nuxt.config.ts
.envNUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-forconst policy = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
"frame-src https://challenges.cloudflare.com",
"object-src 'none'",
"base-uri 'self'",
].join("; ");
export default defineNuxtConfig({
runtimeConfig: {
turnstileSecretKey: "",
ikiformSecretKey: "",
clientIpHeader: "x-forwarded-for",
public: {
turnstileSiteKey: "",
},
},
routeRules: {
"/contact": { headers: { "Content-Security-Policy": policy } },
},
$production: {
nitro: {
storage: {
ratelimit: { driver: "upstash" },
},
},
},
});- 모든
runtimeConfig키는 런타임에NUXT_변수로 덮어쓸 수 있습니다.turnstileSecretKey는NUXT_TURNSTILE_SECRET_KEY로,public.turnstileSiteKey는NUXT_PUBLIC_TURNSTILE_SITE_KEY로 덮어씁니다.public밖의 키는 브라우저에 절대 전달되지 않습니다. routeRules는/contact에서만 CSP를 보냅니다. Nuxt는 페이로드와 하이드레이션 스크립트를 인라인하므로 이 정책은'unsafe-inline'을 유지합니다. 더 엄격한 정책이 필요하다면 커뮤니티 모듈nuxt-security가 nonce를 생성해 줍니다.upstash드라이버는 프로덕션에서만 마운트되며 환경에서UPSTASH_REDIS_REST_URL과UPSTASH_REDIS_REST_TOKEN을 읽습니다.@upstash/redis패키지가 필요하므로npm install @upstash/redis로 설치하세요.
서버 유틸리티
server/utils의 파일은 모든 서버 라우트에 자동으로 import됩니다.
import type { H3Event } from "h3";
const WINDOW_SECONDS = 60;
const LIMIT = 5;
export function clientIp(event: H3Event) {
const header = useRuntimeConfig(event).clientIpHeader;
return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}
export async function secondsToWait(ip: string) {
const storage = useStorage("ratelimit");
const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
const key = `contact:${ip}:${window}`;
const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
await storage.setItem(key, count);
if (count <= LIMIT) return 0;
return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}
export async function isHuman(event: H3Event, token: string, ip: string) {
if (!token || token.length > 2048) return false;
try {
const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
});
return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
} catch {
return false;
}
}clientIp는 호스트가 덮어쓰는 헤더 딱 하나만 읽습니다.NUXT_CLIENT_IP_HEADER를 Vercel은x-forwarded-for, Netlify는x-nf-client-connection-ip, Cloudflare는cf-connecting-ip로 설정하세요.- 카운터는 Nitro 스토리지의 고정 구간입니다. 읽은 뒤 쓰는 방식은 원자적이지 않으므로, 같은 순간에 온 두 POST가 모두 제한 아래로 빠져나갈 수 있습니다. 문의 폼에는 허용할 만합니다. 키는 저절로 만료되지 않으므로 Redis 데이터베이스에 축출 정책을 지정하세요.
$fetch는 siteverify 본문을 JSON으로 보내며, siteverify는 이를 받습니다. 호스트 검사는getRequestHost를 쓰며, 테스트 토큰에는 더미 호스트 이름이 있으므로 개발 환경에서는 건너뜁니다.
서버 라우트
interface ContactBody {
name?: string;
email?: string;
company?: string;
message?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event);
const wait = await secondsToWait(ip);
if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });
const body = await readBody<ContactBody>(event);
const fields = {
name: body.name?.trim().slice(0, 120) ?? "",
email: body.email?.trim() ?? "",
company: body.company?.trim().slice(0, 120) ?? "",
message: body.message?.trim().slice(0, 5000) ?? "",
_gotcha: body._gotcha ?? "",
};
if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
}
const origin = getRequestHeader(event, "origin");
await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: fields,
}).catch((failure) => {
throw createError({
status: failure.status ?? 502,
message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
});
});
return { sent: true };
});.post.ts접미사는 Nitro가POST에만 응답하게 합니다. 그 밖의 요청은405를 받습니다.- 제한은
readBody보다 먼저 실행되므로, 폭주가 본문 파싱이나 siteverify 호출 비용을 쓰지 않습니다. - 실패할 때마다
createError를 던집니다. JSON 본문에는 컴포넌트가 표시하는message가 담깁니다. - 서버 측 fetch는
Origin을 보내지 않으므로 방문자의 이 헤더를 넘기며, Ikiform은 이를 읽어 무료localhost테스트를 알아봅니다. - 허니팟
_gotcha는 그대로 함께 전달되므로, Ikiform은 채워진 허니팟을 버리지 않고 스팸으로 저장합니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.컴포저블과 폼
interface TurnstileApi {
render(
container: HTMLElement,
options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
): string;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
let loading: Promise<TurnstileApi> | undefined;
function load() {
loading ??= new Promise<TurnstileApi>((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = () => {
const api = (window as Window & { turnstile?: TurnstileApi }).turnstile;
if (api) resolve(api);
else reject(new Error("Turnstile did not load"));
};
script.onerror = () => reject(new Error("Turnstile did not load"));
document.head.append(script);
});
return loading;
}
export function useTurnstile(action: string) {
const { turnstileSiteKey } = useRuntimeConfig().public;
const box = ref<HTMLElement>();
const token = ref("");
let api: TurnstileApi | undefined;
let widgetId: string | undefined;
onMounted(async () => {
api = await load();
if (!box.value) return;
widgetId = api.render(box.value, {
sitekey: turnstileSiteKey,
action,
callback: (value) => (token.value = value),
"expired-callback": () => (token.value = ""),
});
});
onBeforeUnmount(() => {
if (widgetId) api?.remove(widgetId);
});
function reset() {
token.value = "";
if (widgetId) api?.reset(widgetId);
}
return { box, token, reset };
}<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
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";
reset();
}
}
</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" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="fields.email" type="email" autocomplete="email" required />
<label for="company">Company <span>(optional)</span></label>
<input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
<label for="message">Message</label>
<textarea id="message" v-model="fields.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" 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>- 컴포저블은
onMounted에서만 실행되므로 서버 렌더링은window를 건드리지 않습니다. - 버튼은 토큰을 기다리며, 토큰은 한 번만 쓸 수 있고 300초 동안 유효하므로
reset()이 실패할 때마다 새 토큰을 요청합니다. - 이 폼에는 JavaScript가 필요합니다. JavaScript 없이 POST하는 버전은 다음 섹션을 참고하세요.
- 허니팟 가이드처럼
.hp클래스가 허니팟을 화면 밖으로 숨깁니다.
로컬에서 테스트하기
개발 환경에서는 ratelimit 마운트가 메모리에 있으므로 nuxt dev를 재시작하면 집계가 초기화됩니다. 브라우저 없이도 제한이 작동하는 것을 볼 수 있습니다. 토큰 없는 POST는 siteverify에서 403으로 실패하지만, 각각 여전히 집계됩니다.
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done처음 다섯 줄은 403을, 여섯 번째 줄은 429를 출력합니다. 로컬에서는 IP 헤더를 설정하는 프록시가 없으므로 모든 요청이 unknown으로 집계됩니다. 예상된 동작이며, 헤더를 코드가 아니라 설정에 두는 이유이기도 합니다.
배포
| 호스트 | NUXT_CLIENT_IP_HEADER | 스토리지 드라이버 |
|---|---|---|
| Vercel | x-forwarded-for | Vercel Marketplace의 upstash |
| Netlify | x-nf-client-connection-ip | upstash 또는 redis |
| Cloudflare | cf-connecting-ip | upstash. Workers KV는 최종 일관성이라 집계가 늦어짐 |
| Node 서버 | 프록시가 설정하는 헤더 | redis, 단일 인스턴스라면 메모리 |
모든 NUXT_ 변수를 호스트 대시보드에서 설정하세요. Nuxt는 서버가 시작될 때 이를 읽으므로, 키를 바꾸면 다시 빌드할 필요 없이 재시작만 하면 됩니다. 모든 프로덕션 및 미리보기 호스트 이름을 Turnstile 위젯에 추가하세요.
Ikiform에 검사 맡기기
서버 라우트가 없으면 문의 페이지를 nuxt generate 사이트의 나머지와 함께 프리렌더링할 수 있습니다. 일반 폼을 method="post"와 엔드포인트 URL로 설정한 action으로 Ikiform 엔드포인트에 연결하고, server/api/contact.post.ts와 컴포저블을 지우세요.
폼의 Spam check를 Ikiform으로 설정하세요. 토큰 없는 POST에는 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보내므로, CSP에 Cloudflare 항목이 필요 없습니다. Rate limit은 방문자당 10분에 2건인 Strict를 고르고, Allowed domains에 도메인을 등록하세요. Ikiform은 걸러 낸 스팸을 걸린 사유와 함께 30일 동안 보관합니다. 엔드포인트와 키는 Nuxt 설정 가이드에서 다룹니다.
작동 확인하기
- 테스트 키로
nuxt dev를 실행하고 메시지를 보내세요. Submissions에 나타납니다. 플랜이 없는 워크스페이스는 매월localhost테스트 50건을 받습니다. - 1분 안에 메시지 여섯 개를 보내세요. 여섯 번째에 남은 초가 표시됩니다.
NUXT_TURNSTILE_SECRET_KEY를 항상 실패하는 시크릿으로 설정하고 재시작하세요. 모든 POST에 사람 확인 오류가 표시됩니다.nuxt build후curl -I로/contact를 요청하세요. 응답에Content-Security-Policy가 들어 있습니다.- 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
| 프로덕션에서 시크릿이 비어 있음 | 프로덕션에서는 런타임에 .env를 읽지 않습니다. NUXT_TURNSTILE_SECRET_KEY와 나머지 변수를 호스트 환경에 설정하세요. |
| 배포하거나 인스턴스가 바뀔 때마다 제한이 초기화됨 | 스토리지 마운트가 아직 메모리에 있습니다. $production.nitro.storage가 공유 드라이버를 마운트하고 자격 증명이 설정되어 있는지 확인하세요. |
| 스크립트가 헤더를 바꿔 제한을 우회함 | 해당 헤더를 덮어쓰지 않는 호스트에서 xForwardedFor: true로 getRequestIP를 읽고 있습니다. clientIp로 호스트 자체 헤더를 읽으세요. |
서버 렌더링 중 window is not defined | Turnstile 코드가 onMounted 밖에서 실행되었습니다. 모든 window 접근을 컴포저블의 라이프사이클 훅 안에 두세요. |
질문
onMounted에서 위젯을 명시적으로 렌더링하고, 토큰을 ref에 보관한 뒤, 필드와 함께 서버 라우트로 POST하세요. 라우트는 runtimeConfig의 시크릿 키와 함께 토큰을 siteverify로 보내고, success일 때만 계속합니다.
Nitro의 useStorage로 클라이언트 IP별 요청을 세고, 프로덕션에서는 Redis나 Upstash에 마운트해 모든 인스턴스가 집계를 공유하게 하세요. 집계가 제한을 넘으면 createError로 429를 던지세요.
클라이언트가 보낸 X-Forwarded-For를 그대로 통과시키는 호스트에서 xForwardedFor를 true로 쓰면 안전하지 않습니다. 첫 번째 항목은 클라이언트가 보낸 아무 값이기 때문입니다. 대신 호스트가 덮어쓰는 헤더를 읽으세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.