# HTML 폼에 Cloudflare Turnstile을 추가하는 방법

> 일반 HTML 폼에 Cloudflare Turnstile을 추가하세요. 위젯, 서버 측 siteverify, 테스트 키, 그리고 키나 서버 없이 실행하는 방법까지 다룹니다.

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

Turnstile 스크립트와 `cf-turnstile` 요소를 폼 안에 추가하세요. 위젯이 숨겨진 `cf-turnstile-response` 필드에 토큰을 넣습니다. 그런 다음 서버에서 그 토큰과 시크릿 키를 Cloudflare의 siteverify 엔드포인트로 보내고, 응답이 `success`일 때만 POST를 받으세요. 서버가 없다면 폼 백엔드가 검증하게 하거나, 키가 전혀 필요 없는 Ikiform의 관리형 검사를 쓰세요.

## Turnstile에서 다른 점

Turnstile은 두 부분으로 나뉘는데, 일반 HTML 폼에는 그중 하나만 들어갈 자리가 있습니다. 위젯은 공개 **site key**로 브라우저에서 실행되어 토큰을 만듭니다. 검사는 **secret key**로 서버에서 실행되며, 이 키는 절대 페이지에 노출되면 안 됩니다. 위젯은 보여 주지만 토큰을 검증하지 않는 폼은 장식일 뿐입니다. 봇은 위젯을 완전히 건너뛰고 엔드포인트로 바로 POST합니다.

그래서 HTML 폼에서 진짜 질문은 시크릿을 어디에 두느냐입니다. 이 페이지는 세 가지 답을 다룹니다. 여러분의 작은 서버, 여러분의 키를 보관하는 폼 백엔드, 그리고 백엔드 말고는 아무도 키를 갖지 않는 관리형 검사입니다.

다이어그램: 위젯에서 판정까지, Turnstile 토큰의 일생

1. Turnstile 위젯은 공개 site key로 브라우저에서 실행됩니다. 대부분의 방문자는 위젯이 무언가 하는 것을 보지 못합니다.
2. 토큰을 발급해 폼 안의 숨겨진 cf-turnstile-response 입력에 넣습니다.
3. 폼이 POST되고, 토큰은 다른 필드와 함께 전송됩니다.
4. 서버가 시크릿 키, 토큰, 방문자 IP를 Cloudflare의 siteverify 엔드포인트로 보냅니다.
5. 응답이 success인지, 호스트 이름과 action이 예상한 값인지 확인합니다.
6. 모두 일치하면 POST를 받습니다. 하나라도 어긋나면 거부합니다.

토큰은 300초 동안 유효하며 한 번만 검증할 수 있습니다. 같은 토큰을 두 번째로 검사하면 timeout-or-duplicate로 실패합니다.

## 위젯 추가하기

Cloudflare 대시보드의 **Turnstile**에서 위젯을 만들고, 폼이 실행되는 호스트 이름을 등록한 뒤 site key를 복사하세요. 그런 다음 스크립트와 `cf-turnstile` 요소를 폼 안에 추가하세요. 요소는 반드시 `<form>` 안에 있어야 합니다. 그렇지 않으면 숨겨진 토큰 필드가 폼 밖에 생겨 POST되지 않습니다.

`contact.html`:

```html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

- `data-action="contact"`는 위젯에 레이블을 붙입니다. siteverify가 이 값을 돌려주므로, 서버는 다른 폼용으로 발급된 토큰을 거부할 수 있습니다.
- 스크립트는 Cloudflare의 공식 URL에서 불러와야 합니다. Cloudflare에 따르면 프록시하거나 자체 호스팅한 사본은 위젯을 망가뜨립니다.
- 사이트가 Content Security Policy를 보낸다면 `script-src`와 `frame-src` 모두에 `https://challenges.cloudflare.com`을 허용하세요.
- Cloudflare는 무료로 위젯을 최대 20개, 위젯마다 호스트 이름을 최대 10개까지 허용하므로 스테이징 호스트도 등록하세요.

## 직접 운영하는 서버에서 토큰 검증하기

