Web Bot Auth: IP 대신 서명으로 봇의 신원을 증명하기

Web Bot Auth: IP 대신 서명으로 봇의 신원을 증명하기

서버 로그에 GPTBot이 찍혀 있다고 해봅시다. 이게 정말 OpenAI가 보낸 요청일까요? 🤔

User-Agent 헤더는 그냥 문자열입니다. curl -A "GPTBot" 한 줄이면 누구나 GPTBot 행세를 할 수 있죠. 그래서 지금까지는 IP 주소로 한 번 더 확인했습니다. 봇 운영자가 자기 IP 대역을 공개하면 사이트는 요청이 그 대역에서 왔는지 대조하는 방식이었는데요.

그런데 이 방식이 요즘 급격히 무너지고 있습니다. AI 에이전트가 사용자를 대신해 웹을 돌아다니기 시작하면서부터입니다. 이런 에이전트는 서버리스 환경이나 클라우드에서 돌아가느라 IP가 매번 바뀌고, 심지어 Cloudflare Workers처럼 여러 서비스가 같은 IP 대역을 공유하기도 합니다. IP 하나로는 아무것도 증명되지 않는 상황이 된 겁니다.

Web Bot Auth는 바로 이 문제를 겨냥합니다. 봇이 자기 개인 키로 요청에 서명해서, 어디서 보내든 신원을 증명할 수 있게 만드는 방식이죠. 이 글에서는 서명을 직접 만들어보며 동작 원리를 확인하고, 누가 실제로 이 방식을 따르고 있는지 직접 조사한 결과를 정리해보겠습니다.

기존 검증 방식이 무너진 자리

Cloudflare가 이 표준을 시작하며 짚은 문제는 세 가지였습니다.

우선 User-Agent 헤더는 위조가 너무 쉽습니다. 스펙 자체가 이 약점을 인정하고 있을 정도인데요. 의도적으로 다른 봇을 흉내 내는 요청과 진짜 요청을 헤더만 봐서는 구분할 방법이 없습니다.

공개 IP 목록은 관리 부담이 큽니다. 클라우드 사업자가 IP 할당을 바꾸면 목록도 따라 바뀌어야 하고, 공유 호스팅에서는 전혀 관계없는 서비스가 같은 대역을 씁니다. 봇 운영자 입장에서는 IP를 고정하느라 인프라 선택지가 좁아지는 것도 부담이고요.

마지막으로 사이트마다 사전 공유 비밀 키를 나눠 갖는 방법도 있지만, 수천 개 사이트를 돌아다니는 크롤러가 사이트마다 별도 토큰을 관리하는 건 현실성이 없습니다.

정리하면 “이 봇이 자기가 말하는 그 봇이 맞는가”를 확인할 확실한 방법이 없었다는 게 핵심입니다. 사이트 운영자가 robots.txt로 특정 봇을 차단해도, 그 봇을 사칭한 스크래퍼는 그냥 통과하는 구조였죠.

세 개의 헤더와 하나의 경로

Web Bot Auth의 설계는 놀랄 만큼 단순합니다. 새로운 인증 서버도, 중앙 등록소도 만들지 않고 이미 웹에 있는 관례 두 가지를 재사용합니다. 바로 /.well-known/ 경로와 JWKS입니다.

봇 운영자는 자기 도메인의 /.well-known/http-message-signatures-directory 경로에 Ed25519 공개 키를 JWKS 형식으로 올려둡니다. 이걸 키 디렉터리(key directory)라고 부릅니다. 그리고 요청을 보낼 때마다 개인 키로 서명한 뒤, 세 개의 헤더를 붙입니다.

  • Signature-Agent: 내 공개 키가 어디 있는지 알려주는 URL
  • Signature-Input: 무엇을 서명했고 어떤 키를 썼는지에 대한 메타데이터
  • Signature: 실제 서명값

받는 쪽은 Signature-Agent가 가리키는 곳에서 공개 키를 가져와 서명을 검증하면 끝입니다.

