문의 폼 스팸 막기

문의 폼 스팸 막기6분 분량

Turnstile을 이용한 TanStack Start 폼 스팸 방지

클라이언트 IP를 위한 함수 미들웨어, 토큰 버킷, 서버 함수에서 검증하는 Turnstile, CSP로 TanStack Start 폼을 스팸으로부터 보호하세요.

Preet SutharIkiform 창업자

클라이언트 IP와 출처를 한 번 읽어 모든 서버 함수에 컨텍스트로 넘기는 함수 미들웨어를 추가하세요. 서버 함수에서는 입력을 검증하고, IP별 버킷에서 토큰을 하나 꺼내고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. Start가 설치하는 CSRF 미들웨어는 유지하고, src/start.ts를 만든다면 직접 다시 추가하세요.

TanStack Start에서 다른 점

서버 함수는 동일 출처 RPC 엔드포인트입니다. Start는 Sec-Fetch-Site, Origin, Referer를 확인하는 CSRF 미들웨어로 다른 사이트로부터 서버 함수를 보호하며, 이 미들웨어는 다른 곳의 페이지가 방문자의 브라우저를 통해 POST하는 것을 막습니다. 원하는 헤더를 무엇이든 보낼 수 있는 스크립트는 막지 못합니다. 그래서 스팸 검사는 여전히 함수 안에 있어야 합니다.

이를 위한 Start의 기본 구성 요소는 미들웨어입니다. 함수 미들웨어는 검증기와 핸들러보다 먼저 실행되고, 요청 헤더를 읽을 수 있으며, context를 통해 핸들러에 값을 넘깁니다. IP 조회를 한 번만 작성하면 사이트의 모든 폼이 이를 사용합니다.

TanStack Start 서버 함수 앞의 미들웨어 체인여러분의 코드 이전서버 함수 안에서POST/_serverFn폼이나 스크립트에서CSRF미들웨어같은 출처?방문자 미들웨어context:ip, origin검증기공백 제거, 길이제한토큰 버킷3회, 20초마다1회 회복siteverifyaction이supportIkiform저장, 허니팟 점수결과: 나중에다시예토큰 남음사람비어 있음TanStack Start 서버 함수 앞의 미들웨어 체인여러분의 코드 이전서버 함수 안에서POST/_serverFn폼이나 스크립트에서CSRF 미들웨어같은 출처?방문자 미들웨어context: ip, origin검증기공백 제거, 길이 제한토큰 버킷3회, 20초마다 1회 회복siteverifyaction이 supportIkiform저장, 허니팟 점수결과: 나중에다시예토큰 남음사람비어 있음
  1. 브라우저나 스크립트가 서버 함수의 URL로 POST합니다.
  2. Start의 CSRF 미들웨어는 여러분의 출처에서 왔다고 증명할 수 없는 POST를 거부합니다.
  3. 방문자 미들웨어가 클라이언트 IP와 Origin 헤더를 읽어 컨텍스트로 넘깁니다.
  4. 검증기가 핸들러보다 먼저 입력을 정규화합니다.
  5. 핸들러가 해당 IP의 버킷에서 토큰을 하나 꺼냅니다. 남은 토큰이 없으면 나중에 다시 시도하라는 결과를 반환합니다.
  6. Turnstile 토큰을 검증한 뒤 필드를 Ikiform으로 전달하고, Ikiform이 저장하고 허니팟 점수를 매깁니다.
각 계층은 다음 계층보다 저렴합니다. 거부된 POST는 siteverify나 Ikiform에 도달하지 않습니다.

프로젝트 설정

파일
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
.env
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
  • Vite는 VITE_ 접두사가 붙은 변수만 브라우저에 노출하며, import.meta.env로 읽습니다. 시크릿에는 접두사가 없으므로 서버에서 process.env로만 존재합니다.
  • 테스트 키는 localhost를 포함해 어떤 호스트에서든 통과합니다. 환경마다 실제 키로 바꾸세요.

클라이언트 IP를 위한 함수 미들웨어

src/server/visitor.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});

CLIENT_IP_HEADER를 호스트가 덮어쓰는 헤더로 설정하세요. Vercel은 x-forwarded-for, Netlify는 x-nf-client-connection-ip(문서에는 Functions의 context.ip만 나와 있지만 Netlify 직원이 권장하는 헤더), Cloudflare는 cf-connecting-ip입니다. nginx 뒤에서 직접 운영하는 Node 서버라면 nginx가 설정하는 헤더를 쓰고, nginx를 우회해 앱에 접근할 수 없게 하세요. 미들웨어는 방문자의 Origin도 넘깁니다. Ikiform으로 가는 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이를 읽어 무료 localhost 테스트를 알아보기 때문입니다.

IP별 토큰 버킷

고정 구간에서는 스크립트가 한 분의 끝에 허용량 전부를 보내고 다음 분의 시작에 또 보낼 수 있습니다. 토큰 버킷은 시도를 하나씩 회복하므로, 오타를 고치는 사람은 1분이 아니라 몇 초만 기다리면 됩니다.

src/server/bucket.ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}

이 카운터는 메모리에 있으며, 컨테이너에서 실행하는 node .output/server/index.mjs처럼 오래 실행되는 단일 서버에서는 정확합니다. Vercel이나 Netlify에서는 함수 인스턴스마다 맵이 따로 있어 실제 제한을 알 수 없습니다. 그런 곳에서는 레이트 리밋 페이지처럼 버킷을 Redis로 옮기고, 같은 take(key) 형태를 유지하세요.

서버 함수

