
에이전트로부터 자격 증명을 제거했습니다. 그럼에도 에이전트는 저를 대신해 이메일과 Slack에서 활동합니다.
요약
기존 MCP 설정의 보안 취약점인 평문 API 키 저장 문제를 해결하기 위해, 자격 증명 대신 URL 기반의 인증 체인을 설계한 사례를 소개합니다. OAuth와 동적 클라이언트 등록(DCR)을 활용하여 에이전트에게 최소 권한만 부여하고 안전하게 Gmail 및 Slack 권한을 관리하는 방법을 다룹니다.
핵심 포인트
- API 키 평문 저장 및 광범위한 권한 부여 문제 해결
- OAuth와 DCR을 활용한 관리형 MCP 엔드포인트 설계
- 에이전트에게 프로바이더 토큰 대신 범위가 지정된(scoped) 토큰 제공
- 사용자 승인 프로세스를 통한 안전한 도구 호출 및 권한 관리
일반적인 MCP (Model Context Protocol) 설정은 다음과 같은 방식으로 인증 (auth)을 처리합니다: API 키를 생성하고, 이를 mcp.json이나 .env 파일에 붙여넣은 뒤, 클라이언트를 재시작합니다. 작동은 합니다. 하지만 이제 그 키는 에이전트를 실행하는 모든 머신에 평문 (plaintext)으로 저장됩니다. 또한 이 키는 종종 하나의 광범위하고 고정된 권한 세트를 가집니다. 해당 파일을 읽는 모든 에이전트는 동일한 권한 세트를 갖게 됩니다. 그리고 에이전트 하나가 오작동하면, 해결 방법은 모든 곳에서 공유 키를 교체(rotate)하는 것뿐입니다.
실제 인증을 추가한 후에 발생하는 두 번째 실패 사례는 다음과 같습니다: 에이전트가 도구 (tool)를 호출했을 때 아무런 설명 없는 403 에러를 받는 경우입니다. 사용자는 무엇을 승인해야 할지 모르고, 에이전트는 무엇을 요청해야 할지 모릅니다. 결국 누군가는 서버 로그를 읽어야 하는 상황에 처하게 됩니다.
저는 프로덕션 환경을 위한 다중 사용자 AI 시스템을 구축합니다. 제 에이전트들은 Claude Code와 같은 외부 에이전트를 포함하여, 매일 사용자의 Gmail 및 Slack 계정에서 활동합니다. 이 중 어떤 에이전트도 프로바이더 토큰 (provider token)을 받지 않습니다. 이것이 그 작업을 가능하게 하는 인증 체인이며, 여기에는 설계 과정에서 가장 많은 고민이 들어간 부분, 즉 호출 시점에 동의가 누락되었을 때 어떤 일이 발생하는지에 대한 내용이 포함되어 있습니다.
키 대신 URL 사용
외부 에이전트는 Gmail이나 Slack 자격 증명을 받지 않습니다. 대신 제 플랫폼이 노출하는 관리형 MCP 엔드포인트 (endpoint)인 URL을 받습니다. 저는 이 엔드포인트를 '문 (door)'이라고 부르며, 하위 인터페이스에서도 동일하게 부릅니다.
Claude Code는 OAuth 클라이언트로서 이 문에 연결합니다. 동적 클라이언트 등록 (Dynamic Client Registration, DCR)은 구성된 리다이렉트 허용 목록 (redirect allowlist)에 따라 클라이언트 ID를 등록합니다. 사용자는 로그인하고 이 연결에 부여될 수 있는 최대 권한을 승인합니다. OAuth 교환은 프로바이더 토큰이 아니라, 해당 클라이언트 및 권한에 결합된 범위가 지정된 (scoped) KDCube 베어러 (bearer) 토큰을 반환합니다.
승인 화면은 또한 요청된 기능들을 해당 계정들과 연결하여 해결합니다. 만약 필수적인 프로바이더가 연결되어 있지 않다면, 연결 링크와 함께 해당 프로바이더의 이름이 표시됩니다. 연결 단계에서는 무엇을 추가해야 하는지 이미 알려줍니다. 이는 이 포스트의 뒷부분에서 다룰 호출 시점의 거부 상황과 동일한 형태를 띠고 있으며, 이를 앞 단계로 옮겨 놓은 것입니다.