flowchart TB
    accTitle: Web Bot Auth 요청 검증 흐름
    accDescr: 봇이 개인 키로 요청에 서명해 세 개의 헤더와 함께 보내면, 검증자가 Signature-Agent가 가리키는 키 디렉터리에서 공개 키를 받아 서명을 확인하고 통과나 차단을 결정한다.

    subgraph bot["봇 운영자"]
        sign["개인 키로 요청 서명"]
        dir[("키 디렉터리<br/>/.well-known/<br/>http-message-signatures-directory")]
    end

    subgraph verifier["검증자 (CDN이나 WAF)"]
        parse["세 헤더 파싱"]
        check{"서명이 유효한가?"}
        parse --> check
    end

    sign -->|"Signature<br/>Signature-Input<br/>Signature-Agent"| parse
    dir -.->|"공개 키"| check
    check -- "예" --> pass(["검증된 봇으로 통과"])
    check -- "아니요" --> block(["차단하거나 챌린지"])

기반이 되는 스펙은 IETF의 RFC 9421 HTTP Message Signatures입니다. Web Bot Auth는 여기에 “봇 신원 확인”이라는 용도에 맞는 규약을 얹은 초안이고요.

실제 키 디렉터리 들여다보기

말로만 들으면 추상적이니 실제로 열어보겠습니다. OpenAI의 디렉터리에 요청을 보내보죠.

curl https://chatgpt.com/.well-known/http-message-signatures-directory
결과
{
  "keys": [
    {
      "crv": "Ed25519",
      "kty": "OKP",
      "x": "7F_3jDlxaquwh291MiACkcS3Opq88NksyHiakzS-Y1g",
      "kid": "otMqcjr17mGyruktGvJU8oojQTSMHlVm7uO-lrcqbdg",
      "use": "sig",
      "nbf": 1735689600,
      "exp": 1787570367
    }
  ],
  "signature_agent": "https://chatgpt.com",
  "purpose": "ai"
}

익숙한 JWKS 구조에 몇 가지가 더 붙어 있는데요. signature_agent는 이 키가 어떤 신원에 속하는지 못 박아 둔 값이고, purpose는 최근 논의 중인 레지스트리 초안에서 나온 필드입니다.

purpose가 은근히 중요합니다. Browserbase의 디렉터리를 보면 값이 다릅니다.

browserbase.com의 디렉터리
{ "keys": [ /* 2개 */ ], "purpose": "rag" }

OpenAI는 ai, Browserbase는 rag입니다. “누구인가”에 더해 “무슨 용도로 긁는가”까지 선언하게 만드는 방향인데요. 사이트 운영자가 “검색 인덱싱은 환영, 학습 데이터 수집은 사절” 같은 정책을 세울 수 있게 하려는 설계입니다. 콘텐츠 신호 정책이 robots.txt에 용도별 허용 여부를 표현하려던 시도와 방향이 같습니다. 다만 이쪽은 서명으로 뒷받침된다는 차이가 있죠.

디렉터리 응답 자체에도 조건이 붙습니다. Content-Typeapplication/http-message-signatures-directory+json이어야 하고, 응답 스스로도 서명을 달고 있어야 합니다. 키를 나눠주는 창구가 위조되면 전체가 무너지니까요.

서명을 직접 만들어보기

원리는 디지털 서명과 다르지 않습니다. 다만 HTTP 요청의 어느 부분을 서명 대상으로 삼느냐에서 이 스펙만의 고민이 드러나는데요. 직접 만들어보면 감이 옵니다. Buncrypto.subtle만으로 충분합니다.

wba.ts
const b64url = (buf: ArrayBuffer) => Buffer.from(buf).toString("base64url");

// 1. Ed25519 키 쌍 생성
const { privateKey, publicKey } = (await crypto.subtle.generateKey(
  "Ed25519",
  true,
  ["sign", "verify"],
)) as CryptoKeyPair;

const jwk = await crypto.subtle.exportKey("jwk", publicKey);

// 2. JWK 썸프린트 계산 — 키를 사전순으로 정렬한 JSON을 SHA-256
const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x });
const keyid = b64url(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)),
);

여기서 계산한 썸프린트(thumbprint)가 곧 키의 식별자가 됩니다. RFC 8037이 정한 방식인데, 키 필드를 사전순으로 정렬한 JSON을 해싱하는 것뿐이라 누가 계산해도 같은 값이 나옵니다. 별도로 ID를 발급받을 필요가 없다는 뜻이죠.

다음은 서명 베이스(signature base)를 조립하는 단계입니다. 이게 RFC 9421의 실질적인 알맹이입니다.

