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

> getClientAddress를 쓰는 폼 액션, IP별 제한, use:enhance 뒤의 Turnstile, 이를 허용하는 CSP로 SvelteKit 폼을 스팸으로부터 보호하세요.

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

폼 액션에서 검사하세요. `getClientAddress()`로 제한하고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. 위젯은 Svelte 액션으로 렌더링하고, 토큰은 한 번만 쓸 수 있으므로 `use:enhance` 콜백에서 초기화하세요. `sveltekit()` 플러그인의 `csp` 옵션에서 `challenges.cloudflare.com`을 허용하고, `adapter-node`에서는 실제 IP가 담긴 프록시 헤더를 SvelteKit에 알려 주세요.

## SvelteKit에서 다른 점

SvelteKit은 요청 이벤트의 `getClientAddress()`로 방문자의 IP를 바로 줍니다. 이 값의 출처는 어댑터에 따라 다르며, `adapter-node`의 기본값은 프록시 뒤에서는 틀립니다. 이것만 바로잡으면 나머지 검사는 폼 액션에 깔끔하게 들어갑니다.

다이어그램: 어댑터별로 getClientAddress()가 방문자의 IP를 얻는 곳

1. 폼 액션이 요청 이벤트에 클라이언트 주소를 묻습니다.
2. SvelteKit은 배포에 쓰는 어댑터에 답을 맡깁니다.
3. adapter-node는 ADDRESS_HEADER와 XFF_DEPTH를 설정하지 않으면 소켓의 원격 주소를 씁니다.
4. Vercel, Netlify, Cloudflare 어댑터는 플랫폼이 제공하는 주소를 읽으므로 설정할 것이 없습니다.

nginx나 로드 밸런서 뒤에서는 ADDRESS_HEADER를 설정하기 전까지 adapter-node가 모든 방문자에 대해 프록시의 주소를 보며, 하나의 공유 주소는 IP별 제한을 사이트 전체 제한으로 바꿔 버립니다.

SvelteKit의 또 다른 세부 사항은 `use:enhance`입니다. 페이지를 새로 고치지 않고 제출하므로 사람에게는 더 좋지만, Turnstile이 스스로 다시 렌더링되지 않는다는 뜻이기도 합니다. 응답마다 위젯을 직접 초기화해야 합니다.

## 프로젝트 설정

`파일`:

```text
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
```

`.env`:

```bash
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
```

- SvelteKit 3은 `src/env.ts`에 선언한 변수를 읽습니다. `public: true`가 있는 것은 site key뿐이므로, `$app/env/public`이 브라우저에 넘길 수 있는 것도 site key뿐입니다. `src/lib/server`처럼 `server` 디렉터리에 있는 것은 클라이언트 코드에서 아예 import할 수 없으며, 시도하면 SvelteKit이 빌드를 실패시킵니다.
- 선언한 변수는 `static: true`를 설정하지 않으면 앱이 시작될 때 읽히므로, 키를 교체하면 다시 빌드할 필요 없이 재시작만 하면 됩니다. `$env/*` 모듈은 여전히 작동하지만 SvelteKit 3에서는 지원 중단되었습니다.
- Cloudflare의 테스트 키는 `localhost`를 포함해 어떤 호스트에서든 통과합니다.

`vite.config.ts`:

```ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});
```

SvelteKit 3은 옵션을 `vite.config.ts`에서 받아 `sveltekit()` 플러그인에 넘기며, `svelte.config.js`는 더 이상 읽지 않습니다. `mode: "auto"`를 쓰면 SvelteKit은 서버 렌더링 페이지의 자체 인라인 스크립트에는 nonce를, 프리렌더링 페이지에는 해시를 추가하므로 정책에 `'unsafe-inline'`이 필요 없습니다. Turnstile은 `script-src`가 허용하는 호스트에서 불러오며, `frame-src`가 허용하는 iframe을 그립니다.

## 서버 전용 헬퍼

`src/lib/server/spam.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

const limiter = new Ratelimit({
  redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
```

- 제한은 모든 서버 인스턴스가 공유하는 Redis의 슬라이딩 구간으로, IP당 분당 메시지 5건입니다. `reset`은 밀리초로 돌아오며, 기다려야 할 정수 초로 바뀝니다.
- 테스트 토큰에는 더미 호스트 이름이 있으므로, `$app/env`의 `dev`로 로컬에서는 호스트 이름 검사를 건너뜁니다.
- siteverify는 300초보다 오래되었거나 이미 사용된 토큰에 `timeout-or-duplicate`로 응답하며, 그래서 페이지가 위젯을 초기화합니다.

