TechFeedTechFeed
Security

클라우드플레어 턴스타일, 리캡차, 사이트키, 서버 검증 | 문의 폼 봇을 어디서 거르나?

문의 폼 봇은 클라우드플레어 턴스타일로 거른다. 사이트키는 브라우저, 시크릿은 서버다. 사이트베리파이로 300초 토큰을 한 번만 확인한다. 리캡차 이미지 퍼즐은 한국 모바일에서 자주 막힌다. 관리 모드가 기본이고 안 보이는 모드는 개인정보 약관이 따로다. 개발자, API, Next.js, 보안, CSP 기준으로 테스트 키와 호스트 제한만 본다.

by

문의 폼에 구글 리캡차를 붙였더니, 한국 휴대폰에서 이미지 퍼즐이 안 열린 적 있으실 겁니다. 클라우드플레어 턴스타일은 사진 고르기를 기본으로 두지 않습니다. 브라우저가 토큰을 만들고, 서버가 사이트베리파이로 한 번만 확인합니다. 토큰은 생성 뒤 300초이고, 같은 값은 두 번 통과하지 않습니다. 위젯만 붙이고 서버를 건너뛰면 보호가 아닙니다. 로컬 https 주소는 터널 글에, 사파리 쿠키는 세임사이트 글에 있습니다. 여기는 문의와 로그인 폼의 봇 칸만 봅니다.


12개 사이트를 돌리며 저는 파트너십 폼부터 리캡차를 뺐습니다. 숫자는 2026년 8월 클라우드플레어 턴스타일 문서 기준입니다.


문의 폼이 한국 모바일에서 막히면 어디부터 보나

리캡차 이미지 칸이 하얗게 남으면, 먼저 위젯 호스트가 한국 인앱 브라우저에서 열리는지 봅니다. 캡차 종류를 바꾸기 전에 그 칸부터 확인합니다.


구글 리캡차는 사진 퍼즐을 자주 띄웁니다. 카카오톡 인앱, 삼성 인터넷, 느린 엘티이에서 퍼즐 이미지가 비거나 체크 박스가 멈춘 날을 여러 번 봤습니다. 사용자는 스팸이 아니라 사람인데, 문의가 서버에 도착하지 않습니다. 턴스타일 공식 시작 문서는 전통 캡차 없이 방문자를 확인한다고 적습니다. 관리 모드는 위험도에 따라 체크 박스를 띄울 수는 있어도, 글자와 사진을 해독시키는 칸은 아닙니다.


턴스타일은 클라우드플레어 프록시가 없어도 됩니다. 문서가 다른 시디엔, 온프레미스, 멀티 클라우드에서도 위젯과 사이트베리파이만으로 동작한다고 밝힙니다. 버셀에만 올려 둔 Next.js 사이트에도 붙습니다. 제가 막힌 자리는 리캡차 스크립트가 한국 모바일에서 이미지를 못 받는 쪽이었습니다. 봇 점수 API를 더 파는 문제가 아니었습니다.


사용자에게 보이는 것서버가 할 일언제 쓰나
없음폼만없음내부망, 로그인 뒤에만 열리는 화면
구글 리캡차체크 또는 이미지 퍼즐구글 검증 엔드포인트이미 구글 콘솔에 묶여 있고, 퍼즐이 한국에서 열리는 팀
턴스타일 관리대부분 자동. 필요할 때만 체크사이트베리파이 필수문의, 파트너십, 로그인. 제가 기본으로 두는 칸
턴스타일 비가시위젯이 안 보임사이트베리파이 + 개인정보 약관 문구디자인만 우선할 때. 약관 문장 없으면 쓰지 않음

터널로 로컬 주소를 여는 글과 자리를 섞지 않습니다. 그 글은 카카오 콜백 https 입니다. 여기는 폼이 봇인지 사람인지 가리는 칸입니다. 링크 미리보기가 내부망을 대신 여는 사고는 에스에스알에프 글입니다.


위젯이 초록이어도 끝이 아닙니다 | 브라우저는 토큰 문자열만 만듭니다. 공격자는 아무 문자열을 폼에 넣을 수 있습니다. 문서가 서버 검증을 필수라고 적습니다.