wba.ts (이어서)
const authority = "example.com";
const signatureAgent = '"https://bot.example"';
const created = 1786000000;
const expires = created + 60;

const params =
  `("@authority" "signature-agent")` +
  `;created=${created};keyid="${keyid}";alg="ed25519"` +
  `;expires=${expires};tag="web-bot-auth"`;

const base = [
  `"@authority": ${authority}`,
  `"signature-agent": ${signatureAgent}`,
  `"@signature-params": ${params}`,
].join("\n");

const sig = await crypto.subtle.sign(
  "Ed25519",
  privateKey,
  new TextEncoder().encode(base),
);

실행하면 이런 결과가 나옵니다.

결과
keyid (썸프린트): tf77gmReFmbF6u-pypOtNyvxWspfLjZvHX4CPJxyFzo

--- 서명 베이스 ---
"@authority": example.com
"signature-agent": "https://bot.example"
"@signature-params": ("@authority" "signature-agent");created=1786000000;keyid="tf77gmReFmbF6u-pypOtNyvxWspfLjZvHX4CPJxyFzo";alg="ed25519";expires=1786000060;tag="web-bot-auth"

--- 전송할 헤더 ---
Signature-Agent: "https://bot.example"
Signature-Input: sig1=("@authority" "signature-agent");created=1786000000;keyid="tf77gmReFmbF6u-pypOtNyvxWspfLjZvHX4CPJxyFzo";alg="ed25519";expires=1786000060;tag="web-bot-auth"
Signature: sig1=:taoYOgmyfHkUHxH9gtBDh/BU34f/7gCQ84MlVuxXytW3fbg5Z21imSn1eo4rN2gfispWF9X3+01hSOfKMkv1BA==:

검증 결과: true

@authority가 왜 서명 대상에 들어갈까

("@authority" "signature-agent") 부분이 무엇을 서명할지 고른 목록입니다. @authority는 파생 구성 요소(derived component)라고 부르는데, 요청을 보내는 목적지 도메인을 가리킵니다. https://example.com/foo로 보내는 요청이면 example.com이죠.

이걸 왜 굳이 서명에 넣을까요? 서명된 요청을 통째로 가로채서 다른 사이트로 재전송하는 걸 막기 위해서입니다. 위 스크립트 마지막에 이 시나리오를 넣어봤습니다.

wba.ts (이어서)
// 목적지 도메인만 바꿔서 재전송해보면
const tampered = base.replace("example.com", "victim.com");
const okTampered = await crypto.subtle.verify(
  "Ed25519",
  publicKey,
  sig,
  new TextEncoder().encode(tampered),
);
결과
다른 도메인으로 재전송했을 때: false

서명 대상에 목적지를 못 박아 두었기 때문에, A 사이트로 향하던 서명을 B 사이트에 갖다 쓰면 검증이 깨집니다. Cloudflare가 최소한 @authority는 반드시 서명하라고 권하는 이유입니다.

그런데 같은 사이트로 같은 요청을 반복해서 재전송하는 건 어떨까요? 여기서 재미있는 부분이 나옵니다. Cloudflare는 본 적 있는 nonce를 저장해두는 방식으로 재전송 공격(replay attack)을 막지 않습니다. 대신 expires를 1분 정도로 짧게 잡으라고 권합니다. 엣지에서 전 세계 요청을 검증하면서 nonce 데이터베이스를 유지하는 건 비용이 너무 크니, 유효 시간을 좁히는 쪽으로 타협한 셈이죠.

서명할 구성 요소를 고를 때 주의할 점도 있습니다. HTTP Message Signatures 스펙은 서명 대상에 ASCII가 아닌 문자를 금지하는데, Cloudflare는 이를 우회하는 sfbs 파라미터를 지원하지 않습니다. 한글이 들어간 헤더를 서명에 넣으면 검증이 실패한다는 뜻입니다.

그래서 지금 누가 쓰고 있나

여기서부터가 실무적으로 가장 궁금한 부분일 텐데요. 문서에 “지원한다”고 쓰여 있는 것과 실제로 키를 게시하고 있는 것은 다릅니다. 확인은 어렵지 않습니다. 앞에서처럼 디렉터리를 열어보면 바로 드러나니까요.

