문의 폼 스팸 막기5분 분량
Turnstile을 이용한 Astro 폼 스팸 방지
허니팟, astro:env 시크릿으로 확인하는 Turnstile, IP별 제한, Astro 내장 CSP를 갖춘 Action으로 Astro 폼을 스팸으로부터 보호하세요.
폼이 JavaScript 없이 POST하는 Astro Action에 검사를 두세요. context.clientAddress로 제한하고, astro:env/server의 시크릿으로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. 문의 페이지는 온디맨드로 렌더링되어야 하므로 어댑터를 추가하고 해당 페이지에 prerender = false를 지정하세요. 사이트 전체를 정적으로 유지하고 싶다면 일반 폼을 Ikiform으로 POST하고 같은 검사를 Ikiform에 맡기세요.
Astro에서 다른 점
Astro는 기본적으로 정적 HTML을 빌드하며, 정적 HTML은 시크릿을 보관할 수 없습니다. Turnstile 시크릿, Ikiform 시크릿 키, 레이트 리밋 카운터는 모두 요청마다 실행되는 코드가 필요하므로, Astro 사이트에서 첫 번째 결정은 문의 페이지를 온디맨드로 렌더링할지 여부입니다.
- 문의 페이지를 어떻게 렌더링할지 정합니다.
- 자체 Turnstile 키를 쓰면 페이지는 어댑터를 통해 온디맨드로 렌더링됩니다.
- 폼이 Astro Action으로 POST하고, Action이 IP별로 제한하고 토큰을 검증한 뒤 필드를 전달합니다.
- Ikiform이 메시지를 저장하고 허니팟 점수를 매깁니다.
- 사이트를 정적으로 유지하려면 일반 폼이 Ikiform으로 바로 POST하고, Ikiform이 허니팟, 관리형 Turnstile 검사, 레이트 리밋을 실행합니다.
Astro는 기본적으로 security.checkOrigin을 통해 온디맨드 페이지로 오는 폼 POST의 출처도 확인합니다. 이 검사는 다른 사이트의 페이지가 여러분의 페이지로 POST하는 것을 막습니다. 원하는 헤더를 무엇이든 보낼 수 있는 스크립트는 막지 못하므로, 아래 검사는 여전히 필요합니다.
프로젝트 설정
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envnpx astro add vercel, netlify, cloudflare로 어댑터를 추가하세요. 모든 변수를 astro:env 스키마에 선언하면 시크릿이 없을 때 import 시점에 실패하고, 시크릿이 클라이언트 코드로 import되는 일이 없습니다.
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_tokenimport vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
},
},
security: {
csp: {
scriptDirective: {
resources: ["'self'", "https://challenges.cloudflare.com"],
},
directives: ["frame-src https://challenges.cloudflare.com"],
},
},
});access: "secret"은 값이 번들에 들어가지 않게 합니다.astro:env/server는 클라이언트 스크립트에서 로드되지 않습니다.scriptDirective.resources는 Astro의 기본값을 대체하므로, Cloudflare 옆에'self'를 다시 적으세요. Astro는 여전히 자체 스크립트를 해시합니다.- CSP는
astro dev에서 실행되지 않습니다.astro build와astro preview로 확인하세요.
IP별 제한
Action에서 context.clientAddress는 어댑터가 플랫폼에서 받아 알려 주는 방문자의 IP이므로, X-Forwarded-For를 직접 파싱할 필요가 없습니다. 온디맨드 라우트에만 있으며, 프리렌더링된 라우트에서는 예외를 던집니다. 이 카운터는 IP별로 분당 Redis 키 하나를 유지하므로 서버리스 인스턴스 사이에서도 작동합니다.
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";
const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
return count > limit;
}Cloudflare에서 Astro 7은 Astro.locals.runtime 대신 cloudflare:workers에서 바인딩을 읽습니다. wrangler.jsonc에 선언한 Workers 레이트 리밋 바인딩은 네트워크 호출 없이 Redis를 대체하지만, 위치별 근사치 집계라는 대가가 있습니다.
import { env } from "cloudflare:workers";
export async function overLimit(ip: string) {
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
return !success;
}토큰 검증하기
import { TURNSTILE_SECRET_KEY } from "astro:env/server";
export async function isHuman(token: string, ip: string, host: string) {
const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip });
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
if (!verdict.success || verdict.action !== "contact") return false;
return import.meta.env.DEV || verdict.hostname === host;
} catch {
return false;
}
}- siteverify는 form-encoded 본문을 받으므로
URLSearchParams에는 헤더가 필요 없습니다. - 호스트 이름은 방문자가 실제로 POST한 호스트인
context.url.hostname과 비교합니다. 테스트 토큰에는 더미 호스트 이름이 들어 있으므로astro dev에서는 이 검사를 건너뜁니다. - 토큰은 300초 동안 유효하며 한 번만 쓸 수 있습니다. 일반 폼 POST는 페이지를 새로 로드하고, Turnstile은 그때마다 새 토큰을 발급합니다.
액션
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "A few words more, please.").max(5000),
_gotcha: z.string().optional(),
"cf-turnstile-response": z.string().max(2048).optional(),
}),
handler: async (input, context) => {
const ip = context.clientAddress;
if (await overLimit(ip)) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
}
if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
}
const { "cf-turnstile-response": _token, ...fields } = input;
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(fields),
});
if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
return { sent: true };
},
}),
};- Zod가 핸들러보다 먼저 실행되므로, 형식이 잘못된 POST는 Redis 호출을 쓰지 않습니다. 필드 오류는
isInputError를 통해 페이지로 돌아옵니다. - 허니팟
_gotcha는 스키마에서 선택 항목이며 그대로 전달됩니다. Ikiform은 채워진 허니팟을 스팸으로 점수 매기므로 여전히 검토할 수 있습니다. - 토큰은 전달하기 전에 제거합니다. 이미 검증한 토큰은 Ikiform에 쓸모가 없습니다.
- 서버 측 fetch는
Origin을 보내지 않으므로 방문자의 이 헤더를 함께 보내며, Ikiform은 이를 읽어 무료localhost테스트를 알아봅니다. TOO_MANY_REQUESTS는429에,FORBIDDEN은403에 대응합니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.페이지
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
<main>
<h1>Contact us</h1>
{result?.data?.sent ? (
<p role="status">Thanks. We'll reply by email.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
{fields.name && <p class="field-error">{fields.name[0]}</p>}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p class="field-error">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
{fields.message && <p class="field-error">{fields.message[0]}</p>}
<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>
<div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}
</main>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>폼은 클라이언트 JavaScript 없이 작동합니다. action={actions.sendMessage}는 쿼리 문자열에 액션 이름을 담아 같은 페이지로 POST하고, Astro가 액션을 실행하면 렌더링 중에 Astro.getActionResult()가 결과를 읽습니다. 이 페이지만 온디맨드로 렌더링되고, 사이트의 나머지는 정적으로 유지됩니다.
- POST가 실패한 뒤 새로 고치면 폼을 다시 보낼지 묻습니다. 이것이 거슬린다면 Astro 문서는 결과를 세션에 담아 POST, 리디렉션, GET 패턴을 쓰라고 권합니다.
- Turnstile 스크립트는
is:inline을 쓰므로 Astro가 건드리지 않습니다. Cloudflare의 공식 URL에서 불러와야 합니다. - 정적 페이지의 클라이언트 스크립트에서 액션을 호출해도 작동하지만, 그 경우 시도할 때마다 직접
turnstile.reset()을 호출해야 합니다.
배포
| 호스트 | 어댑터 | 시크릿 보관 위치 | 제한 |
|---|---|---|---|
| Vercel | @astrojs/vercel | 프로젝트 환경 변수 | Redis |
| Netlify | @astrojs/netlify | 사이트 환경 변수 | Redis |
| Cloudflare | @astrojs/cloudflare | wrangler secret put | Workers 레이트 리밋 바인딩 |
배포하기 전에 Turnstile 위젯에 프로덕션 호스트 이름을 추가하고, 미리보기에서 테스트한다면 미리보기 호스트 이름도 추가하세요.
정적으로 유지하고 Ikiform에 검사 맡기기
대부분의 Astro 사이트는 정적이며, 문의 폼 하나 때문에 어댑터를 추가하는 것은 실제 비용입니다. 어댑터 없이 폼을 Ikiform 엔드포인트로 바로 POST하세요.
---
---
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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 minlength="10"></textarea>
<div class="hp" aria-hidden="true">
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send message</button>
</form>폼의 Spam check를 Ikiform으로 설정하세요. 토큰 없는 POST에는 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보내므로, 페이지에는 스크립트도, 키도, CSP 변경도 필요 없습니다. Rate limit은 방문자당 10분에 2건인 Strict를 고르고, Allowed domains에 도메인을 추가하세요. 스팸은 실제 메시지와 같은 응답을 받고 30일 동안 보관되므로 오탐을 되살릴 수 있습니다. 나머지는 Astro 설정 가이드에 있습니다.
작동 확인하기
- 테스트 키로
astro dev를 실행하고 메시지를 보내세요. Submissions에 나타납니다. 플랜이 없는 워크스페이스는 매월localhost테스트 50건을 받습니다. - 개발자 도구에서
required속성을 지우고 이름을 비워 두세요. 입력 아래에 필드 오류가 표시됩니다. TURNSTILE_SECRET_KEY를 항상 실패하는 시크릿으로 설정하고 제출하세요. 페이지에 사람 확인 오류가 표시됩니다.- 1분 안에 여섯 번 제출하세요. 여섯 번째는 제한 메시지와 함께
429로 응답합니다. - 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. astro build와astro preview를 실행하고 페이지를 열어 콘솔에 CSP 위반이 없는지 확인하세요.
주의할 점
| 문제 | 해결 방법 |
|---|---|
프리렌더링된 페이지에서 clientAddress를 쓸 수 없음 | 페이지나 액션이 빌드 시점에 실행되었습니다. 문의 페이지에 export const prerender = false를 추가하세요. |
| 액션 결과가 항상 undefined임 | 폼이 프리렌더링된 페이지로 POST했습니다. 폼 액션은 온디맨드로 렌더링되는 페이지에서만 실행됩니다. |
| 개발 환경에서는 괜찮은데 프로덕션에서 위젯이 비어 있음 | CSP가 위젯을 막고 있습니다. scriptDirective.resources에 https://challenges.cloudflare.com이 있어야 하고, frame-src도 이를 허용해야 합니다. |
커스텀 도메인에서 보낸 모든 POST가 메시지 없이 403 | security.checkOrigin이 다른 출처를 보았습니다. 흔히 프록시가 호스트를 바꿔 쓴 경우입니다. Astro에 도달하는 host 헤더가 페이지의 도메인과 일치하는지 확인하세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 사유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
질문
폼 액션으로는 작동하지 않습니다. 액션으로 POST하는 폼은 어댑터를 통해 페이지가 온디맨드로 렌더링되어야 합니다. 완전한 정적 사이트라면 대신 Ikiform 같은 폼 백엔드로 폼을 POST하세요.
Action에서는 context.clientAddress를, 페이지에서는 Astro.clientAddress를 쓰세요. 어댑터가 플랫폼에서 값을 채웁니다. 온디맨드로 렌더링되는 라우트에만 있습니다.
네, Astro 6부터 안정화된 security.csp로 지원합니다. scriptDirective.resources에 'self'와 함께 https://challenges.cloudflare.com을 추가하고, 같은 호스트에 대한 frame-src 지시어를 추가하세요. 개발 환경에서는 실행되지 않으므로 프로덕션 빌드로 테스트하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.