src/server/support.ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
  • 검증기는 핸들러보다 먼저 실행되며 입력을 정규화하기만 합니다. 필드 오류는 폼이 해당 입력 옆에 표시할 수 있는 결과로 돌아옵니다.
  • 저렴한 검사부터 합니다. 이메일과 길이 검사는 비용이 없고, 버킷은 맵 조회 한 번, siteverify는 네트워크 호출 한 번입니다.
  • action은 support여야 합니다. 다른 위젯용으로 여러분의 site key로 발급된 토큰은 거부됩니다.
  • 허니팟 _gotcha는 여기서 확인하지 않고 전달하므로, 채워진 경우 검토할 수 있는 스팸으로 저장됩니다.
서버 측 버전은 시크릿 키로 POST하므로 Ikiform은 Allowed domains와 자체 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5건인 Standard 제한이 모든 방문자를 합쳐서 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

라우트와 위젯

위젯은 작은 훅에서 명시적으로 렌더링됩니다. 모듈 수준의 프라미스가 스크립트를 한 번만 불러오므로 클라이언트 내비게이션 중에 라우트가 여러 번 마운트되어도 괜찮으며, 토큰은 Turnstile의 callback으로 전달됩니다.

src/lib/use-turnstile.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  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<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((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?: 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 useTurnstile(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
src/routes/support.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
  • Turnstile이 토큰을 건네줄 때까지 버튼은 비활성화되어 있으므로, 빈 토큰으로 제출하는 사람이 없습니다.
  • turnstile.reset()은 시도할 때마다 실행됩니다. 토큰은 한 번만 쓸 수 있고 300초 동안 유효하므로, 재시도에는 새 토큰이 필요합니다.
  • 이 폼에는 JavaScript가 필요합니다. 서버 함수는 .url을 통해 일반 폼 POST를 받을 수 있지만, Turnstile 위젯은 어차피 브라우저가 필요합니다.

src/start.ts의 보안 헤더

Start에는 자체 CSP 옵션이 없습니다. src/start.ts의 전역 요청 미들웨어로 모든 응답에 CSP를 설정할 수 있습니다. 이 파일을 만들면 Start가 기본으로 설치하는 CSRF 미들웨어가 꺼지므로 먼저 다시 추가하세요.

src/start.ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

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'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
  • Start가 서버에서 렌더링하는 HTML에는 인라인 스크립트가 들어 있습니다. nonce를 설정하지 않으면 script-src에 'unsafe-inline'이 필요하며, 그래도 목록에 없는 호스트의 스크립트는 여전히 막습니다.
  • Turnstile은 challenges.cloudflare.com의 iframe에 위젯을 그리므로 frame-src가 이를 허용해야 합니다.
  • CSRF filter가 검사를 서버 함수로 한정하므로 일반 페이지 로드에는 영향이 없습니다.

배포

호스트CLIENT_IP_HEADER리미터
직접 운영하는 Node 서버나 컨테이너프록시가 설정하는 헤더메모리로 충분
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipWorkers 레이트 리밋 바인딩

Vercel은 x-forwarded-for를 방문자의 주소로 덮어쓴다고 문서화하고 있습니다. 배포하기 전에 모든 프로덕션 및 미리보기 호스트 이름을 Turnstile 위젯에 등록하세요.

Ikiform에 검사 맡기기

Turnstile 키와 리미터를 직접 운영하고 싶지 않다면 서버 함수를 빼고 브라우저에서 POST하세요. onSubmit에서 send 호출을 챌린지와 제출로 바꾸세요. 여기서 box는 위젯을 대신하는 빈 div에 연결된 ref입니다.

src/routes/support.tsx
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });

폼의 Spam check를 Ikiform으로 설정하고, Rate limit은 방문자당 10분에 2건인 Strict를 고르고, Allowed domains에 도메인을 추가하세요. 그러면 Ikiform이 허니팟, ikiform.com의 Turnstile, IP별 제한, 콘텐츠 점수를 실행하고, 걸러 낸 것을 30일 동안 보관합니다. CSP에는 Cloudflare 대신 frame-src와 connect-src에 https://ikiform.com이 필요합니다. 서버 함수를 유지한다면 키는 서버 설정 가이드에서 다룹니다.

작동 확인하기

  1. 테스트 키로 개발 서버를 실행하고 요청을 보내세요. Submissions에 나타납니다. 플랜이 없어도 매월 localhost 테스트 50건은 무료입니다.
  2. 요청 네 개를 빠르게 보내세요. 네 번째는 초 단위 대기 시간과 함께 나중에 다시 시도하라는 메시지를 반환합니다.
  3. TURNSTILE_SECRET_KEY를 항상 실패하는 시크릿으로 설정하세요. 모든 요청에 사람 확인 오류가 표시됩니다.
  4. 네트워크 탭의 서버 함수 호출을 Origin 없이 curl로 재전송하세요. CSRF 미들웨어가 거부합니다.
  5. 허니팟을 채워 _gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

주의할 점

문제해결 방법
src/start.ts를 추가한 뒤 교차 사이트 POST가 서버 함수에 도달함커스텀 src/start.ts는 기본 CSRF 미들웨어를 대체합니다. 예제처럼 requestMiddleware에 createCsrfMiddleware()를 추가하세요.
모든 방문자가 하나의 버킷을 공유함CLIENT_IP_HEADER가 호스트가 설정하지 않는 헤더를 지정하고 있어 모든 IP가 unknown입니다. 위 표의 헤더를 쓰세요.
Vercel에서 제한이 무작위로 초기화되는 것 같음함수 인스턴스마다 메모리가 따로 있습니다. 버킷을 Redis로 옮기세요.
개발 환경에서 위젯이 두 번 렌더링됨React의 Strict Mode는 이펙트를 두 번 마운트합니다. 정리 함수가 첫 번째 위젯을 제거하므로 프로덕션에서는 하나만 보입니다.

질문

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