문의 폼 스팸 막기5분 분량
캡차 없이 폼 스팸을 막는 방법
허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
보이지 않는 검사 네 가지를 함께 쓰고 결과를 합산하세요. 허니팟, 폼이 얼마나 오래 열려 있었는지 보여 주는 서명된 타임스탬프, 링크와 스팸 문구에 대한 점수, 방문자별 레이트 리밋입니다. 합계가 기준값을 넘을 때만 스팸으로 처리하면 약한 신호 하나 때문에 실제 사람이 막히지 않습니다. 걸러 낸 것은 보관하고, 스팸 폴더에 봇이 여전히 통과하는 것이 보일 때만 나중에 캡차를 추가하세요.
캡차가 없을 때 다른 점
캡차는 강한 예 또는 아니요 하나를 줍니다. 캡차가 없으면 대신 여러 개의 약한 신호를 갖게 되며, 각 신호만으로는 피하기도 쉽고 실수로 걸리기도 쉽습니다. 빠른 POST는 봇일 수도 있고, 미리 써 둔 메시지를 붙여 넣은 사람일 수도 있습니다. 링크는 스팸일 수도 있고 포트폴리오일 수도 있습니다. 요령은 점수를 매기는 것입니다. 신호마다 가중치를 주고 합산한 뒤, 기준값을 넘을 때만 스팸으로 판단하세요. 유일하게 강한 신호인 채워진 허니팟에는 혼자서도 기준을 넘을 만큼 큰 가중치를 줍니다.
- 스크립트 봇은 허니팟을 채워 5를 더하고, 1초도 안 되어 POST해 3을 더합니다. 합계 8이므로 스팸입니다.
- 붙여 넣은 SEO 홍보는 이름 필드의 링크로 3, 알려진 스팸 문구로 2를 받습니다. 합계 5로 기준값 5에 도달합니다.
- 링크 3개가 든 실제 메시지는 2점뿐이므로 받음 처리됩니다.
서명된 렌더링 시각
사람은 폼을 채우는 데 몇 초가 필요합니다. 시간 함정은 그 간격을 잽니다. 약한 버전은 숨겨진 필드에 Date.now()를 넣는데, 어떤 봇이든 다시 쓸 수 있습니다. 강한 버전은 서버에서 시크릿으로 렌더링 시각에 서명하므로, 봇은 페이지가 실제보다 일찍 열렸다고 주장할 수 없습니다. 이 파일은 Web Crypto가 있는 곳이면 어디서든 실행됩니다. Node 20 이상, Deno, Bun, Workers, 모든 서버리스 런타임입니다.
const encoder = new TextEncoder();
async function key(secret: string) {
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
"verify",
]);
}
function toHex(bytes: ArrayBuffer) {
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function fromHex(hex: string) {
return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}
export async function signRender(secret: string, now = Date.now()) {
const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
return `${now}.${toHex(signature)}`;
}
export type RenderCheck = "ok" | "too_fast" | "invalid";
export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
const [time = "", signature = ""] = token.split(".");
if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
const age = now - Number(time);
if (!valid || age < 0 || age > 86_400_000) return "invalid";
return age < minSeconds * 1000 ? "too_fast" : "ok";
}페이지를 렌더링할 때 서명하므로, 페이지는 정적 HTML로 캐시되지 않고 요청마다 렌더링되어야 합니다. Astro에서는 다음과 같습니다.
---
import { signRender } from "../lib/render-token";
export const prerender = false;
const rendered = await signRender(import.meta.env.RENDER_SECRET);
---
<form method="POST" action="/api/contact">
<input type="hidden" name="rendered" value={rendered} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="name">Name</label>
<input id="name" name="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>- 문의 폼에는 3초가 안전한 최솟값입니다. 미리 써 둔 메시지를 붙여 넣는 사람도 2초는 걸릴 수 있습니다.
- 하루보다 오래된 토큰은 거부하세요. 그렇지 않으면 봇이 페이지를 한 번 불러와 같은 토큰을 몇 주 동안 재사용합니다.
- 토큰이 없거나 위조된 것은 증거가 아니라 신호로 다루세요. 캐시된 페이지나 오래된 탭에서도 그런 토큰이 나올 수 있습니다.
POST에 점수 매기기
신호를 한 함수에 모으세요. 링크와 문구 검사는 허니팟과 시간 검사가 잡지 못하는 것, 즉 사람이 입력하거나 붙여 넣은 메시지를 잡습니다. 문구 목록은 짧고 구체적으로 유지하세요. free 같은 넓은 단어는 실제 고객을 걸러 냅니다.
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;
type Reason = keyof typeof WEIGHTS;
export function scoreContact(input: {
name: string;
message: string;
trap: string;
render: "ok" | "too_fast" | "invalid";
}) {
const reasons: Reason[] = [];
if (input.trap.trim()) reasons.push("honeypot");
if (input.render === "too_fast") reasons.push("too_fast");
if (input.render === "invalid") reasons.push("invalid_render");
if (input.name.match(LINK)) reasons.push("link_in_name");
if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
return { score, reasons, spam: score >= THRESHOLD };
}import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";
export const POST: APIRoute = async ({ request, redirect }) => {
const form = await request.formData();
const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
const verdict = scoreContact({
name: String(form.get("name") ?? ""),
message: String(form.get("message") ?? ""),
trap: String(form.get("_gotcha") ?? ""),
render,
});
await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
return redirect("/thanks", 303);
};핸들러는 스팸을 버리지 않고 모든 것을 상태와 함께 저장하며, 양쪽 모두 같은 리디렉션으로 응답합니다. 스팸은 나중에 검토하면 되고, 봇은 어떤 신호에 걸렸는지 알 수 없습니다. 이 라우트 앞에 방문자별 레이트 리밋을 두면 이 중 어떤 것이 실행되기도 전에 폭주를 막을 수 있습니다.
Ikiform은 캡차 없이 이 검사들을 실행합니다
모든 Ikiform 폼은 설정 없이 허니팟, 시간 검사, 콘텐츠 점수, 레이트 리밋을 실행하며, Spam check는 Off로 시작합니다. 허니팟은 _gotcha입니다. 콘텐츠 점수는 링크, 알려진 스팸 문구 22개, 일회용 이메일 도메인, 24시간 안의 동일한 반복을 확인합니다. 레이트 리밋은 방문자당 분당 5건으로 시작합니다. 5점 이상이면 스팸입니다.
| 신호 | 가중치 |
|---|---|
| 허니팟이 채워짐 | +5 |
| 서명된 렌더링 후 2초 안에 POST | +3 |
| 렌더링 시각이 위조되었거나 24시간보다 오래됨 | +3 |
| 이름 또는 제목 필드의 링크 | +3 |
| 24시간 안에 동일한 필드 | +3 |
| 링크 3개 이상 | +2 |
| 알려진 스팸 문구 | +2 |
| 일회용 이메일 도메인 | +2 |
Ikiform의 서명된 렌더링 시각은 폼의 공개 스키마에서 옵니다. shadcn/ui 컴포넌트는 이를 대신 보냅니다. 직접 렌더링하는 폼은 @ikiform/sdk의 getSchema()로 받아 와 _ikiform_ts로 돌려보낼 수 있습니다.
import { getSchema, submit } from "@ikiform/sdk";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
if (rendered) data.set("_ikiform_ts", rendered);
const result = await submit(FORM_ID, data);
form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});일반 HTML 폼은 렌더링 시각을 보내지 않으며 이 때문에 불이익을 받지 않으므로, 없어도 손해가 없습니다. 스팸으로 판정된 POST는 실제 제출과 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없습니다. spam 상태로 저장되고, 알림 이메일과 웹훅을 보내지 않으며, 사용량에 포함되지 않고, 30일 뒤 삭제됩니다. 스팸을 차단한 워크스페이스에는 매주 월요일 합계, 가장 많이 받은 폼, 주요 사유를 담은 요약 이메일이 한 통 갑니다. 자세한 내용은 스키마 엔드포인트와 스팸 문서에 있습니다.
작동 확인하기
- 폼을 불러와 1초 안에 제출하세요. 직접 만든 핸들러라면 POST는
too_fast와 함께 스팸으로 저장됩니다. - 브라우저 개발자 도구에서 숨겨진
rendered값을 고친 뒤 제출하세요.invalid_render로 점수가 매겨집니다. - 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - 링크 3개 외에 수상한 점이 없는 메시지를 보내세요. 받음 처리됩니다.
- 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 Spam으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.
주의할 점
- 정적 페이지. 배포할 때 한 번 빌드한 페이지는 모든 방문자에게 렌더링 시각이 하나입니다. 요청마다 서명하거나, 폼이 나타날 때 JavaScript로 새 토큰을 받아 오세요.
- 비밀번호 관리자와 붙여 넣기. 어떤 사람은 자동 완성으로 1초 만에 폼을 채웁니다. 시간 검사는 그 자체로는 절대 막지 않도록 약한 신호로 두세요.
- 문구 목록은 낡습니다. 스팸 문구는 바뀝니다. 몇 주마다 통과한 것을 검토하고, 흔한 단어가 아니라 구체적인 문구를 추가하세요.
- 반복은 정상입니다. 사람들은 답이 없으면 다시 보냅니다. 동일한 반복에 가중치를 주되, 바로 차단하지는 마세요.
질문
대부분은 가능합니다. 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋이 작은 사이트에 오는 스팸 대부분을 실제 방문자에게 부담 없이 막습니다. 완전한 브라우저를 실행하며 기다리는 봇은 여전히 통과할 수 있으며, 이때 Turnstile 같은 보이지 않는 검사가 도움이 됩니다.
문의 폼이라면 3초 정도입니다. 더 짧으면 빠른 봇을 놓치고, 더 길면 미리 써 둔 메시지를 붙여 넣거나 자동 완성을 쓰는 사람을 잡기 시작합니다. 강제 차단이 아니라 여러 신호 중 하나로 쓰세요.
숨겨진 필드의 평범한 타임스탬프는 어떤 봇이든 더 오래된 것처럼 다시 쓸 수 있습니다. 서버에서 시크릿으로 서명하면 유효한 렌더링 시각은 여러분의 서버만 만들 수 있습니다.
아니요. 허니팟, 서명된 시간 검사, 콘텐츠 점수, 레이트 리밋은 스팸 검사가 꺼져 있어도 모든 폼에서 실행됩니다. 관리형 Turnstile 검사는 그래도 스팸이 통과할 때 폼마다 켜는 추가 기능입니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.