
Cloudflare Turnstile은 위젯만 붙이면 뚫린다…서버 검증까지 잇는 법

Cloudflare Turnstile로 로그인·가입·문의 폼을 보호하려면 위젯이 만든 토큰을 폼 처리 서버에서 Siteverify로 검증해야 한다. Cloudflare의 시작 안내는 브라우저 위젯과 서버 검증을 함께 구현하도록 명시한다. 위젯만 붙이고 서버가 검증하지 않으면 공격자는 화면을 거치지 않고 폼 주소에 임의의 문자열을 보내도 본래 작업을 요청할 수 있다.
구현의 기준은 간단하다. 브라우저에는 공개 sitekey로 위젯을 표시하고, 서버에는 비공개 secret을 보관한다. 폼이 제출되면 서버가 토큰을 검증한 뒤 success, hostname, action이 모두 기대값과 맞을 때만 로그인 처리나 문의 저장을 시작한다. 브라우저의 성공 콜백은 사용자 화면을 제어하는 데 쓸 수 있지만, 요청을 허용하는 최종 판단은 서버가 맡아야 한다.
위젯을 만들고 폼에 토큰을 넣기
Cloudflare 대시보드의 Turnstile 메뉴에서 위젯을 만들 때 폼을 제공할 호스트 이름을 등록한다. 발급된 sitekey는 페이지에 노출되는 공개 식별자이고, 같은 위젯의 secret은 서버가 Siteverify를 호출할 때 쓰는 자격 증명이다. 운영용과 개발용 위젯을 분리하면 테스트 키나 로컬 호스트 설정이 운영 폼으로 섞일 가능성을 줄일 수 있다.
로그인 폼을 예로 들면 Turnstile 스크립트를 불러오고 폼 안에 위젯 요소를 둔다. 아래 SITEKEY를 실제 공개 키로 바꾸고, 로그인 요청을 구분할 action으로 login을 지정한다. secret은 HTML이나 브라우저 스크립트에 넣지 않는다. 공개 저장소에도 올리지 말고 서버 환경변수나 비밀 관리 서비스로 전달한다.
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="post" action="/login"><input name="email" type="email" required><input name="password" type="password" required><div class="cf-turnstile" data-sitekey="SITEKEY" data-action="login"></div><button type="submit">로그인</button></form>
위젯을 form 요소 안에 두면 토큰을 담은 숨은 입력값 cf-turnstile-response가 자동으로 추가된다. 일반 HTML 폼은 다른 입력값과 함께 이 토큰을 제출한다. JavaScript로 요청 본문을 직접 만드는 앱이라면 토큰도 그 본문에 포함해야 한다. 어느 방식으로 받았든 서버 입장에서는 아직 검증되지 않은 사용자 입력이다.
위젯이 화면에 보이거나 브라우저에서 토큰을 받았다는 사실만으로 로그인 요청을 허용하지 않는다. 폼 주소는 브라우저 화면과 별개로 호출할 수 있기 때문이다. 같은 이유로 클라이언트가 별도로 보낸 action 값이나 요청의 Host 헤더를 검증 기준으로 삼지 않는다. 어떤 호스트와 작업을 허용할지는 서버 설정과 해당 경로에서 정한다.
로그인 처리 앞에서 Siteverify 호출하기
요청의 순서는 폼 제출 → 애플리케이션 서버 → Siteverify 응답 → 로그인 처리다. Siteverify 검증 문서에 따르면 토큰은 생성 후 300초 동안 유효하고 한 번만 검증할 수 있다. 서버는 secret과 받은 토큰을 Siteverify에 POST하고 JSON 응답을 확인해야 한다. 토큰을 받았다는 이유만으로 로그인 함수를 먼저 실행하면 검증 결과가 뒤늦게 와도 이미 작업이 진행된 상태가 된다.
다음은 전역 fetch와 AbortSignal.timeout을 사용할 수 있는 Node.js 환경의 Express 예시다. 일반 HTML 폼을 받을 수 있도록 urlencoded 본문 파서를 등록한다. TURNSTILE_SECRET_KEY와 TURNSTILE_HOSTNAME은 서버 환경변수이며, TURNSTILE_HOSTNAME에는 운영 로그인 폼을 실제로 제공하는 호스트 이름을 넣는다. 필수 설정이 비어 있으면 시작 단계에서 오류를 내도록 한다.
const express = require('express'); const app = express(); app.use(express.urlencoded({ extended: false })); const secret = process.env.TURNSTILE_SECRET_KEY; const expectedHost = process.env.TURNSTILE_HOSTNAME; if (!secret || !expectedHost) throw new Error('Turnstile 서버 설정이 필요합니다');
검증 미들웨어는 토큰이 없거나 문자열이 아니면 외부 호출 전에 거절한다. 길이 검사도 추가해 Siteverify가 허용하는 토큰 범위를 벗어난 입력을 걸러낸다. 이어서 secret과 토큰을 URL 인코딩된 본문으로 전송하고, 응답을 읽은 뒤 해당 로그인 경로의 기대값과 비교한다. 검증 결과를 담은 값은 클라이언트가 보낸 값이 아니라 Siteverify 응답에서 읽어야 한다.
async function turnstileGuard(req, res, next) { const token = req.body['cf-turnstile-response']; if (typeof token !== 'string' || !token || token.length > 2048) return res.status(400).send('인증을 다시 진행해 주세요'); try { const body = new URLSearchParams({ secret, response: token }); const reply = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body, signal: AbortSignal.timeout(5000) }); if (!reply.ok) return res.status(503).send('잠시 후 다시 시도해 주세요'); const result = await reply.json(); if (result.success !== true || result.hostname !== expectedHost || result.action !== 'login') return res.status(400).send('인증을 다시 진행해 주세요'); return next(); } catch (error) { return res.status(503).send('잠시 후 다시 시도해 주세요'); } }
app.post('/login', turnstileGuard, loginHandler);
여기서 loginHandler는 애플리케이션의 기존 로그인 처리 함수로 연결한다. 비밀번호 확인과 세션 발급은 turnstileGuard가 next()를 호출한 뒤에만 실행된다. 가입·문의 경로에도 같은 순서를 적용하되 각 위젯의 data-action과 서버가 기대하는 action을 signup, contact처럼 일치시킨다. 폼마다 다른 작업을 수행하더라도 검증을 통과하기 전에는 계정 생성이나 메시지 저장이 시작되지 않아야 한다.
success 뒤에 hostname과 action을 확인하기
Siteverify의 success는 제출된 토큰의 검증 결과다. hostname은 위젯이 실행된 호스트 이름이고, action은 위젯에 지정한 작업 식별자다. 로그인 경로에서 success만 확인하면 다른 폼에 사용하려고 발급된 토큰도 해당 경로의 정책상 받아들이게 될 수 있다. 따라서 예시 코드는 success가 true여도 hostname이 서버 설정과 다르거나 action이 login이 아니면 요청을 거절한다.
여러 호스트가 같은 로그인 폼을 제공한다면 단일 문자열 대신 서버가 관리하는 명시적 허용 목록으로 hostname을 비교할 수 있다. 이때 허용 목록에는 실제로 폼을 제공하는 호스트만 넣는다. 위젯 생성 시 설정한 허용 호스트와 서버의 응답값 검사는 서로 다른 단계다. 앞의 설정은 위젯의 실행 위치를 제한하고, 뒤의 검사는 지금 처리하려는 요청에 그 토큰을 쓸지 결정한다.
예시에서는 방문자 IP를 Siteverify에 보내지 않는다. remoteip는 필수 매개변수가 아니므로 토큰과 hostname·action 검증을 위해 임의로 채울 필요가 없다. 나중에 추가한다면 프록시가 전달하는 IP 헤더를 서버가 신뢰하도록 구성했는지 먼저 확인해야 한다. 신뢰하지 않는 요청 헤더를 그대로 사용하면 방문자가 전달한 문자열을 실제 IP로 잘못 취급할 수 있다.
만료·재사용·통신 장애를 구분해 처리하기
토큰이 만료됐거나 이미 검증에 사용됐다면 Siteverify는 검증 실패와 함께 timeout-or-duplicate 오류 코드를 반환할 수 있다. 사용자가 폼을 오래 열어 두었다가 제출하거나, 한 번 보낸 요청을 같은 토큰으로 다시 보내는 상황이 여기에 해당한다. 이때 서버는 원래 작업을 실행하지 않고 새 토큰으로 다시 제출하도록 안내해야 한다. 이전 토큰을 그대로 자동 재전송하는 방식은 해결책이 되지 않는다.
브라우저에서는 만료 콜백을 이용하거나 turnstile.reset으로 위젯을 갱신해 새 토큰을 받을 수 있다. 폼의 다른 입력값을 수정해 다시 제출할 때도 기존 토큰이 이미 검증에 쓰였다면 새 토큰이 필요하다. 이런 갱신은 사용자가 폼을 다시 제출할 수 있게 돕는 기능이다. 새 토큰으로 들어온 요청 역시 서버에서 다시 검증한다.
Siteverify가 success를 false로 반환한 경우와 Siteverify에 연결하지 못한 경우도 분리한다. 예시 코드는 검증 실패를 400, 원격 응답 오류나 시간 초과를 503으로 처리하며 어느 경우에도 loginHandler를 호출하지 않는다. 사용자에게는 재시도할 수 있는 문구를 보여 주되 서버 로그에서는 토큰 거절과 통신 장애를 구분하면 원인을 파악하기 쉽다. 로그와 오류 화면에 secret이나 토큰 원문을 남기지 않는다.
Siteverify 호출에 성공한 직후 애플리케이션의 다른 처리 단계가 실패했다면, 같은 토큰을 다시 쓰는 재요청은 거절될 수 있다. 로그인이나 가입 폼의 오류 복구 흐름을 설계할 때 이 점이 중요하다. 사용자에게 입력을 고쳐 다시 제출하도록 할 때는 위젯도 갱신하고, 서버는 새 요청에서 받은 토큰을 독립적으로 검증해야 한다.
테스트 키로 통과와 거절 경로 확인하기
Cloudflare의 테스트 키 안내에는 통과용 sitekey 1x00000000000000000000AA와 secret 1x0000000000000000000000000000000AA, 거절용 sitekey 2x00000000000000000000AB와 secret 2x0000000000000000000000000000000AA가 제시돼 있다. 중복 토큰 오류 경로에는 통과용 sitekey와 3x0000000000000000000000000000000AA secret을 조합할 수 있다. 개발 환경에서는 sitekey와 secret을 모두 테스트용으로 맞춰야 하며, 운영 secret은 테스트 위젯의 더미 토큰을 거절한다.
테스트 secret은 미리 정해진 결과를 돌려주므로 실제 토큰의 만료와 일회성 사용을 증명하는 수단으로 삼지 않는다. 특히 통과용 테스트 secret에 같은 더미 토큰을 반복 제출해도 운영 토큰의 재사용 결과를 재현하지 못한다. hostname과 action 검사를 확인할 때는 Siteverify 응답을 모의로 만들어 각각 다른 값을 반환하게 하고, loginHandler가 호출되지 않는지 살핀다. 테스트 응답의 예시값을 운영 호스트나 action의 보장된 값으로 가정하지 않는다.
실제 키를 쓰는 사전 운영 환경에서는 등록된 호스트의 위젯으로 정상 토큰을 발급받아 로그인 경로에 제출할 수 있다. 그 토큰을 다시 제출했을 때 거절되는지, 토큰 없이 직접 폼 주소를 호출했을 때 로그인 처리가 시작되지 않는지도 확인한다. 이 확인은 화면에 위젯이 표시되는 상태와 서버가 요청을 통제하는 상태를 구분해 준다. 폼의 보호 기준은 마지막까지 서버의 검증 결과에 있어야 한다.
함께 읽기:
관련 기사
뉴스레터 구독
최신 Web3, AI, 암호화폐 뉴스를 이메일로 받아보세요.