위젯 키와 시크릿은 어디에 나누나

사이트키는 에이치티엠엘에 넣고, 시크릿은 서버 환경 변수에만 둡니다. 같은 쌍을 브라우저와 서버에 동시에 노출하지 않습니다.


대시보드에서 위젯을 하나 만들면 사이트키와 시크릿이 한 쌍으로 나옵니다. 사이트키는 공개 값입니다. 시크릿은 사이트베리파이 요청에만 씁니다. 호스트 이름 허용 목록에 실제 도메인만 넣습니다. 문서는 프로덕션 사이트키에 localhost 를 넣지 말라고 권합니다. 로컬 시험은 더미 키를 씁니다. 더미 사이트키 1x00000000000000000000AA 는 항상 통과하는 보이는 위젯입니다. 더미 시크릿 1x0000000000000000000000000000000AA 는 더미 토큰만 받습니다. 운영 시크릿은 더미 토큰을 거절합니다.


개발, 스테이징, 운영 위젯을 나눕니다. 문서가 환경마다 다른 위젯을 쓰라고 적습니다. 이름도 로그인 폼, 문의 폼처럼 용도로 붙입니다. 호스트를 느슨하게 두면 다른 사이트에 사이트키를 심어 토큰을 만들 수 있습니다. 서버에서 호스트 이름을 한 번 더 대조하면 그 구멍이 줄어듭니다.


문의 폼에 관리 모드 위젯을 암시 렌더로 붙인다
<!-- https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/ --> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <form method="POST" action="/api/partnership"> <input name="name" required /> <textarea name="message" required></textarea> <!-- 운영: NEXT_PUBLIC_TURNSTILE_SITEKEY --> <!-- 로컬 시험: 1x00000000000000000000AA --> <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div> <button type="submit">보내기</button> </form>

암시 렌더는 cf-turnstile 클래스만 있으면 됩니다. 제출 때 숨은 필드 cf-turnstile-response 가 채워집니다. 시크릿을 NEXT_PUBLIC_ 에 넣으면 번들에 나갑니다. 그 사고는 시크릿 체크 글과 같습니다. 사이트키만 공개 환경 변수로 둡니다.


사이트키는 브라우저, 시크릿은 서버에만 두는 턴스타일 키 분리
사이트키는 에이치티엠엘에 둔다. 시크릿은 사이트베리파이 요청에만 쓴다

브라우저만 통과시키고 서버를 건너뛰는 실수

위젯이 성공해도 서버가 사이트베리파이를 부르지 않으면, 아무 문자열로 문의가 들어옵니다. 보호는 그 호출에서 끝납니다.


공식 검증 문서는 세 가지를 이유로 적습니다. 토큰은 위조될 수 있습니다. 생성 뒤 300초가 지나면 만료됩니다. 한 번 검증된 값은 다시 쓰면 timeout-or-duplicate 로 거절됩니다. 엔드포인트는 POST https://challenges.cloudflare.com/turnstile/v0/siteverify 입니다. 폼 인코딩과 제이슨 모두 받고, 응답은 항상 제이슨입니다. 필수 칸은 secret 과 response 입니다. remoteip 는 선택입니다. 네트워크가 한 번 끊길 수 있으면 idempotency_key 에 유유아이를 넣어 같은 검증을 다시 부를 수 있습니다.


성공 응답에는 hostname, action, challenge_ts 가 옵니다. 문의 폼이면 action 을 contact 로 맞춰 두고, 서버에서 그 값이 다르면 거절합니다. hostname 이 자기 도메인이 아니면 거절합니다. 엔터프라이즈만 오는 ephemeral_id 는 무료 위젯에 기대지 않습니다.


Next.js 라우트에서 사이트베리파이로 토큰을 한 번 확인한다
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' export async function POST(request) { const form = await request.formData() const token = form.get('cf-turnstile-response') const ip = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || 'unknown' if (!token || String(token).length > 2048) { return Response.json({ ok: false, reason: 'token' }, { status: 400 }) } const body = new FormData() body.append('secret', process.env.TURNSTILE_SECRET_KEY) body.append('response', String(token)) body.append('remoteip', ip) const result = await fetch(SITEVERIFY, { method: 'POST', body }).then((r) => r.json()) if (!result.success) { return Response.json( { ok: false, reason: result['error-codes'] || [] }, { status: 400 } ) } if (result.hostname && result.hostname !== 'tech.ambitstock.com') { return Response.json({ ok: false, reason: 'hostname' }, { status: 400 }) } // 여기서만 문의 저장. 시크릿과 토큰은 로그에 남기지 않는다 return Response.json({ ok: true }) }

