문의 폼 스팸 막기5분 분량
정적 사이트 문의 폼의 스팸을 막는 방법
시크릿을 보관하거나 POST 수를 셀 수 없는 정적 사이트 폼에서도 허니팟 파셜, 안전한 스크립트, 서버 측 검사로 스팸을 막으세요.
정적 사이트에는 서버가 없으므로, 페이지의 어떤 것도 시크릿 키를 보관하거나, 캡차 토큰을 검증하거나, 방문자별 POST 수를 셀 수 없습니다. 이런 검사는 다른 곳에서 실행해야 합니다. 호스트의 함수나, 폼이 POST하는 폼 백엔드입니다. 어느 쪽이든 폼에 접근성을 갖춘 허니팟을 넣고, JavaScript 없이도 폼이 작동하게 유지하세요.
정적 사이트에서 다른 점
정적 사이트에서는 배포하는 모든 바이트가 공개됩니다. HTML이나 JavaScript에 있는 것은 무엇이든 봇이 읽고 건너뛸 수 있으므로, 클라이언트 측 스팸 검사는 강제가 아니라 권고일 뿐입니다. 가장 중요한 세 가지 검사는 모두 서버가 필요합니다.
- 캡차 토큰 검증에는 시크릿 키가 필요하며, 이 키는 공개 파일에 둘 수 없습니다.
- 방문자별 POST 수 세기에는 요청 사이에도 유지되는 저장소가 필요합니다.
- 허니팟 읽기에는 POST가 도착한 뒤 봇의 손이 닿지 않는 곳에서 실행되는 코드가 필요합니다.
정적 호스트는 페이지로 오는 POST도 받지 않습니다. Vercel과 GitHub Pages는 정적 페이지로 온 폼 POST에 405 Method Not Allowed로 응답하므로, 스팸을 따지기 전에 폼에는 엔드포인트부터 필요합니다.
- 자기 자신에게 POST하는 정적 페이지는 코드를 실행하지 않는 호스트에 닿으므로, POST는 405로 실패하거나 어디에도 가지 않습니다.
- 호스트 함수를 쓰면 페이지가 시크릿을 보관하는 함수로 POST하고, 함수가 허니팟을 읽고 토큰을 검증한 뒤 메시지를 전달합니다.
- 폼 백엔드를 쓰면 페이지가 백엔드로 바로 POST하고, 백엔드가 허니팟, 점수, 레이트 리밋, 사람 확인을 실행한 뒤 이메일을 보냅니다.
선택지
| 선택지 | 스팸 검사 | 장단점 |
|---|---|---|
| Netlify Forms | 모든 POST에 Akismet. 추가하면 netlify-honeypot 필드와 reCAPTCHA v2도 | Netlify에서만 작동. 호스트를 옮기면 폼을 다시 만들어야 함. Netlify Forms 대안 참고 |
| 호스트의 함수 | 직접 작성하는 모든 것: 허니팟, 토큰 검사, 저장소를 쓰는 레이트 리밋 | 직접 작성하고 유지 보수해야 하며, 뒤에 이메일이나 저장 서비스가 필요 |
| Ikiform 같은 폼 백엔드 | 코드 없이 허니팟, 점수, 레이트 리밋, 허용 도메인, 사람 확인 | 별도 서비스, 제출 건수 기준 요금 |
재사용 가능한 파셜로 만든 폼
어떤 엔드포인트를 고르든 폼은 파셜이나 include로 한 번만 작성해, 모든 페이지가 같은 허니팟을 쓰게 하세요. 아래 버전은 Ikiform으로 POST합니다. 대신 함수를 쓰려면 action을 함수 경로로 바꾸고 _redirect를 지우세요.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
<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>
<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>
<input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
<p role="status"></p>
<button>Send message</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>- 허니팟은 화면 밖에 있고, 탭 순서에서 빠져 있으며, 스크린 리더에서 숨겨져 있습니다. 각 속성은 허니팟 가이드에서 설명합니다.
_redirect는 사람들을 감사 페이지로 보냅니다. Ikiform은 폼의 Allowed domains 중 하나에 있는https:주소일 때만 따릅니다.- Hugo, Jekyll, Eleventy 가이드에서 설정, 쇼트코드, GitHub Pages를 다룹니다.
망가뜨리지 않고 개선하기
스크립트로 방문자를 페이지에 머물게 할 수 있지만, 스크립트가 폼이 작동하는 유일한 방법이 되어서는 안 됩니다. 이 스크립트는 fetch로 POST하고, 두 경우에 일반 브라우저 POST로 돌아갑니다. 네트워크 호출이 실패할 때, 그리고 Ikiform의 사람 확인이 challenge_required로 토큰을 요구할 때입니다. 일반 POST는 대신 Ikiform의 확인 페이지를 받으므로, 스크립트를 바꾸지 않고도 검사를 켤 수 있습니다.
<script>
document.querySelectorAll("form.contact").forEach((form) => {
const status = form.querySelector("[role=status]");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren("Thanks. We'll reply by email.");
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
const wait = response.headers.get("Retry-After");
if (response.status === 429 && wait) {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
} else {
status.textContent = result.error.message;
}
} catch {
form.submit();
return;
}
button.disabled = false;
});
});
</script>form.submit()은submit이벤트를 다시 발생시키지 않고 폼을 보내므로 폴백이 무한 반복되지 않습니다.429는Retry-After에서 대기 시간을 읽어 보여 줍니다. Ikiform은 이 헤더를 브라우저 코드에 노출합니다.- 성공 문구가 폼을 대체하므로 실수로 두 번 보내는 사람이 없습니다.
호스트 함수를 직접 작성한다면
Netlify, Cloudflare Pages, Vercel은 모두 정적 파일 옆에서 작은 함수를 실행합니다. 함수는 여러분의 백엔드이므로 스팸 검사도 함수가 맡습니다. 이 Netlify Function은 허니팟을 읽고, 링크로 가득 찬 메시지를 버리고, Turnstile 토큰을 검증한 뒤 Resend로 이메일을 보냅니다. 걸러 낸 POST는 모두 실제 POST와 같은 감사 페이지 리디렉션을 받습니다.
import type { Config, Context } from "@netlify/functions";
const LINK = /https?:\/\/|www\./gi;
export default async (req: Request, context: Context) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await req.formData();
const email = String(form.get("email") ?? "");
const message = String(form.get("message") ?? "");
const thanks = Response.redirect(new URL("/thanks/", req.url), 303);
if (String(form.get("_gotcha") ?? "").trim()) return thanks;
if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY ?? "",
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: context.ip,
}),
});
const verdict = (await check.json()) as { success: boolean };
if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: "Contact form <form@example.com>",
to: "you@example.com",
reply_to: email,
subject: "New message from the website",
text: message,
}),
});
return thanks;
};
export const config: Config = { path: "/api/contact" };context.ip는 방문자의 IP이며, siteverify에서remoteip로 쓸 수 있습니다.TURNSTILE_SECRET_KEY와RESEND_API_KEY는 저장소가 아니라 사이트의 환경 변수에 설정하세요.- 걸러 낸 POST를 저장하지 않고 버리므로, 실수로 걸린 실제 메시지는 사라집니다. 최소한 로그는 남기세요.
- 함수는 호출 사이에 메모리가 없으므로 레이트 리밋이 없습니다. 레이트 리밋 가이드에서 Redis로 추가합니다.
Ikiform이 정적 폼에 해 주는 것
폼이 Ikiform으로 바로 POST하면 서버 측 작업은 Ikiform에서 이루어집니다. 허니팟은 _gotcha이고, 콘텐츠 점수와 방문자별 레이트 리밋이 모든 POST에서 실행되며, Allowed domains는 다른 사이트에서 온 POST를 스팸으로 표시합니다. 키 없이 Cloudflare Turnstile을 쓰려면 Spam check를 Ikiform으로 바꾸세요. 일반 HTML POST는 확인 페이지에서 이를 실행하고, 위 스크립트는 그 페이지로 돌아갑니다. 스팸으로 판정된 POST는 실제 제출과 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없습니다. spam 상태로 저장되고, 알림 이메일과 웹훅을 보내지 않으며, 사용량에 포함되지 않고, 30일 뒤 삭제됩니다. 스팸을 차단한 워크스페이스에는 매주 월요일 합계, 가장 많이 받은 폼, 주요 사유를 담은 요약 이메일이 한 통 갑니다.
username.github.io 같은 호스트 자체 주소도 등록합니다. Netlify 배포 미리보기와 Vercel 미리보기 URL은 메인 사이트 주소의 하위 도메인이 아니므로, 미리보기를 테스트할 때는 해당 미리보기 호스트를 정확히 추가하세요.작동 확인하기
- JavaScript를 끄고 제출하세요. 폼은 여전히 POST되고 감사 페이지로 이동합니다.
- JavaScript를 켜고 제출하세요. 폼이 감사 문구로 바뀝니다.
- 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - Spam check를 Ikiform으로 설정하고 스크립트를 켠 채로 제출하세요. 페이지가 일반 POST로 돌아가 확인 페이지를 보여 줍니다.
- 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 Spam으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.
주의할 점
403 origin_not_allowed, 또는 모든 POST가 스팸으로 들어감. 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다.- 축소된 HTML. 일부 빌드 단계는 비어 있거나 쓰이지 않는다고 판단한 속성을 지웁니다. 빌드된 페이지의 허니팟에
aria-hidden과tabindex가 남아 있는지 확인하세요. - 캐시된 감사 페이지. 스팸과 실제 POST의 감사 페이지 URL이 같다면(그래야 합니다), POST가 진짜라는 전제에 의존하는 내용은 절대 넣지 마세요.
- 저장소에 든 키. 공개 정적 사이트 저장소에 커밋된 시크릿은 공개된 것입니다. 한 번이라도 그랬다면 키를 교체하세요.
질문
위젯은 표시할 수 있지만, 토큰은 서버에서 시크릿 키로 검증해야 합니다. 호스트의 함수, 여러분의 키를 검증하는 폼 백엔드, 또는 키가 필요 없는 Ikiform의 관리형 검사를 쓰세요.
가장 흔한 봇은 막지만, 서버에서 무언가가 허니팟을 읽을 때만 그렇습니다. 레이트 리밋과 콘텐츠 점수를 함께 쓰세요. 이것들도 서버나 폼 백엔드가 필요합니다.
폼이 파일만 제공하는 호스트의 페이지로 POST하고 있습니다. 폼의 action을 함수나 폼 백엔드 엔드포인트로 지정하세요.
작동해야 합니다. 먼저 일반 HTML 폼으로 POST하고, 폼을 개선하면서 문제가 생기면 일반 POST로 돌아가는 스크립트를 추가하세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.