## 폼 액션

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
```

- 제한은 본문을 파싱하기 전에 실행됩니다. `fail(429)`는 메시지와 함께 방문자를 페이지에 머물게 합니다.
- 그 밖의 모든 실패는 `values`를 반환하므로 오류 후에 입력이 다시 채워집니다.
- 원래의 `FormData`가 토큰만 빠지고 허니팟을 포함해 전달됩니다. Ikiform은 채워진 허니팟에 점수를 매기고 메시지를 스팸으로 저장합니다.
- 서버 측 fetch는 `Origin`을 보내지 않으므로 방문자의 이 헤더를 넘기며, Ikiform은 이를 읽어 무료 `localhost` 테스트를 알아봅니다.

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

## Svelte 액션으로 만든 위젯

`src/lib/turnstile.ts`:

```ts
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

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

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = SCRIPT;
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).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 turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}
```

Svelte 액션은 서드파티 위젯에 딱 맞는 자리입니다. 요소가 마운트될 때 실행되고, 요소가 사라질 때 `destroy`가 실행됩니다. 위젯 ID는 요소에 저장되므로 페이지가 정확히 그 위젯을 초기화할 수 있습니다.

## 페이지

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

- `update({ reset: false })`는 결과를 적용하면서 방문자가 입력한 내용을 유지합니다. 기본값은 브라우저처럼 폼을 지웁니다.
- `resetTurnstile`은 성공이든 아니든 응답마다 실행됩니다. 사용한 토큰은 사라지고 새 토큰이 준비됩니다.
- JavaScript가 없어도 폼은 POST되고 액션도 실행되지만, 위젯이 렌더링되지 않으므로 siteverify가 명확한 메시지와 함께 거부합니다.
- 폼 액션이 있는 페이지는 프리렌더링할 수 없으므로 이 라우트는 항상 서버에서 렌더링됩니다. 다른 페이지는 프리렌더링된 채로 둘 수 있습니다.
- [허니팟 가이드](https://ikiform.com/ko/guides/spam-protection/honeypot.md)처럼 `.hp` 클래스가 허니팟을 화면 밖으로 숨깁니다.

## 로컬에서 테스트하기

`vite dev`에서는 앱 앞에 어댑터가 없으므로 `getClientAddress()`가 루프백 주소를 반환하고 모든 로컬 탭이 하나의 버킷을 공유합니다. 메시지를 확인하는 데는 괜찮지만 프록시 설정을 확인하는 데는 맞지 않습니다. 그것을 테스트하려면 `adapter-node`로 빌드하고 프로덕션에서 쓸 것과 같은 변수로 결과물을 실행하세요.

`터미널`:

```bash
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build
```

`X-Forwarded-For` 값을 바꿔 가며 몇 번 POST하세요. `XFF_DEPTH=1`이면 SvelteKit은 가장 오른쪽 주소, 즉 프록시가 추가할 주소를 읽으므로, 클라이언트가 왼쪽에 지어낸 값은 아무것도 바꾸지 못합니다.

## 배포

프록시 하나 뒤에서 `adapter-node`로 직접 운영하는 서버라면 실제 IP가 어디 있는지 SvelteKit에 알려 주세요.

`환경`:

```bash
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1
```

SvelteKit 문서는 헤더가 위조하기 쉬우며 가장 왼쪽 `X-Forwarded-For` 주소를 읽는 것이 전형적인 실수라고 경고합니다. `XFF_DEPTH`는 신뢰하는 프록시를 오른쪽부터 세므로, 운영하는 프록시 수로 설정하고 프록시를 우회해 앱에 접근할 수 없게 하세요. SvelteKit 3에서는 adapter-node의 `ORIGIN` 변수가 없어졌습니다. 프록시가 원래 호스트를 넘기지 않는다면 `sveltekit()` 옵션에 `paths.origin`을 설정해 SvelteKit 자체 폼 검사가 공개 URL을 알게 하세요.

| 어댑터 | 클라이언트 IP | 시크릿 |
| --- | --- | --- |
| `adapter-node` | `ADDRESS_HEADER`와 `XFF_DEPTH` | 프로세스 환경 변수 |
| `adapter-vercel` | 자동 | 프로젝트 환경 변수 |
| `adapter-netlify` | 자동 | 사이트 환경 변수 |
| `adapter-cloudflare` | 자동 | `wrangler secret put` |

## Ikiform에 검사 맡기기

이 중 아무것도 직접 운영하지 않으려면 `+page.server.ts`를 지우고 폼의 `action`을 `use:enhance` 없이 Ikiform 엔드포인트로 지정하세요. 그러면 페이지를 정적 사이트의 나머지처럼 프리렌더링할 수 있습니다.

폼의 **Spam check**를 **Ikiform**으로 설정하면 토큰 없는 POST에 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보냅니다. **Rate limit**은 방문자당 10분에 2건인 Strict를 고르고, **Allowed domains**에 도메인을 등록하세요. Ikiform은 걸러 낸 스팸을 30일 동안 보관하므로 오탐은 클릭 한 번으로 복원할 수 있습니다. 엔드포인트와 키는 [SvelteKit 설정 가이드](https://ikiform.com/ko/docs/guides/sveltekit.md)에서 다룹니다.

## 작동 확인하기

1. 테스트 키로 `vite dev`를 실행하고 메시지를 보내세요. **Submissions**에 나타납니다. 플랜이 없는 워크스페이스는 매월 `localhost` 테스트 50건을 받습니다.
2. 새로 고치지 않고 두 번째 메시지를 보내세요. 성공한다면 위젯 초기화가 작동한 것입니다.
3. 1분 안에 메시지 여섯 개를 보내세요. 여섯 번째에 몇 초 기다려야 하는지 표시됩니다.
4. 프록시 뒤에서 두 네트워크로부터 `getClientAddress()`를 로그로 남기세요. 프록시의 주소가 아니라 서로 다른 두 주소가 보입니다.
5. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| `adapter-node`에서 모든 방문자가 함께 레이트 리밋에 걸림 | `getClientAddress()`가 프록시의 주소를 반환합니다. `ADDRESS_HEADER`와 `XFF_DEPTH`를 설정하세요. |
| 두 번째 제출이 항상 사람 확인에 실패함 | 위젯이 이미 쓴 토큰을 유지하고 있습니다. `use:enhance` 콜백에서 `update` 다음에 `resetTurnstile`을 호출하세요. |
| 액션이 있는 페이지가 프리렌더링되어 빌드가 실패함 | 문의 라우트가 레이아웃에서 `prerender = true`를 물려받았습니다. 액션이 있는 페이지는 프리렌더링할 수 없으므로, 해당 `+page.server.ts`에 `export const prerender = false`를 설정하세요. |
| 위젯 영역이 비어 있고 콘솔에 CSP 위반이 표시됨 | `vite.config.ts`의 `csp.directives`에서 `script-src`나 `frame-src`에 `https://challenges.cloudflare.com`이 빠져 있습니다. |

