문의 폼 스팸 막기

문의 폼 스팸 막기5분 분량

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

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

Preet SutharIkiform 창업자

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

Turnstile에서 다른 점

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

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

위젯에서 판정까지, Turnstile 토큰의 일생브라우저에서, site key로서버에서, secret key로위젯 실행보통 보이지 않음토큰 발급숨겨진cf-turnstile-response입력폼 POST토큰이 필드와 함께 전송siteverify시크릿 + 토큰 + 방문자 IP응답 확인success, 호스트 이름,actionPOST 수락거부제출모두 일치하나라도 어긋남위젯에서 판정까지, Turnstile 토큰의 일생브라우저에서, site key로서버에서, secret key로위젯 실행보통 보이지 않음토큰 발급숨겨진cf-turnstile-response입력폼 POST토큰이 필드와 함께 전송siteverify시크릿 + 토큰 + 방문자 IP응답 확인success, 호스트 이름,actionPOST 수락거부제출모두 일치하나라도 어긋남
  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
<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
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
<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 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

Ikiform에서 검증하기

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

방식관리할 키페이지의 위젯자체 서버
위에서 본 여러분의 서버나 Workersite key와 secret key예, 여러분의 것예
Your own Turnstile keys를 쓴 Ikiformsite 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
<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 계정은 필요 없습니다. 스팸 문서에서 두 흐름을 모두 볼 수 있습니다.

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

테스트 키

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

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

테스트 토큰에는 실제 호스트 이름이 없으므로 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처럼 먼저 직접 토큰을 검증하세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.