브라우저에서 사이트베리파이를 부르지 않습니다 | 시크릿이 프론트에 있으면 검증을 우회할 수 있습니다. 문서가 백엔드만 호출하라고 적습니다. 분석 화면에 검증 수가 0이면 이 호출이 빠진 겁니다.


안 보이는 위젯이 항상 편한 건 아닙니다

관리 모드가 공식 권장입니다. 안 보이는 모드는 약관 문장이 필요하고, 실패했을 때 사용자가 다시 누를 칸이 없습니다.


위젯 모드는 세 가지입니다. 관리는 위험도에 따라 자동 통과와 체크 박스를 고릅니다. 비대화형은 스피너만 보이고 클릭을 요구하지 않습니다. 비가시는 위젯 자체도 안 보입니다. 비가시를 켜면 클라우드플레어 턴스타일 개인정보 부속 약관을 자기 개인정보 처리방침에 적어야 한다고 문서가 조건을 겁니다. 12사이트 푸터에 그 문장을 아직 안 넣은 날은, 관리 모드만 켰습니다.


크기가 보통, 가변, 압축입니다. 시에스피가 있으면 script-srcframe-srchttps://challenges.cloudflare.com 을 넣습니다. 논스와 strict-dynamic 도 문서가 지원한다고 적습니다. 헤더 전체는 보안 헤더 글입니다. 여기는 턴스타일 호스트 두 칸만 봅니다.


모드화면에 남는 것조건
관리필요할 때만 체크기본. 사진 퍼즐 없음
비대화형스피너클릭을 원하지 않을 때
비가시없음개인정보 부속 약관 문구 필수

플레이라이트 같은 자동 브라우저는 봇으로 잡힙니다. 시험 스위트에는 더미 키를 넣습니다. 항상 실패하는 보이는 키는 2x00000000000000000000AB 입니다. 체크를 강제하는 키는 3x00000000000000000000FF 입니다. 시크릿 3x0000000000000000000000000000000AA 는 이미 쓴 토큰 오류를 돌려줍니다.


턴스타일 관리, 비대화형, 비가시 모드를 한 표로 나눈 안내
관리 모드가 기본이다. 비가시는 개인정보 약관 문장이 있어야 켠다

토큰 5분이 지나면 다시 받아야 하는 이유

토큰은 만들어진 뒤 300초입니다. 긴 문의 글을 쓰다 제출하면 만료됩니다. 페이지를 새로고침하거나 turnstile.reset 으로 다시 받습니다.


문서가 최대 길이를 2,048자로 적습니다. 만료와 재사용은 둘 다 실패입니다. 만료는 사용자가 폼을 오래 열어 둔 경우입니다. 재사용은 서버가 같은 토큰으로 사이트베리파이를 두 번 부른 경우입니다. 웹훅이 두 번 오는 자리와 비슷해 보이지만, 여기는 폼 한 건입니다. 재시도 폭풍은 멱등 글에 둡니다. 사이트베리파이 네트워크만 끊겼다면 같은 idempotency_key 로 검증을 다시 부릅니다. 문의 저장이 끝난 뒤에 같은 토큰으로 한 번 더 부르면 거절이 맞습니다.


페이지가 뜨자마자 위젯이 토큰을 만들면, 첨부 파일을 고르는 동안 5분이 지나기 쉽습니다. 그때는 제출 직전에 실행하는 편이 낫습니다. 공식 클라이언트 렌더 문서의 execute 흐름을 따릅니다. 실패 코드 invalid-input-response 는 토큰이 깨졌거나 만료된 칸입니다. missing-input-secret 은 환경 변수가 빈 칸입니다. invalid-input-secret 은 키를 잘못 붙인 칸입니다.


