# Turnstile을 이용한 Nuxt 폼 스팸 방지

> Turnstile을 쓰는 Nitro 서버 라우트, Nitro 스토리지의 IP별 제한, runtimeConfig 시크릿, routeRules로 설정한 CSP로 Nuxt 폼을 스팸으로부터 보호하세요.

2026-10-25 게시 · https://ikiform.com/ko/guides/spam-protection/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` 파일은 프로덕션에서 읽히지 않으므로 시크릿은 플랫폼 환경 변수로 설정해야 합니다.

다이어그램: Nuxt 서버 라우트와 Nitro 스토리지를 거치는 문의 POST

1. 컴포넌트가 $fetch로 필드와 Turnstile 토큰을 /api/contact에 POST합니다.
2. 서버 라우트가 runtimeConfig에 지정된 헤더에서 클라이언트 IP를 읽습니다.
3. Nitro 스토리지에서 현재 1분 동안 해당 IP의 POST 수를 셉니다. 5건을 넘으면 남은 초와 함께 429를 던집니다.
4. 제한 이내라면 siteverify로 토큰을 검증하며 action과 호스트를 확인합니다.
5. 그런 다음 필드를 Ikiform으로 전달하고, Ikiform이 저장하고 허니팟 점수를 매깁니다.

개발 환경에서는 스토리지 마운트가 메모리에 있습니다. 프로덕션에서는 nuxt.config가 다른 드라이버를 마운트하므로 같은 코드가 Redis와 통신합니다.

## 프로젝트 설정

`파일`:

```text
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
```

`.env`:

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

`nuxt.config.ts`:

```ts
const 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됩니다.

`server/utils/spam.ts`:

```ts
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`를 쓰며, 테스트 토큰에는 더미 호스트 이름이 있으므로 개발 환경에서는 건너뜁니다.

## 서버 라우트

`server/api/contact.post.ts`:

```ts
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은 채워진 허니팟을 버리지 않고 스팸으로 저장합니다.

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

## 컴포저블과 폼

`app/composables/useTurnstile.ts`:

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

`app/components/ContactForm.vue`:

```vue
<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하는 버전은 다음 섹션을 참고하세요.
- [허니팟 가이드](https://ikiform.com/ko/guides/spam-protection/honeypot.md)처럼 `.hp` 클래스가 허니팟을 화면 밖으로 숨깁니다.

## 로컬에서 테스트하기

개발 환경에서는 `ratelimit` 마운트가 메모리에 있으므로 `nuxt dev`를 재시작하면 집계가 초기화됩니다. 브라우저 없이도 제한이 작동하는 것을 볼 수 있습니다. 토큰 없는 POST는 siteverify에서 `403`으로 실패하지만, 각각 여전히 집계됩니다.

`터미널`:

```bash
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 설정 가이드](https://ikiform.com/ko/docs/guides/nuxt.md)에서 다룹니다.

## 작동 확인하기

1. 테스트 키로 `nuxt dev`를 실행하고 메시지를 보내세요. **Submissions**에 나타납니다. 플랜이 없는 워크스페이스는 매월 `localhost` 테스트 50건을 받습니다.
2. 1분 안에 메시지 여섯 개를 보내세요. 여섯 번째에 남은 초가 표시됩니다.
3. `NUXT_TURNSTILE_SECRET_KEY`를 항상 실패하는 시크릿으로 설정하고 재시작하세요. 모든 POST에 사람 확인 오류가 표시됩니다.
4. `nuxt build` 후 `curl -I`로 `/contact`를 요청하세요. 응답에 `Content-Security-Policy`가 들어 있습니다.
5. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 프로덕션에서 시크릿이 비어 있음 | 프로덕션에서는 런타임에 `.env`를 읽지 않습니다. `NUXT_TURNSTILE_SECRET_KEY`와 나머지 변수를 호스트 환경에 설정하세요. |
| 배포하거나 인스턴스가 바뀔 때마다 제한이 초기화됨 | 스토리지 마운트가 아직 메모리에 있습니다. `$production.nitro.storage`가 공유 드라이버를 마운트하고 자격 증명이 설정되어 있는지 확인하세요. |
| 스크립트가 헤더를 바꿔 제한을 우회함 | 해당 헤더를 덮어쓰지 않는 호스트에서 `xForwardedFor: true`로 `getRequestIP`를 읽고 있습니다. `clientIp`로 호스트 자체 헤더를 읽으세요. |
| 서버 렌더링 중 `window is not defined` | Turnstile 코드가 `onMounted` 밖에서 실행되었습니다. 모든 `window` 접근을 컴포저블의 라이프사이클 훅 안에 두세요. |

## 질문

### Nuxt 폼에 Turnstile을 어떻게 추가하나요?

onMounted에서 위젯을 명시적으로 렌더링하고, 토큰을 ref에 보관한 뒤, 필드와 함께 서버 라우트로 POST하세요. 라우트는 runtimeConfig의 시크릿 키와 함께 토큰을 siteverify로 보내고, success일 때만 계속합니다.

### Nuxt 서버 라우트에 레이트 리밋을 어떻게 거나요?

Nitro의 useStorage로 클라이언트 IP별 요청을 세고, 프로덕션에서는 Redis나 Upstash에 마운트해 모든 인스턴스가 집계를 공유하게 하세요. 집계가 제한을 넘으면 createError로 429를 던지세요.

### Nuxt에서 getRequestIP를 레이트 리밋에 써도 안전한가요?

클라이언트가 보낸 X-Forwarded-For를 그대로 통과시키는 호스트에서 xForwardedFor를 true로 쓰면 안전하지 않습니다. 첫 번째 항목은 클라이언트가 보낸 아무 값이기 때문입니다. 대신 호스트가 덮어쓰는 헤더를 읽으세요.

### 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 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
