거래 범위 지정 PASETO: 대상(Audience) 제한 토큰에 대해 내가 잘못 알고 있었던 세 가지
요약
Model Context Protocol(MCP) 명세서의 권한 부여 규정에 따라, MCP 서버가 대상(Audience) 제한 토큰을 검증해야 하는 의무와 PASETO를 활용한 구현 경험을 다룹니다. 토큰이 특정 트랜잭션에만 유효하도록 범위를 지정하는 설계 원칙과 구현 시 주의사항을 설명합니다.
핵심 포인트
- MCP 서버는 자신을 대상으로 발급된 토큰인지 반드시 검증해야 함
- 토큰은 특정 리소스나 트랜잭션에만 유효하도록 범위(Scope)가 지정되어야 함
- PASETO v4.public을 사용하여 헤더 조작 공격 위험을 방지함
- 자격 증명은 플랫폼 사용자 확인이 아닌 특정 거래의 유효성 확인에 집중해야 함
Model Context Protocol의 권한 부여(authorization) 섹션은 매우 직설적으로 명시하고 있습니다: 서버는 자신을 위해 발급되지 않은 모든 토큰을 거부해야 합니다. 이는 현재 버전과 함께 도입된 것이 아니라 2025-06-18 개정 이후부터 규범적(normative)으로 적용되어 온 사항입니다. 이 점을 분명히 말할 가치가 있는 이유는, 흥미로운 질문의 핵심이 이 규칙이 새로운지 여부가 아니었기 때문입니다. 저는 모든 자격 증명(credential)이 정확히 하나의 양자 간 거래(two-party transaction)에 범위가 지정(scoped)되도록 하는 시스템을 운영해 왔으며, 이에 따라 제가 아직 해결하지 못한 세 번째 문제를 포함하여 실제로 어떤 작업이 필요했는지 기록하고자 합니다.
명세서 문구
제가 이 글을 쓰는 시점의 최신 개정판인 2026-07-28 권한 부여 명세서에서 발췌한 세 개의 규범적 문장입니다. 이 문장들은 하나의 연속된 단락이 아니라 토큰 처리(Token Handling) 섹션 전반에 걸쳐 있습니다:
MCP 서버는 RFC 8707 섹션 2에 따라 액세스 토큰(access tokens)이 의도된 대상(audience)으로서 자신을 위해 구체적으로 발급되었는지 반드시 검증해야 합니다. [...] MCP 서버는 자신의 리소스와 함께 사용하기에 유효한 토큰만을 수락해야 합니다. MCP 서버는 다른 어떤 토큰도 수락하거나 전달해서는 안 됩니다.
가운데 문장이 변경된 부분입니다. 2025-06-18 버전에서는 "권한 부여 서버(Authorization servers)는 토큰만을 수락해야 한다"로 시작하여, 토큰을 수신하는 당사자에 대한 구절에서 토큰을 발급하는 당사자에게 의무를 부여했습니다. 현재의 개정판은 그 의무를 MCP 서버로 지목합니다. 첫 번째와 세 번째 문장은 2025-06-18 버전에서 변경되지 않았습니다.
이는 명세서상의 세 문장이지만, 구현(implementation) 측면에서는 작업량의 약 4분의 1에 해당합니다. 이 포스트의 나머지 부분은 나머지 4분의 3에 대한 내용입니다.
토큰의 용도
시스템은 협상하는 동안 가명(pseudonymous) 상태를 유지하는 두 에이전트 사이에서 작업을 중개합니다. 거래가 체결되면 각 측은 상대방의 콜백 엔드포인트(callback endpoint)와 자격 증명(credential)을 받으며, 중개자는 데이터 경로(data path)에서 벗어납니다. 수신 측에서 이 자격 증명의 역할은 단 하나입니다. 이 호출자가 거래 1a2b3c4d에 속하는지 여부를 확인하는 것입니다. 플랫폼의 유효한 사용자인지, 혹은 유료 고객인지를 확인하는 것이 아닙니다. 오직 '이 거래'에 대해서만 확인합니다.
이는 MCP 요구 사항과 동일한 형태입니다. 하나의 리소스(resource)를 위해 발행된 토큰은 다른 모든 리소스에서는 가치가 없어야 합니다. 유일한 차이점은 제 리소스가 서버가 아닌 트랜잭션(transaction)이라는 점입니다.
쉬운 부분: v4.public과 그 외에는 없음
서명(Signing)은 한 줄이면 충분하며, 버전은 토큰에서 읽어오는 것이 아니라 컴파일 타임(compile time)에 선택됩니다:
// V4Sign은 일반 문자열을 반환하며, 내부 인코딩 오류가 발생할 때만 패닉(panic)을 일으킵니다.
signed := tok.V4Sign(kp.PrivateKey, nil)
v4.local도 없고, JWT도 없으며, 알고리즘 협상(algorithm negotiation)도 없습니다. 헤더 자체가 없기 때문에 공격자가 재작성할 수 있는 alg 헤더도 존재하지 않습니다. 검증자가 RSA 공개 키를 HMAC 비밀키로 취급하도록 유도하거나 alg: none을 허용하도록 속이는 알고리즘 혼동(algorithm-confusion) 버그 계열은, 그러한 버그가 존재하기 위해 협상 가능한 알고리즘 필드가 반드시 필요합니다. PASETO의 기여는 더 나은 암호학(cryptography)이 아닙니다. 바로 협상 자체를 삭제한 것입니다.
이것이 어려운 결정이었다고 거짓말하지는 않겠습니다. 10분 정도 걸렸고, 버그의 한 카테고리를 제거했습니다. 그다음 세 가지는 훨씬 더 오래 걸렸습니다.
첫 번째: 고정된 상수는 로테이션 장애를 초래한다
첫 번째 버전은 토큰 푸터(footer)에 배포 단계 이름과 같은 고정된 문자열을 키 식별자(key identifier)로 넣었습니다. 파싱도 잘 되었고, 라운드 트립(round-trip)도 잘 되었으며, 테스트도 통과했습니다.
이는 또한 서명 키(signing key)를 교체할 때 즉각적인 전환(hard cutover)이 일어나야 함을 의미했습니다. 이 자격 증명(credentials)은 7일 동안 유효합니다. 화요일에 키를 교체하면, 지난 7일 동안 발행된 모든 자격 증명은 이제 잘못된 키로 연결되는 kid를 포함하게 됩니다. 두 키가 동일한 이름을 사용하기 때문에, 이전 키와 새 키를 모두 선택할 수 있는 유예 기간(window)이 존재하지 않습니다.
해결책은 키에 이름을 붙이는 것을 중단하고 지문(fingerprinting)을 사용하는 것입니다:
const kidHexLen = 16
func DeriveKid(pub paseto.V4AsymmetricPublicKey) string {
...
따라서 발행(mint) 경로와 로더(loader)가 서로 불일치하지 않도록, 로더는 호출자로부터 kid를 전달받는 대신 직접 kid를 유도(derive)합니다:
func LoadKeyPair(privKeyHex string) (KeyPair, error) {
sk, err := paseto.NewV4AsymmetricSecretKeyFromHex(privKeyHex)
if err != nil {
...
이제 kid는 키에 붙은 라벨(label)이 아니라 키의 속성(property)이 됩니다. 두 개의 키가 동시에 활성화될 수 있으며, 검증기(verifier)는 kid를 통해 키를 선택하므로, 키 교체는 서비스 중단(outage)이 아닌 중첩된 유예 기간(overlap window)이 됩니다. 이전 키는 해당 키로 서명된 마지막 자격 증명이 만료될 때까지(최대 7일로 제한됨) 로드된 상태로 유지되다가 사라집니다.
네, 16자리의 16진수(hex) 문자는 잘린 해시(truncated hash)입니다. 이는 선택자(selector)일 뿐, 보안 경계(security boundary)가 아닙니다. kid는 검증기에게 어떤 키를 시도할지 알려줄 뿐이며, 토큰의 진위 여부는 서명(signature)이 결정합니다. 만약 누군가가 내 키의 SHA-256 해시 중 앞 8바이트를 공유하는 두 번째 공개 키를 찾아낸다 하더라도, 그들은 내 키를 선택하는 데는 성공할 수 있지만 여전히 그것으로 서명할 수는 없습니다. 충돌하는 kid는 검증에 실패할 뿐, 위조(forge)를 가능하게 하지는 않습니다.
kid를 유도하는 것은 절반의 과정일 뿐입니다. 나머지 절반은 이를 다시 읽어들이는 것이며, 이는 서명을 확인하기 전에 토큰을 반드시 건드려야 하는 유일한 지점입니다:
// ExtractKid는 서명을 검증하지 않고 토큰 푸터(footer)에서 키 ID를 읽습니다.
// 이는 의도적으로 안전하지 않게 설계되었으며, 전체 Verify 호출 전
// 키 선택을 위해서만 사용됩니다.
...
go-paseto는 해당 메서드를 UnsafeParseFooter로 호출하며, 그 이름은 정직합니다. 그 시점에서는 아무것도 인증되지 않았기 때문입니다. 그럼에도 이것이 안전한 이유는 푸터(footer)가 서명(signature)에 의해 보호되기 때문인데, PASETO가 이를 사전 인증 인코딩(pre-authentication encoding)에 포함시키기 때문입니다. 변조된 kid(key ID)는 잘못된 키를 가리키는 것 외에는 아무것도 할 수 없으며, 잘못된 키는 검증(verification)에 실패합니다. kid는 어떤 키를 시도할지에 대한 힌트일 뿐, 결코 믿어야 할 주장(claim)이 아닙니다.
이 차이점은 명확히 짚고 넘어갈 가치가 있습니다. 왜냐하면 "토큰을 사용하여 키를 선택한 다음, 그 키로 토큰을 검증한다"는 문장은 선택자(selector)가 인증된 봉투(authenticated envelope) 내부에 존재한다는 사실을 알아차리기 전까지는 순환 논리처럼 읽히기 때문입니다. 이를 일반화한 규칙은 다음과 같습니다: 무언가를 어떻게 (how) 확인할지 결정하기 위해 인증되지 않은 입력을 읽을 수는 있지만, 그것이 통과했는지 **여부 (whether)**를 결정하기 위해 읽어서는 안 됩니다.
일반화할 수 있는 부분: 로테이션(rotation) 중에도 유지되어야 하는 식별자는 그것이 식별하는 대상으로부터 유도되어야 합니다. 식별자를 독립적으로 선택하면 두 가지가 서로 어긋날 수 있으며, 이러한 어긋남은 압박 속에서 키를 로테이션하는 바로 그 순간에 표면화됩니다.
둘: 에러 메시지를 읽어서 에러를 분류하지 마세요
호출자는 검증 실패 시 두 가지 서로 다른 답변을 필요로 합니다. ErrExpiredToken은 일상적인 이벤트이며 일상적인 해결책이 있습니다. ErrInvalidToken은 누군가 내가 서명하지 않은 자격 증명(credential)을 제시했음을 의미합니다. 이들은 서로 다른 곳에 분류되어야 하며, 그중 하나는 담당자에게 페이지(page)를 보내야 합니다.
go-paseto는 규칙을 통해 시간적 주장(temporal claims)을 검증하므로, 명백한 구현 방식은 만료되지 않았다는 규칙을 켠 상태로 파싱하고 반환되는 에러를 검사하는 것입니다. 실제로는 다음과 같은 의미가 됩니다:
// 내가 처음에 작성했다가 삭제한 버전입니다.
if strings.Contains(err.Error(), "expired") {
return ErrExpiredToken
...
그것은 작동하지만, 다른 사람의 저장소(repository)에 있는 문자열에 결합되어 있으며, 그들은 해당 문자열을 변경하지 않겠다고 약속한 적이 없습니다. 규칙 오류의 문구를 변경하는 마이너 버전 업데이트(minor version bump)는 빌드를 실패시키지도, 타입 체크(type check)를 실패시키지도 않으며, 동일한 라이브러리를 통해 만료된 토큰을 생성하는 그 어떤 테스트도 실패시키지 않습니다. 이는 모든 만료된 자격 증명(credential)을 위조(forgery)로 조용히 재분류합니다. 눈에 띄게 깨지는 것은 아무것도 없습니다. 그저 일상적인 이벤트가 공격을 위해 예약된 경로로 들어오기 시작할 뿐이며, 이것이 실제 공격이 무시되는 방식입니다.
배포되는 버전은 해당 규칙을 끄고 직접 비교를 수행합니다:
// 내장된 NotExpired 규칙 없이 파싱하여, 서명은 유효하지만 만료된
// 토큰이 성공적으로 파싱되도록 하고, 아래에서 결정론적으로(deterministically) 분류할 수 있게 합니다.
parser := paseto.NewParserWithoutExpiryCheck()
...
언제나 서명(Signature)이 우선입니다. 위의 키드(kid) 조회는 토큰에 더 일찍 접근하는 유일한 부분이며, 아무것도 결정하지 않습니다. 암호학적으로 유효한 토큰만이 클레임(claims)을 읽을 수 있으므로, expired는 내가 확실히 서명한 토큰에 대한 진술이 됩니다. 그다음은 내가 소유한 코드 내에서 문자열 대신 값을 비교하는 시간적 검사(temporal checks)입니다.
중간에 있는 명시적인 not-before 검사는 만약 제가 생성자(constructor)의 이름을 믿었다면 틀렸을 부분입니다. 따라서 go-paseto v1.6.0이 실제로 무엇을 하는지 정확하게 짚고 넘어갈 가치가 있습니다. NewParser()는 정확히 하나의 규칙인 NotExpired()를 사전 로드하며, 해당 규칙은 exp만을 읽고 그 외에는 읽지 않습니다. nbf는 자체 규칙인 NotBeforeNbf()를 가지고 있으며, 기본적으로 로드되지 않습니다. ValidAt()은 iat, nbf, exp를 함께 확인하며, 이는 오직 NewParserForValidNow()를 통해서만 얻을 수 있습니다.
따라서 그 검증은 제가 무언가를 꺼서 보완하는 것이 아니었습니다. NewParserWithoutExpiryCheck는 제가 이미 대체하고 있던 단 하나의 규칙을 제거했습니다. nbf 간극은 제가 무엇인가를 건드리기 전부터 기본 파서(default parser)에서 계속 열려 있었습니다. 저는 생성자(constructor) 이름 대신 규칙 목록을 읽다가 그것을 발견했을 뿐입니다. NewParser라는 이름의 파서는 합리적인 기본값(sensible defaults)을 제공할 것처럼 들립니다. 하지만 그것은 단일 요소 슬라이스(one-element slice)이며, 그 요소는 당신이 가정했던 것이 아닙니다.
세 번째: 검증(verification) 측면을 구현하지 않았습니다
이것은 제가 아직 해결책을 찾지 못한 문제이며, 가장 중요한 문제이기에 솔직하게 말씀드리겠습니다. 현재 코드 상태에 대한 세 가지 사실입니다.
Verify에는 프로덕션 호출자가 없습니다. 해당 기능은 존재하고 테스트도 완료되었지만, 테스트 파일 이외의 호출자를 저장소(repository)에서 검색해 보면 아무것도 나오지 않습니다. 프로덕션에서는 거래 확정 시 Mint를 호출하고, 시작 시 LoadKeyPair를 호출합니다. 서비스 경로(serving path) 상에서 무언가를 검증하는 것은 아무것도 없습니다.
어떤 엔드포인트도 공개 키(public key)를 게시하지 않습니다. API 어디에도 kid-to-key 문서가 없습니다. 자격 증명(credential)을 수신하고 푸터(footer)를 읽는 상대방은 자신이 가져올 수 있는 것이 아무것도 없는 키 식별자(key identifier)를 받게 됩니다. 오늘날 자격 증명을 확인하는 유일한 방법은 저에게 물어보는 것뿐이며, 이는 자격 증명이 제가 증명한다고 말하는 것을 증명한다는 의미입니다.
양측 모두 동일한 토큰을 받습니다. 확정(Finalize) 단계에서 한 번 민팅(mint)되어 양측에 동일한 문자열을 전달합니다. 이 토큰은 거래 ID와 두 개의 가명 참조(pseudonymous references)를 모두 포함하고 있으므로, 해당 거래의 구성원임을 증명합니다. 하지만 어떤 구성원이 이를 보유하고 있는지는 구분할 수 없습니다.
이것들을 종합하여 제가 만든 것에 대해 정직하게 설명하자면 다음과 같습니다: 올바르게 민팅되었고, 올바른 범위(scope)를 가지며, 올바르게 교체(rotate) 가능한 자격 증명이지만, 아무도 독립적으로 검증할 수 없으며 소지자를 식별할 수도 없습니다. 민팅 부분은 맞습니다. 민팅을 보안 속성(security property)으로 변환하는 부분이 누락되었습니다.
이것이 바로 MCP 명세(spec)가 대상(audience) 바인딩에서 멈추지 않는 정확한 이유입니다. 동일한 문서에서 발견 가능성(discovery) 자체를 하나의 요구 사항으로 규정하고 있습니다:
MCP 서버는 OAuth 2.0 Protected Resource Metadata (RFC9728)를 반드시 구현해야 합니다. MCP 클라이언트는 인증 서버 발견 (discovery)을 위해 OAuth 2.0 Protected Resource Metadata를 반드시 사용해야 합니다.
대상 (audience) 규칙은 마치 단순한 배관 작업(plumbing)처럼 들릴 수 있지만, 그렇지 않습니다. 어떤 권한 부여 기관(authority)이 리소스를 서명하는지, 그리고 어떤 키를 사용하는지 알 수 있는 공개되고 가져올 수 있는(fetchable) 방법이 없다면, "이 토큰은 당신을 위해 발급되었습니다"라는 문구는 "발급자가 이 토큰은 당신을 위해 발급되었다고 말합니다"라는 수준으로 격하됩니다. 발견 (Discovery)은 대상 바인딩 (audience binding)이 보호하고자 하는 당사자가 해당 바인딩을 검증할 수 있게 만드는 핵심 요소입니다.
해결책의 형태는 정해져 있으며, 구현이 완료되면 별도로 작성하겠습니다:
- 현재 및 이전 공개 키를 kid별로 키를 지정하여 안정적인 well-known 경로에 게시함으로써, 상대방이 중개인(broker)에게 묻지 않고도 검증할 수 있도록 합니다.
- 각 당사자당 하나의 토큰을 발행하되, 어느 쪽이 이를 보유하고 있는지 명시하는 클레임 (claim)을 포함하여, 자격 증명 (credential)이 단순히 거래 내용뿐만 아니라 소지자 (bearer)를 식별할 수 있도록 합니다.
내가 유지할 것들
- 협상 (negotiation)을 삭제하십시오. 특정 필드가 공격받을 수 있다면, 가장 저렴한 방어책은 그 필드가 존재하지 않게 하는 것입니다.
- 식별 대상으로부터 식별자를 도출하십시오. 키(Keys)와 이전 출력값이 여전히 사용 중인 동안에 회전 (rotate)하는 모든 것들이 이에 해당합니다.
- 생성자 이름이 아니라 규칙 목록을 읽으십시오. 라이브러리가 기본적으로 검증하는 것은 짧고 구체적인 목록이며, 이는 대개 이름이 암시하는 것보다 더 짧습니다.
그리고 더 일찍 했어야 할 한 가지는 다음과 같습니다: 발행자 (issuer)를 만들기 전에 검증자 (verifier)를 구축하십시오. 토큰을 발행하는 것은 만족감을 주지만 그 자체로는 보안을 제공하지 않습니다. 자격 증명을 보유할 가치가 있게 만드는 모든 요소는 그것을 검사하는 측에 존재합니다. 만족스러운 절반(발행)을 배포하고 테스트가 통과되는 것을 지켜보면서, 나머지 절반(검증)이 전혀 작성되지 않았다는 사실을 알아차리지 못하기는 매우 쉽습니다.
이 내용은 cogdepot.com에서 실행되고 있으며, 이곳에서는 거래가 확정될 때 자격 증명이 발행되고 각 당사자는 상대방을 위한 거래별 엔드포인트 (per-deal endpoint)를 받게 됩니다. 만약 위의 내용 중 논쟁하고 싶은 부분이 있다면, 저 역시 섹션 3에 대해서는 논쟁할 용의가 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기