어떤 서버든 토큰을 검증할 수 있습니다. 시크릿과 토큰을 담아 siteverify에 `POST` 한 번이면 됩니다. 여기서는 정적 폼이 POST하는 Cloudflare Worker로 구현합니다. 토큰, 호스트 이름, action을 확인하고, 토큰을 빼낸 뒤 시크릿 키로 필드를 Ikiform에 전달합니다. 전달하는 부분은 메시지를 저장하거나 이메일로 보내는 다른 코드로 바꿔도 됩니다.

`src/index.ts`:

```ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
```

- siteverify는 JSON이나 form-encoded 본문을 받고 항상 JSON으로 응답합니다. `remoteip`는 선택 사항이며, `idempotency_key`를 쓰면 네트워크 오류 뒤에 검사를 안전하게 재시도할 수 있습니다.
- `success`만이 아니라 `hostname`과 `action`도 확인하세요. 다른 사람의 페이지에서 여러분의 site key로 발급된 토큰도 `success`라고 응답합니다.
- 두 시크릿은 `wrangler.toml`이 아니라 `wrangler secret put`으로 저장하세요.
- Worker는 `303` 리디렉션으로 응답하므로 일반 폼에는 JavaScript가 필요 없습니다. 폼의 `action`을 Worker의 라우트로 지정하세요.
- Worker는 페이지의 `Origin` 헤더를 그대로 넘깁니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더를 읽어 무료 `localhost` 테스트를 알아보기 때문입니다.

검사가 실패한 뒤 폼 페이지에 오류를 표시하려면 Worker가 설정하는 쿼리 문자열을 읽으세요.

`contact.html`:

```html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
```

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

## Ikiform에서 검증하기

서버를 아예 운영하고 싶지 않다면 폼이 Ikiform으로 바로 POST하고 Ikiform이 siteverify를 호출하게 할 수 있습니다. 방식은 폼마다 **Spam check** 설정이 결정합니다.

| 방식 | 관리할 키 | 페이지의 위젯 | 자체 서버 |
| --- | --- | --- | --- |
| 위에서 본 여러분의 서버나 Worker | site key와 secret key | 예, 여러분의 것 | 예 |
| **Your own Turnstile keys**를 쓴 Ikiform | site key와 secret key를 Ikiform에 붙여 넣음 | 예, 여러분의 것 | 아니요 |
| **Ikiform**을 쓴 Ikiform | 없음 | 아니요. ikiform.com의 확인 페이지 또는 작은 프레임 | 아니요 |

**Your own Turnstile keys**를 쓰면 site key와 secret key를 폼 설정에 붙여 넣습니다. 시크릿은 암호화되어 저장됩니다. 위젯 마크업은 그대로 두고 폼만 Ikiform 엔드포인트로 연결하세요. Ikiform이 `cf-turnstile-response`를 검증하고 토큰의 호스트 이름을 폼의 **Allowed domains**와 비교합니다. 유효한 토큰이 없는 POST는 시크릿 API 키가 있더라도 `403 captcha_failed`를 받습니다.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

**Ikiform**을 쓰면 페이지에 아무것도 추가하지 않습니다. 토큰 없는 일반 HTML POST에는 ikiform.com의 짧은 **Checking you're human** 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보냅니다. `fetch` POST는 대신 `403 challenge_required`를 받으므로, 먼저 `@ikiform/sdk`의 `challenge()`를 호출해 토큰을 받으세요. Cloudflare 계정은 필요 없습니다. [스팸 문서](https://ikiform.com/ko/docs/spam.md#human-check)에서 두 흐름을 모두 볼 수 있습니다.

Cloudflare에 연결할 수 없어도 Ikiform은 POST를 버리지 않고 `captcha_unavailable` 사유와 함께 스팸으로 저장하므로 대시보드에서 복원할 수 있습니다. 직접 운영하는 서버도 같은 결정을 의도적으로 내려야 합니다. 위의 Worker처럼 실패 시 닫히게 하거나, POST를 보관해 검토하세요.

## 테스트 키