## 질문

### SvelteKit에서 클라이언트 IP를 어떻게 얻나요?

폼 액션, load 함수, 훅에서 요청 이벤트의 getClientAddress()를 호출하세요. 어댑터가 값을 제공합니다. 프록시 뒤의 adapter-node에서는 ADDRESS_HEADER와 XFF_DEPTH를 설정하지 않으면 프록시의 주소를 받습니다.

### use:enhance를 쓰면 왜 두 번째 제출에서 Turnstile이 실패하나요?

use:enhance는 페이지를 새로 로드하지 않으므로 위젯이 이미 쓴 토큰을 유지합니다. 응답마다 enhance 콜백에서 위젯을 초기화하세요.

### SvelteKit의 CSP가 Turnstile과 함께 작동하나요?

네. csp.directives의 script-src와 frame-src에 https://challenges.cloudflare.com을 추가하세요. SvelteKit 3에서는 vite.config.ts의 sveltekit() 플러그인 옵션에서 받습니다. mode를 auto로 하면 SvelteKit이 자체 스크립트에 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/without-captcha.md): 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
- [허니팟 필드](https://ikiform.com/ko/guides/spam-protection/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md): Turnstile과 hCaptcha부터 작업 증명, 캡차 없는 방식까지 폼용 reCAPTCHA 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