승인하면 연결이 카드가 됩니다.
이 카드가 전체 거버넌스 관계입니다. 아무도 Claude를 수동으로 등록하지 않았고, 제공자 토큰을 붙여넣지도 않았습니다. 체크리스트는 상한선입니다. 이 앱에 부여될 수 있는 최대치입니다.
그 어휘(vocabulary)는 영역(realm)을 따릅니다. Slack은 단일 제공자 영역이므로 slack:search와 같은 Slack 클레임을 사용합니다. 메일은 여러 제공자에 걸쳐 있을 수 있으므로, 이 문(door)은 mail:read와 mail:send를 사용하고, 이후 계정 브로커가 선택된 계정에 대해 gmail:read 및 gmail:send와 같은 제공자별 클레임으로 이를 해결합니다.
바인딩이 결정하며, 계정이 아닙니다
상한선은 접근 권한 그 자체가 아닙니다. 이는 동일한 카드에 있는 ACCOUNTS 섹션에서 결정하는 것입니다. 즉, 어떤 계정과 각 계정에 대한 어떤 권한들입니다.
두 개의 Google 계정 각각이 자체적인 gmail:read / gmail:send 체크를 가지고 있습니다. 사용자는 이 에이전트를 한 계정에서는 읽기 전용으로 바인딩하고 다른 계정에서는 읽기+쓰기로 바인딩할 수 있습니다. 만약 어떤 계정이 메일 발송은 할 수 있지만, 에이전트가 그 계정에 대해 발송하도록 바인딩되지 않았다면, 호출은 거부됩니다. 또 다른 계정이 이를 충족시킬 수 있을 때, 거부는 조용히 하나를 선택하는 대신 레이블이 지정된 후보 계정으로 반환됩니다.
그리고 기본값은 닫혀 있습니다. 건드리지 않았다는 것은 아무것도 아니라는 뜻입니다. 제공자 측에 체크가 없는 에이전트는 그 계정들이 얼마나 능력이 있든, 연결 상한선이 무엇을 말하든 어떤 접근 권한도 없습니다. 처음 연결할 때는 미리 체크된 것이 없으며 - 선택기(picker)는 계정을 보여주고 사용자가 결정합니다.
카드 상의 모든 항목은 수정 가능한 상태로 유지됩니다: 축소(narrow), 확장(extend), 취소(revoke). 취소(Revocation)는 다음 호출 시 적용됩니다. 교체(rotate)를 위해 모든 에이전트에 복사되는 프로바이더 자격 증명(provider credential)은 존재하지 않습니다. 클라이언트의 범위가 지정된(scoped) KDCube 베어러(bearer) 토큰은 자체적인 만료 및 취소 메커니즘을 가집니다.
권한이 부여된 요청은 인자(arguments)에 프로바이더 토큰을 포함하는 대신, 해결된 사용자(resolved user), 호출자 신원(caller identity), 그리고 필요한 클레임(claim)과 함께 신뢰할 수 있는 도구 코드(trusted tool code)에 도달합니다. 신뢰 경계(trusted boundary)에서 계정 브로커(account broker)는 해당 프로바이더 호출을 위해 사용자의 연결된 자격 증명을 해결(resolve)하고 갱신(refresh)합니다. 프로바이더 어댑터(provider adapter)는 호출을 위해 이를 사용하며, 이를 영구적으로 저장(persist)하지 않습니다.
프로바이더 자격 증명은 모델 컨텍스트(model context), 프롬프트(prompt), 생성된 코드(generated code), 도구 결과(tool result), 또는 위임된 클라이언트의 베어러(delegated client's bearer)에 절대 나타나지 않습니다. 울타리(fence)의 해당 측면에는 한 번도 전달받지 못한 자격 증명을 공개할 수 있는 요소가 아무것도 없습니다.
또한 연결 시점에 참이었던 사실에 의존하지 않습니다. 모든 호출은 두 개의 관문을 다시 통과합니다: 이 호출자가 이 작업에 대한 권한(grant)을 보유하고 있는가, 그리고 연결된 계정이 이 호출자와 결합되어 해당 클레임을 승인하는가입니다. 연결 시점의 승인은 상한선(ceiling)일 뿐입니다. 호출당 체크(per-call checks)가 권한(authority)을 가집니다.
두 가지 레시피가 이 엔드 투 엔드(end to end) 과정을 설명합니다: 신뢰 경계에서 도구가 자격 증명을 해결하는 방법, 그리고 전체 체인, 세 가지 진입 방식.
거부는 스스로의 해결책을 명시합니다
에이전트는 성장합니다. 도구가 지난달에는 필요하지 않았던 클레임(claim)을 필요로 하기 시작합니다. 사용자가 두 번째 Slack 워크스페이스를 연결합니다. 대화 도중에 특정 작업이 처음으로 호출됩니다. 토큰 기반의 세상에서는 이 각각의 상황이 정체불명의 실패(mystery failure)로 나타납니다.
여기서 거부(denial)는 단순한 이유가 아닙니다. 그것은 작은 복구 프로토콜 (recovery protocol)이며, 각 게이트 (gate)는 서로 다른 복구 방법을 반환합니다.
Gate 1: 호출자에게 작업 권한 (operation grant)이 부족함. 서비스는 정확한 작업과 누락된 클레임 (claims)을 명시한 다음, 올바른 권한 확장 (grant-extension) 경로를 안내합니다:
[
Gate 2: 호출자는 허용되었으나, 연결된 계정 (connected-account) 측에서 작업을 수행할 수 없음. Gate 1을 통과하면, 이 계약 (contract)은 계정 측의 조건과 이를 해결할 수 있는 동작을 명시합니다:
[
retry_hint는 제가 가장 좋아하는 필드입니다. 이 에러는 사용자가 조치를 취한 후 호출자가 작업을 재시도 (retry)할 수 있는지 여부를 알려줍니다. 여러 계정이 일치할 때, 플랫폼은 결코 조용히 하나를 선택하지 않습니다. account_required는 라벨이 지정된 후보들을 반환하며, 호출자는 account_id와 함께 재시도합니다.
KDCube 채팅 컴포넌트는 거부 상황을 Connection Hub에서 일치하는 요청을 여는 실행 가능한 동의 배너 (actionable consent banner)로 렌더링할 수 있습니다. 외부 MCP 클라이언트는 도구 응답 (tool response)에서 동일한 구조화된 복구 경로를 받습니다.
[
동의는 작업에 필요할 때, 필요한 범위 내에서 나타납니다. 개방형 에이전트 턴 (open-ended agent turn)에서는 추론 (reasoning)이 시작된 후에야 필요한 도구와 계정이 나타날 수 있습니다.
하나의 리스트, 모든 에이전트
저의 사내 에이전트들은 모두 동일한 카드를 받습니다. 그중 하나는 다음과 같습니다:
OAuth 인증(OAuthed)을 거친 외부 앱과 제 런타임(runtime) 내에 호스팅된 에이전트는 동일한 종류의 시민입니다: 클라이언트 ID(client identity), 권한 부여(grant), 계정별 바인딩(per-account binding), 그리고 동일한 수정 및 취소(edit and revoke) 과정을 거칩니다. 계정을 연결하는 것 자체만으로는 어떤 에이전트도 권한을 부여하지 않습니다. 각 에이전트의 액세스(access)는 고유한 권한 부여(grant)입니다. 여기서 계정은 동일한 두 개일지라도, 바인딩(binding)은 다릅니다: 연결된 앱이 보낼 수 있는 계정과, 이 에이전트가 읽기만 할 수 있는 계정이 다른 식입니다. "이 에이전트가 지금 당장 무엇을 할 수 있는가"에 대한 답은 항상 명확하게 드러납니다.
이러한 형태의 이유
2026-07-28에 발표된 MCP 사양(specification)은 프로토콜에서 관리되는 세션(protocol-managed sessions)을 제거했습니다. 연속성이 필요한 서버는 이제 명시적인 핸들(handle)을 발행하고, 다시 돌아왔을 때 이를 검증합니다. Akamai의 평가는 직설적입니다: 프로토콜은 기반을 개선했지만, 이제 중요한 상태(state)와 권한 부여 경계(authorization boundaries)는 각 서버가 어떻게 구축되느냐에 달려 있습니다.
KDCube의 관리형 엔드포인트(managed endpoint)는 이전 클라이언트들에게 서비스를 계속 제공하면서도 MCP 2026-07-28 사양을 지원합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

