1. 개요

Cloudflare Turnstile 은 CAPTCHA 대체다. 대부분 사용자는 아무것도 안 해도 통과한다. 붙이는 것도 쉽다. 스크립트 하나, render() 한 번, 토큰을 폼에 실어 백엔드가 siteverify. 그런데 세 사이트에 붙이고 운영하니 “가끔 안 된다” 가 세 번 다른 이유로 왔다. 그때마다 하나씩 추가한 체크리스트다. 라이브러리(@marsidev/react-turnstile)를 써도 자체 구현을 해도 적용된다.


2. 핵심 내용

2-1. 위젯 상태를 밖으로 낸다

위젯이 토큰을 낼 때까지 제출 버튼은 비활성이다. 문제는 disabled 가 이유를 말하지 않는다 는 것. 로딩 중인지, 실패했는지, 토큰이 만료됐는지 사용자는 모른다. 죽은 버튼을 본다.

type TurnstileStatus = "disabled" | "loading" | "ready" | "expired" | "error"

위젯 컴포넌트가 onStatusChange 로 이 상태를 밖에 알리고, 호출부가 버튼 문구를 바꾼다.

상태버튼
loading”Waiting for verification…” (비활성)
ready”Submit”
expired”Verification expired, retry”
error”Verification unavailable” + 위젯에 Try again

핵심은 모든 실패 경로가 error 에 착지 하는 것이다. return 으로 빠지는 경로가 하나라도 있으면 그 경로의 사용자는 loading 에서 영원히 기다린다.

2-2. 로더는 하나, 실패한 스크립트는 제거

  • 스크립트 로드를 모듈 수준 Promise 하나로 공유한다. 위젯이 둘이어도 <script> 는 한 번.
  • script.onload 시점에 window.turnstile 이 아직 없을 수 있다. ?onload=콜백 파라미터로 Cloudflare 가 준비됐을 때 부르는 전역 콜백을 쓴다. 콜백은 나중에 삭제하지 말고 no-op 으로 바꾼다.
  • 로드에 실패한 <script> 엘리먼트에 리스너를 다시 붙이면 다시 발화하지 않는다. Promise 가 영원히 settle 되지 않는다. 실패한 엘리먼트는 제거하고 새로 삽입한다.
  • 타임아웃(12초). 그 안에 콜백이 안 오면 error.
  • render() 가 undefined 를 돌려주는 경우도 error.

2-3. 자리를 예약한다

위젯이 뜨면서 아래 버튼이 밀린다. 사용자가 버튼을 누르려는 순간 위젯이 렌더돼 버튼이 72px 내려가면 빈 곳을 누른다. 위젯 높이를 실측해(65~72px, 모드에 따라 다름) min-height 로 예약한다. 폭은 Cloudflare 기본 300px 고정에 가운데 정렬. 폼 폭을 채우려고 size: flexible 을 쓰면 일부 환경에서 iframe 내부가 좌정렬돼 “가운데가 아니다” 는 신고가 온다. 그 정렬은 우리가 못 바꾼다.

2-4. 사파리는 체크박스를 내민다

신호가 적은 환경(사파리, 프라이빗 모드, 일부 확장 프로그램)에서 Turnstile 은 수동 챌린지 를 띄운다. 위젯은 떴는데 사용자가 체크할 때까지 토큰이 없다. 안내가 없으면 “인증이 고장났다”.

위젯 렌더 뒤 2.5초 안에 토큰이 안 오면 “Check the box above to continue.” 를 띄운다. 이 경로는 자동 통과 테스트 키로는 재현되지 않는다. Cloudflare 가 제공하는 “항상 인터랙티브 챌린지” 테스트 사이트 키(3x...FF 계열)를 써야 로컬에서 볼 수 있다. 체크박스 유무는 코드가 아니라 대시보드의 사이트 키 위젯 모드(Managed / Non-interactive / Invisible) 속성이다.

2-5. 토큰은 1회용, 약 5분

토큰은 siteverify 에 한 번 쓰면 끝이다. 로그인에 실패하고 같은 토큰으로 다시 시도하면 timeout-or-duplicate. 호출의 finally 에서 turnstile.reset(widgetId) 를 부른다. 리셋 직후 새 토큰이 올 때까지 loading 으로 돌아가니 버튼 문구도 따라간다.

axios 재시도(axios-retry)를 쓰면 재시도 요청에 옛 토큰 헤더가 그대로 실린다. 재시도 전에 토큰 헤더를 제거해야 한다. 안 그러면 첫 실패 뒤 재시도가 전부 duplicate 로 죽는다.

2-6. 서비스워커는 개입하지 않는다

PWA 라면 서비스워커의 fetch 리스너가 challenges.cloudflare.com 요청을 가로채지 않게 한다. iOS Safari 에서 서비스워커가 이 요청을 처리하면 위젯 스크립트가 20초 가까이 blocked 상태로 걸리는 현상이 있었다. 리스너 맨 앞에서 해당 호스트면 event.stopImmediatePropagation() 으로 빠진다. 그리고 <head> 에 preconnect / dns-prefetch 를 넣어 첫 연결을 앞당긴다.

2-7. 백엔드: fail-open 의 조건

  • 시크릿이 비어 있으면 검증을 건너뛴다. 프론트도 사이트 키가 없으면 위젯을 안 띄운다. 매니페스트는 optional: true. 세 저장소가 어느 순서로 배포돼도 함께 켜지고 함께 꺼진다.
  • siteverify 가 success: false → 거부(fail-closed).
  • siteverify 호출 자체 가 실패(타임아웃, 5xx) → 통과(fail-open). Cloudflare 가 잠깐 죽었다고 문의가 전부 막히는 것보다 낫다는 판단.
  • 토큰 길이 상한(2048)을 호출 전에 검사한다. 안 그러면 공격자가 수 MB 토큰으로 siteverify 실패를 유발해 fail-open 을 스위치로 쓴다. “실패를 누가 유발할 수 있는가” 를 막아야 fail-open 이 안전하다.
  • 검증 실패는 400. 401 로 하면 프론트의 토큰 재발급 경로가 잘못 작동한다.
  • 토큰을 요구하는 경로 목록은 프론트 인터셉터에 단일 출처 로 두고, 그 목록에 있는 요청에만 토큰 헤더를 붙인다. 백엔드 @RequireTurnstile 과 이 목록이 어긋나면 한쪽은 토큰 없이 400, 다른 쪽은 불필요한 위젯.

2-8. 대시보드 지표 읽기

도입 직후 대시보드에 “siteverify 미호출” 경고가 떴다. 위젯은 토큰을 발급하는데 백엔드가 검증하지 않는다는 뜻이다. 조사하니 도입 첫날 폼을 끝까지 제출한 사람이 0명이었다. 발급만 쌓이고 검증이 없으니 경고. 버그가 아니었다. 지표의 의미를 먼저 읽는다.


3. 마무리

요약

  • 상태 5종을 밖으로 내고 버튼이 이유를 말하게. 모든 실패는 error 에 착지.
  • 실패한 <script> 는 제거 후 재삽입. ?onload= 콜백. 타임아웃.
  • 자리 예약, 300px 고정 가운데. 사파리 수동 챌린지는 인터랙티브 테스트 키로 재현.
  • 토큰은 1회용. finally 리셋, 재시도 전 헤더 제거.
  • 서비스워커는 우회. 백엔드 fail-open 은 토큰 길이 상한과 함께.