쿠키를 훔쳐도 로그인이 안 되는 이유 — TLS 지문이 만드는 보이지 않는 방어벽
개발자라면 한 번쯤 이런 사고실험을 해본 적이 있을 것 같습니다. 로그인된 페이지에서 브라우저 DevTools를 열고 세션 쿠키를 전부 복사한 뒤, 그걸 curl 이나 Node fetch에 Cookie 헤더로 붙이면 똑같이 로그인된 상태로 API를 호출할 수 있지 않을까? 라는 질문입니다.
이게 되는 사이트도 있고, 안 되는 사이트도 있습니다. 안 되는 사이트는 대부분 Cloudflare, AWS WAF, Akamai 같은 현대적인 봇 차단 레이어 뒤에 있는 서비스들입니다. 쿠키는 분명히 유효한데도 HTTP 403과 함께 cf-mitigated: challenge 같은 응답 헤더가 돌아오거나, "Just a moment..." 로 시작하는 챌린지 페이지가 떨어집니다. 한 번 곰곰이 생각해보니, 쿠키만 정확히 들고 있는 건 세션 인증의 전부가 아니라는 뜻이었습니다.
이 글은 그 차이를 만드는 TLS 지문(fingerprinting) 개념을 정리한 메모입니다. 그리고 왜 이런 레벨의 방어를 개별 서비스가 직접 구현하지 않고 관리형 서비스에 맡기는지까지 이어서 봅니다.
1. TLS ClientHello 지문 — JA3와 JA4
클라이언트가 HTTPS로 서버에 연결할 때 가장 먼저 보내는 메시지가 TLS ClientHello입니다. 이 메시지에는 클라이언트가 어떤 암호학적 정체성을 가지고 있는지가 통째로 담깁니다.
- 지원하는 cipher suite 목록 (그리고 그 순서)
- 제시하는 TLS extension 목록 (그리고 그 순서)
- 지원하는 elliptic curve 목록
- signature algorithm 목록
이 항목들은 구현체마다 다르게 세팅됩니다. Chrome이 보내는 ClientHello와 Node의 기본 TLS 스택이 보내는 ClientHello는 전혀 다릅니다. Python requests, Go net/http, Java HttpClient도 각자 다른 지문을 만듭니다. 심지어 Chrome 버전 업그레이드 때마다 지문이 조금씩 달라집니다.
2017년 Salesforce는 이 ClientHello의 주요 필드를 이어 붙이고 MD5 해시를 씌운 값을 JA3 지문이라고 공개했습니다. 해시 한 줄이면 "이 클라이언트는 Chrome 147 macOS 인가, 아니면 Node undici 인가"를 상당히 정확하게 구분할 수 있게 된 것입니다. 2023년에는 FoxIO가 TLS 1.3과 QUIC을 보강한 JA4 규격을 공개해서, 현재 많은 WAF/CDN 제품이 JA3에서 JA4로 옮겨가고 있습니다.
실제 지문 값은 이런 모양입니다 (Chrome 예시, 공개 리서치 자료에서 인용).
JA3: 769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11-13-15-16-18-21-23-35-51-65281,23-24-25,0
해시: cd08e31494f9531f560d64c695473da9
서버 입장에서 이 해시를 화이트리스트/블랙리스트와 대조하는 건 상대적으로 저렴한 연산입니다. 그리고 일반적인 자동화 도구의 기본 TLS 스택은 이미 알려진 "봇 지문" 집합에 들어가 있는 경우가 많습니다.
2. HTTP/2 SETTINGS 지문
TLS 레이어를 통과했다고 끝이 아닙니다. HTTPS 위에서 HTTP/2가 열릴 때, 클라이언트는 가장 먼저 SETTINGS 프레임을 보내서 자기 파라미터를 알립니다.
HEADER_TABLE_SIZEINITIAL_WINDOW_SIZEMAX_CONCURRENT_STREAMSMAX_HEADER_LIST_SIZE- 프레임 순서와 기본값 조합
이 값들과 순서도 구현체마다 고유합니다. Akamai가 오랫동안 이걸 "HTTP/2 fingerprint"로 활용해왔고, 최근에는 Cloudflare를 비롯한 다른 벤더들도 TLS 지문과 HTTP/2 지문을 함께 봅니다. 즉 TLS만 속여도 HTTP/2 레이어에서 한 번 더 걸릴 수 있다는 뜻입니다.
정리하자면, 현대 봇 차단은 한 지점이 아니라 여러 레이어의 지문을 동시에 교차 검증하는 구조라는 것입니다. 쿠키는 그중 가장 바깥쪽 한 겹일 뿐이었습니다.
3. 쿠키가 지문에 "바인딩"된다는 것
여기서부터가 이 글의 핵심입니다. Cloudflare의 cf_clearance, AWS WAF의 토큰, 그 외 유사한 봇 차단 쿠키들은 발급 시점의 지문과 묶여서 저장됩니다. 즉 쿠키 자체가 "누구에게 발급된 것" 이라는 메타데이터를 품고 있는 셈입니다.
요청이 들어오면 서버는 이런 순서로 검증합니다.
- TLS ClientHello의 JA3/JA4 지문을 계산
- HTTP/2 SETTINGS 지문을 계산
- 쿠키에 묶여 있는 원래 지문과 비교
- 일치하지 않으면 쿠키 자체를 무효 처리하고 챌린지로 돌려보냄
그 결과, 쿠키를 통째로 복사해 다른 클라이언트에 붙여도 TLS 레이어에서 이미 지문이 달라져버리기 때문에 "쿠키는 맞는데 너는 아니야" 라는 판정이 나오게 됩니다. 이게 브라우저에서는 200 OK가 나오던 요청이 curl에서는 403으로 돌아오는 기술적 이유입니다.
이런 바인딩은 완벽하지 않습니다. curl-impersonate, tls-client 같은 프로젝트는 실제로 Chrome의 ClientHello를 그대로 흉내내는 방향으로 만들어졌습니다. 하지만 이런 도구를 상용 서비스 대상으로 쓰는 것은 대부분의 경우 이용약관 위반이고, 법적/계정 리스크를 감수해야 합니다. 방어자 입장에서 중요한 건, 이 바인딩이 일반적인 자동화 도구 90% 이상을 무력화시킨다는 점입니다.
4. 구현체별 지문 차이 요약
개념이 조금 추상적일 수 있어서, 실제 구현체별로 기본 TLS 지문이 얼마나 다른지를 표로 정리해 보았습니다.
| 클라이언트 | 기본 TLS 지문 특성 | 봇 차단 서비스에서 "봇으로 보이는가" |
|---|---|---|
| Chrome / Firefox | OS·버전별 고유, 자주 업데이트 | 주로 통과 |
| Playwright + 번들 Chromium | 실 Chrome과 유사하나 미세 차이 | 경계선 (설정 따라) |
| Playwright + 실 Chrome 채널 | 실 Chrome과 동일 | 주로 통과 |
Node undici / 기본 fetch |
Node 고유 지문, 매우 식별 쉬움 | 거의 차단 |
Python requests |
OpenSSL 버전 지문, 잘 알려짐 | 거의 차단 |
| Go net/http | Go 표준 라이브러리 지문 | 거의 차단 |
curl (기본) |
libcurl 지문 | 거의 차단 |
흥미로운 지점은 Playwright 같은 자동화 도구도 "어떻게 띄우느냐" 에 따라 결과가 갈린다는 점이었습니다. 번들 Chromium을 쓰느냐 실제 Chrome 바이너리를 쓰느냐, headless 모드가 구버전이냐 신버전이냐에 따라 TLS/HTTP2 지문이 다르게 나오는 경우가 있습니다. "자동화 = 무조건 차단" 이라는 도식보다는, 얼마나 실제 사용자와 같은 스택을 썼느냐의 연속선으로 이해하는 편이 정확하다는 생각이 들었습니다.
5. 이걸 내 서비스에 직접 넣을 수 있을까
원리를 공부하다 보니 자연스럽게 드는 생각이 있었습니다. "그럼 내 서비스 앞단에도 이걸 붙이고 싶다." 그런데 이 지점에서 현실의 벽이 등장합니다.
- TLS ClientHello를 파싱해 JA3/JA4 해시를 계산하려면 TLS 종단 이전 지점에서 볼 수 있어야 합니다. 대부분의 애플리케이션 서버에서는 이미 TLS가 풀린 뒤의 요청만 받기 때문에 지문 자체를 관측할 수 없습니다.
- JA3/JA4 해시를 계산했다 해도, "어떤 지문이 봇이고 어떤 지문이 정상인가"에 대한 실시간으로 업데이트되는 데이터베이스가 필요합니다. 이게 보안 회사의 핵심 자산입니다.
- 정상 사용자를 봇으로 오판하는 오탐(false positive)을 낮추려면 트래픽 통계와 A/B 튜닝이 필요한데, 이건 풀타임 보안팀이 붙어야 가능한 영역입니다.
- 지문-쿠키 바인딩을 유지하려면 쿠키 발급 단계에서 지문을 함께 저장하고, 매 요청마다 검증하는 상태 저장소가 필요합니다.
결국 이건 제품 회사 하나가 직접 만들 영역이 아니라는 결론에 이르게 됩니다. 대부분의 SaaS/개인 서비스가 Cloudflare, AWS WAF, Akamai 같은 관리형 봇 차단을 붙이는 이유가 여기에 있습니다. 값을 내는 대신, "전 세계 트래픽에서 학습한 지문 DB + TLS 이전 레이어에서의 검증 + 실시간 튜닝" 전체를 구독 상품으로 사는 구조입니다.
개발자로서 이 레이어의 존재를 의식하고 있으면, 보안 서비스를 고를 때 어떤 기능을 체크해야 하는지에 대한 감각이 생긴다는 생각이 들었습니다. "Cloudflare가 알아서 막아주네" 에서 그치지 않고, "JA3/JA4 기반 Bot Management까지 쓰고 있나? edge 가격 플랜에 포함되어 있나?" 까지 볼 수 있게 되는 것입니다.
정리
세션은 생각보다 여러 층으로 이루어져 있다는 것을 다시 확인했습니다. 브라우저에서 잘 되던 요청이 스크립트에서는 안 될 때, 그 사이에는 쿠키뿐 아니라 TLS ClientHello 지문, HTTP/2 SETTINGS 지문, 그리고 그 지문에 묶여 있는 쿠키 바인딩이 조용히 작동하고 있습니다.
이 구조의 가장 인상적인 점은 세션을 훔치는 공격자에게도 꽤 높은 벽이 된다는 점이었습니다. 쿠키 탈취만으로는 타인의 계정을 통째로 이어쓸 수 없게 만드는 레이어가, 개별 서비스가 의식하지 않아도 CDN 구독만으로 따라오는 것입니다.
그리고 이 글을 쓰면서 스스로에게 확인한 결론 하나가 있습니다. 이런 정밀한 방어벽을 직접 만드는 대신 관리형 서비스에 맡기는 결정은, 게으름이 아니라 합리적인 분업이라는 것입니다. 내가 만들 제품이 가야 할 핵심이 아닌 영역은, 이미 그 길을 깊게 파 놓은 회사의 기술을 빌려 쓰는 편이 대부분 옳다는 생각이 들었습니다.
관련 글
쿠키 없이 24시간 고유 방문자를 추정하는 방법 (3편)
쿠키도 localStorage도 쓰지 않고 하루 안에서만 같은 독자를 알아보는 방법을 정리했습니다. Plausible의 daily salt 해시를 HMAC 기반 deterministic 방식으로 재구성하면서, 서버 재시작 안전성과 cross-day 추적 불가능성을 어떻게 확보했는지 기록했습니다.
같은 Chromium 엔진, 다른 자동화 — Playwright / MCP / 브라우저 확장 해부
헤드리스 Playwright, Playwright MCP, Claude-in-Chrome 확장이 모두 같은 Chromium 엔진을 쓰는데 왜 쓰임새가 다른지, 레이어와 쿠키 수명 주기 관점에서 정리했습니다.
프로토타입 페이지를 숨기고 싶을 때, HTTP Basic Authentication
아직 공개하기 어려운 프로토타입 페이지를 쉽고 안전하게 숨기는 방법을 찾고 있나요? Next.js 미들웨어로 HTTP Basic 인증을 구현해 외부 접근과 검색 엔진 노출을 효과적으로 차단하세요.