몇 곳을 찔러보면 대체로 두 갈래로 갈립니다. 서명을 이미 붙이고 있는 쪽은 사용자를 대신해 웹을 돌아다니는 에이전트들입니다. OpenAI의 ChatGPT agent, Browserbase, Cloudflare Browser Run, Amazon Bedrock AgentCore Browser가 여기 속하고 Google도 일부 에이전트에 실험적으로 적용하고 있습니다.

반대로 대형 인덱싱 크롤러와 나머지 AI 업체들은 아직 관망하는 쪽입니다. 디렉터리를 열어보면 404가 돌아오고, GPTBot 같은 크롤러는 공식 문서에 여전히 IP 대역 공개 방식만 안내하고 있죠. 목록은 계속 바뀌니 관심 있는 봇이 있다면 그 도메인의 디렉터리를 직접 열어보는 편이 정확합니다.

Google은 특히 조심스럽습니다. “모든 Google user agent가 Web Bot Auth를 쓰는 건 아니고, 참여하는 에이전트조차 모든 요청에 서명하지는 않는다”고 명시하면서, 사이트 운영자에게 IP와 역방향 DNS(reverse DNS)를 계속 주된 검증 수단으로 쓰라고 권고합니다.

초안 단계의 흔적: kid가 서로 다르다

디렉터리를 조사하다가 흥미로운 걸 하나 발견했습니다. 게시된 각 키의 썸프린트를 직접 계산해 kid 값과 대조하는 스크립트를 돌려봤는데요.

결과
chatgpt.com 1개, purpose=ai
  썸프린트 otMqcjr17mGyruktGvJU8oojQTSMHlVm7uO-lrcqbdg  (게시된 kid: otMqcjr17mGyruktGvJU8oojQTSMHlVm7uO-lrcqbdg)  일치=true

agent.bot.goog 5개, purpose=(없음)
  썸프린트 _fmS_hfpckRfp1x7fDExtVKVaYtcu1WJX1Io5LIow24  (게시된 kid: mhxuPw)  일치=false
  썸프린트 cBovpyiY9PIQIFEfaw7w_0Wwwwx4UF2LY6w0lQURHBU  (게시된 kid: cYSMkA)  일치=false

browserbase.com 2개, purpose=rag
  썸프린트 Yh_bzcdYulhUOBmhM5a5sEgtAapL1qo-vq_9yC1udAk  (게시된 kid: Yh_bzcdYulhUOBmhM5a5sEgtAapL1qo-vq_9yC1udAk)  일치=true
  썸프린트 3_MoXQC4LiHisq40Zx-Ru0YAuzNz2F0HrXk26NKpyhg  (게시된 kid: 3_MoXQC4LiHisq40Zx-Ru0YAuzNz2F0HrXk26NKpyhg)  일치=true

OpenAI와 Browserbase는 kid가 썸프린트와 정확히 같은데, Google은 mhxuPw 같은 짧은 자체 식별자를 씁니다. 키도 5개나 게시해두고 짧은 주기로 돌려쓰고 있고요.

이게 왜 중요하냐면, 검증자를 직접 구현할 때 Signature-Inputkeyid를 JWKS의 kid 필드와 단순 비교하도록 짜기 쉽기 때문입니다. JWT 쪽에서는 kid로 키를 고르는 방식이 관례로 굳어 있어 더 그렇고요. 그렇게 만들면 OpenAI 요청은 통과하는데 Google 요청은 조용히 실패합니다. 올바른 구현은 디렉터리에서 받은 각 키의 썸프린트를 직접 계산해서 대조하는 것입니다. 아직 초안 단계인 표준을 다룰 때 겪는 전형적인 균열이죠.

서명하는 쪽보다 검증하는 쪽이 많다

채택 현황에서 눈에 띄는 건 비대칭입니다. 서명하는 주체는 손에 꼽는데, 검증을 지원하는 CDN과 WAF는 그보다 훨씬 많습니다. Cloudflare와 AWS WAF, Akamai를 비롯한 주요 벤더가 이미 검증 쪽에 붙어 있죠.

이 비대칭이 현황을 그대로 설명합니다. 검증 구현은 CDN이나 WAF 벤더 입장에서 차별화 기능이라 붙일 이유가 충분합니다. 반면 서명은 봇 운영자가 키를 관리하고 등록 절차를 밟아야 하는 비용이죠.