Cloudflare는 `localhost`를 포함한 모든 호스트 이름에서 작동하는 더미 키를 공개합니다. 개발하는 동안 쓰고, 나중에 실제 키로 바꾸세요.

| 키 | 값 | 동작 |
| --- | --- | --- |
| 사이트 키 | `1x00000000000000000000AA` | 항상 통과, 위젯 표시 |
| 시크릿 키 | `1x0000000000000000000000000000000AA` | siteverify가 항상 success로 응답 |
| 시크릿 키 | `2x0000000000000000000000000000000AA` | siteverify가 항상 실패로 응답 |

테스트 토큰에는 실제 호스트 이름이 없으므로 Worker의 호스트 이름 검사가 거부합니다. 이 검사는 개발 환경에서만 완화하고, 프로덕션에서는 절대 완화하지 마세요.

## 작동 확인하기

1. 폼을 평소처럼 제출하세요. POST가 수락되고, 위젯은 아무것도 표시하지 않거나 체크박스 하나만 표시합니다.
2. `cf-turnstile` 요소를 지우고 제출하세요. POST가 거부됩니다. Worker는 `?error=check`로 리디렉션하고, Ikiform은 `403 captcha_failed`로 응답합니다.
3. 제출하고, 뒤로 가기를 누른 뒤 같은 페이지에서 다시 제출하세요. 재사용된 토큰은 실패하며, 서버가 실제로 검증한다는 것을 보여 줍니다.
4. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
5. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.

## 주의할 점

- **토큰 하나에 시도 한 번.** `fetch`로 처리한 제출이 실패하면 `turnstile.reset()`을 호출해 새 토큰을 받으세요. 페이지 전체를 새로 고치면 자동으로 새 토큰을 받습니다.
- **입력이 느린 사람.** 토큰은 300초 후 만료됩니다. 위젯의 `data-refresh-expired` 설정이 새 토큰을 알아서 받을지, 클릭을 기다릴지 결정합니다.
- **www와 루트 도메인.** siteverify에게 `www.example.com`과 `example.com`은 다른 호스트 이름입니다. 폼이 실제로 실행되는 쪽과 비교하거나, 둘 다 허용하세요.
- **싱글 페이지 내비게이션.** 암시적 스크립트는 로드될 때 한 번만 위젯을 렌더링합니다. 클라이언트 측 라우트가 바뀔 때는 `?render=explicit`와 `turnstile.render()`로 명시적으로 렌더링하세요.
- **Ikiform 키로 서버에서 POST하는 경우.** 서버가 시크릿 키로 Ikiform에 전달하면 Ikiform은 관리형 검사를 건너뜁니다. Worker처럼 먼저 직접 토큰을 검증하세요.

## 질문

### 서버 없이 Cloudflare Turnstile을 쓸 수 있나요?

위젯은 어디서든 실행되지만, 토큰은 서버에서 시크릿 키로 검증해야 합니다. 서버가 없다면 대신 검증해 주는 폼 백엔드로 폼을 POST하거나, 키가 필요 없는 Ikiform의 관리형 검사를 쓰세요.

### Cloudflare Turnstile은 무료인가요?

Cloudflare는 위젯 최대 20개, 위젯마다 호스트 이름 최대 10개까지 무료로 제공하며, 요금제 페이지에는 챌린지 수에 제한이 없다고 나와 있습니다. 이를 전제로 하기 전에 Cloudflare의 최신 요금제 페이지를 확인하세요.

### siteverify가 timeout-or-duplicate를 반환하는 이유는 무엇인가요?

토큰이 300초보다 오래되었거나 이미 한 번 검증되었습니다. 제출을 시도할 때마다 새 위젯을 렌더링하거나 초기화하세요.

### Ikiform에서 Turnstile을 쓰려면 Cloudflare 계정이 필요한가요?

아니요. 폼의 Spam check를 Ikiform으로 설정하면 Ikiform이 ikiform.com에서 Turnstile을 실행합니다. 계정은 자체 키와 자체 위젯을 쓸 때만 필요합니다.

## 가이드 더 보기

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