제출 직전에 위젯을 리셋하고, 시험 키는 환경으로 나눈다
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/ // .env.development // TURNSTILE_SITEKEY=1x00000000000000000000AA // TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA function onSubmitClick(form) { if (window.turnstile) { window.turnstile.reset() } form.requestSubmit() } // 더미 토큰은 XXXX.DUMMY.TOKEN.XXXX // 운영 시크릿은 이 값을 거절한다. 로컬과 운영 키를 섞지 않는다
턴스타일 토큰 300초 만료와 한 번만 검증하는 흐름
토큰은 5분이다. 같은 값은 두 번 통과하지 않는다. 오래 쓴 폼은 리셋한다

검증은 서버에서 한 번만 한다

사이트베리파이가 성공한 뒤에만 메일을 보내거나 디비에 넣습니다. 위젯 성공 콜백만 믿고 저장하지 않습니다.


제 순서는 짧습니다. 더미 키로 로컬에서 통과와 실패를 한 번씩 봅니다. 운영 위젯의 호스트 목록에 실제 도메인만 넣습니다. 라우트에서 success, hostname, action 을 대조합니다. 시크릿은 버셀 환경 변수에만 둡니다. 시에스피에 challenges.cloudflare.com 두 칸을 엽니다. 분석에서 검증 수가 0이면 라우트가 빠진 겁니다.


카카오 콜백 주소를 여는 터널과 이 글을 한 설정으로 묶지 않습니다. 턴스타일은 폼 봇 칸입니다. 쿠키 SameSite 는 로그인 세션 칸입니다. 헤더 전체 목록은 보안 헤더 글입니다. 오늘 바꿀 것은 문의 폼 한 곳의 사이트키, 시크릿, 사이트베리파이 세 줄입니다.


참고 자료


모드 이름과 시험 키는 콘솔이 우선입니다. 위 표는 2026년 8월 공개 문서 기준입니다.


자주 묻는 질문

클라우드플레어 디엔에스가 없어도 턴스타일을 쓰나?

씁니다. 시작 문서가 프록시와 무관한 독립 서비스라고 적습니다. 버셀만 쓰는 Next.js 사이트에도 위젯과 사이트베리파이면 됩니다. 도메인을 클라우드플레어에 붙이는 터널 글과 칸이 다릅니다.


로컬호스트에서 운영 사이트키를 시험하나?

문서가 운영 키에 localhost 를 넣지 말라고 권합니다. 로컬은 더미 사이트키 1x00000000000000000000AA 와 더미 시크릿을 씁니다. 운영 시크릿은 더미 토큰을 거절합니다. 호스트 목록에 로컬을 억지로 넣지 않습니다.


위젯이 성공인데 서버가 400을 주면?

토큰이 300초를 넘겼거나 이미 검증된 값입니다. 오류 코드가 timeout-or-duplicate 또는 invalid-input-response 인지 봅니다. 폼을 오래 열었으면 리셋하고 다시 제출합니다. 시크릿이 비면 missing-input-secret 입니다.


리캡차 코드를 한 줄만 바꿔도 되나?

이전 문서가 스크립트 자리를 바꾸는 경로를 안내합니다. 그래도 서버 검증 유알엘은 구글이 아니라 사이트베리파이로 바뀝니다. 프론트만 바꾸고 구글 시크릿을 그대로 두면 통과하지 않습니다.


안 보이는 모드가 더 안전하나?

보이지 않는다고 검증이 강해지지는 않습니다. 서버 호출은 같습니다. 실패했을 때 사용자가 다시 체크할 칸이 없고, 개인정보 부속 약관을 자기 페이지에 적어야 합니다. 문의 폼은 관리 모드가 먼저입니다.


시크릿이 깃허브에 올라가면?

대시보드에서 키를 돌리고 버셀 환경 변수를 바꿉니다. 사이트키는 공개여도, 시크릿이 있으면 위조 토큰을 검증해 줄 수 있습니다. 시크릿 체크 글 순서대로 폐기하고, 로그에 토큰을 남기지 않습니다.


클라우드플레어턴스타일리캡차사이트키사이트베리파이보안개발자APINext.jsCSP캡차

함께 보면 좋은 문제 해결

EXPLORE / Security

이어서 읽어보기

전체 토픽 둘러보기