서명이 먼저 붙은 곳이 죄다 “사용자를 대신해 브라우징하는 에이전트”라는 점도 인센티브로 설명됩니다. 앞서 나열한 곳들이 모두 브라우저 자동화 기반이고, IP가 유동적이라 CAPTCHA에 계속 막히던 쪽입니다. 서명할 동기가 절실했던 겁니다. AWS가 이 기능의 문서 제목을 아예 “Reducing CAPTCHAs with Web Bot Auth”라고 붙인 게 솔직한 표현이죠.

반대로 대형 인덱싱 크롤러는 IP 고정이 어렵지 않고 기존 방식이 이미 잘 작동합니다. 급할 이유가 없으니 관망하는 겁니다.

사이트 운영자 입장에서 할 일

Cloudflare를 쓰고 있다면 사실 따로 할 게 없습니다. 서명 검증이 자동으로 이뤄지고, 통과한 봇은 검증된 봇으로 분류됩니다. 규칙에서는 두 가지 필드로 분기할 수 있습니다.

  • cf.bot_management.verified_bot: 검증된 봇인지 여부
  • cf.bot_management.signed_agent: Web Bot Auth로 신원을 밝힌 에이전트인지 여부 (Enterprise 플랜의 Bot Management 필요)

참고로 2026년 7월부터 Cloudflare는 signed agent와 verified bot의 구분을 없앴습니다. 대신 봇을 누가 운영하는지에 따라 Direct와 Intermediary로 나누는데, 사용자를 대신해 동작하는 에이전트가 Intermediary로 분류됩니다.

반대로 서명을 직접 검증하고 싶다면 Cloudflare 지원에 요청해 zone 단위로 이 기능을 끌 수 있습니다. 그러면 Cloudflare가 개입하지 않고, 검증된 봇 판정은 역방향 DNS 같은 다른 방식으로 넘어갑니다.

가장 중요한 실무 원칙은 이겁니다. 서명 유무만으로 허용과 차단을 가르면 안 됩니다. 서명 없는 정당한 크롤러가 아직 압도적으로 많으니까요. 서명은 “확실히 신뢰할 수 있는 신호”로 화이트리스트에 쓰고, 나머지는 기존 IP와 역방향 DNS 검증을 병행하는 게 맞습니다. Google이 자기 문서에서 직접 그렇게 권고하고 있고요.

서명이 형식에 맞는지 시험해볼 엔드포인트도 열려 있습니다.

curl https://crawltest.com/cdn-cgi/web-bot-auth

형식은 맞는데 등록되지 않은 키면 401, 등록된 키로 검증까지 성공하면 200, 형식 자체가 잘못됐으면 400이 돌아옵니다.

마치며

Web Bot Auth는 봇 신원 확인을 네트워크 위치에서 암호학적 증명으로 옮기는 시도입니다. IP는 빌린 것이고 User-Agent는 자기 신고지만, 개인 키로 만든 서명은 어디서 보내든 유효하니까요.

다만 아직 초기입니다. IETF 워킹그룹이 2026년에 차터를 받은 초안 단계고, 실제로 서명하는 주체는 아직 손에 꼽으며, Google조차 실험이라고 못 박고 있습니다. kid 처리가 구현마다 다른 것에서 보이듯 세부 사항도 아직 굳지 않았고요.

그래도 방향은 분명해 보입니다. AI 에이전트가 늘어날수록 “이 요청이 누구 것인지” 확인할 필요는 커지고, IP 목록으로는 감당이 안 되니까요. 사이트 운영자 입장에서는 지금 당장 정책을 갈아엎기보다, 검증된 봇 신호를 어떻게 활용할지 정리해두는 정도가 적당합니다. 크롤링 자체에 값을 매기는 Pay Per Crawl 같은 실험도 결국 “누가 긁는지 확실히 안다”는 전제 위에서 돌아가니, 이 표준이 그 토대를 놓는 셈이기도 합니다. 실제로 AI 에이전트 결제 표준인 x402는 Cloudflare를 거치는 크롤링 요금 정산에서 이 서명을 그대로 인증 수단으로 채택했습니다.

더 자세한 내용은 Cloudflare의 Web Bot Auth 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord