튜토리얼
MCP Server에 OAuth 2.1 인증을 추가하려면? Authorization Server에서 Access Token까지 Remote MCP API 실전
Stateless MCP가 고치는 것은 세션이지, 누가 도구를 호출할 수 있는지가 아니다. Remote MCP를 공망에 올리면 인증은 Authorization Server 발견에서 Access Token까지 가야 한다. JSON-RPC에 공유 키를 넣지 마라.
MCP 2026-07-28은 HTTP 전송의 인가를 선택으로 적는다. 자원을 보호하려면 OAuth 2.1 부분집합으로 구현해야 한다. Cursor, Claude Desktop, OpenAI Responses 원격 MCP, 자체 Agent Host는 먼저 token 없이 Remote MCP를 친다. Server는 401을 돌려주고 WWW-Authenticate에 Protected Resource Metadata를 가리킨다. 클라이언트는 Authorization Server를 찾고, 등록하고, 인가 코드 + PKCE에 resource를 붙여 Access Token을 받은 뒤, 매 홉 HTTP에 Authorization: Bearer를 넣는다. 이 글은 그 Remote MCP API 경로의 실전이다. 역할, metadata 모양, audience 검증, scope 부족 시 step-up. 원문은 MCP Authorization. 이미 MCP란 무엇인가, Stateless MCP, inputSchema와 Tool Calling을 다뤘다. 여기서는 「누가 호출해도 되는가」만 보탠다. STDIO에서 이 브라우저 흐름을 돌리지 마라. 자격 증명은 환경 변수에서 가져온다.
세 역할: MCP Server는 Token을 발급하지 않는다
첫 실수는 MCP Server를 token 발급자로 보는 것이다. 규격에서 보호된 MCP Server는 OAuth 2.1 Resource Server다. Access Token을 받고, 검증하고, audience에 묶어 소비한다. MCP Client는 OAuth 2.1 클라이언트다. 자원 소유자를 대신해 token을 받고 도구를 호출한다. Authorization Server(AS)가 로그인, 동의, 발급을 맡는다. AS는 Resource Server와 같은 기기여도 되고, 기존 IdP(Okta, Keycloak, Auth0, 자체 OIDC)여도 된다. 규격은 AS를 어떻게 만들지 정하지 않는다. MCP Server가 위치를 어떻게 알릴지만 정한다. Authorization Server Discovery를 보라.
인가는 MCP 구현에서 선택이다. HTTP로 자원을 보호하면 이 규격을 따라야 한다. STDIO는 따르면 안 된다. 공유 API 키를 tools/call의 params에 넣거나, token을 JSON-RPC _meta에 넣는 것은 2026-07-28 Remote MCP 인증이 아니다. 인증은 HTTP 전송 층에 있고, 메서드 봉투 안에 있지 않다.
다른 글과의 층: MCP는 발견과 호출 프로토콜이다. 무상태화가 뺀 것은 Session이지 인가가 아니다. inputSchema는 arguments 모양을 묶는다. Schema를 통과해도 호출자가 허가된 것은 아니다. OAuth가 답하는 것은 「이 Bearer가 이 자원용인지, scope가 충분한지」다. Agent 루프 자체는 AI Agent란 무엇인가.
| 역할 | OAuth 2.1 위치 | 무엇을 내놓는가 |
|---|---|---|
| MCP Client / Host | OAuth Client | 발견, PKCE, token 보관, 매 홉 Bearer |
| MCP Server | Resource Server | 401 + PRM, token 검증, audience 바인딩 |
| Authorization Server | 발급자 / IdP | 로그인, 동의, 인가 코드, Access Token |
Authorization Server 발견: 401과 RFC 9728
실전은 실패한 호출에서 시작한다. Client는 https://mcp.example.com/mcp에 tools/list나 임의의 JSON-RPC를 Authorization 없이 보낸다. Server는 HTTP 401을 주고 WWW-Authenticate에 Bearer와 resource_metadata를 넣는다. scope도 넣어 최소 권한을 알린다. 클라이언트는 이 헤더를 파싱해야 한다. resource_metadata가 있으면 그것을 쓰고, 없으면 RFC 9728 well-known을 탐색한다.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="invoices:read"
MCP Server는 OAuth 2.0 Protected Resource Metadata(RFC 9728)를 구현해야 한다. 문서의 authorization_servers에는 AS issuer가 하나 이상 있어야 한다. resource(이 Server의 정규 URI), scopes_supported, bearer_methods_supported도 쓸 수 있다. 클라이언트는 이어서 AS metadata를 가져온다. 경로 없는 issuer는 /.well-known/oauth-authorization-server, 다음 OpenID openid-configuration. tenant 경로가 있으면 규격 우선순위로 경로를 끼운다. 돌아온 문서의 issuer는 URL을 만들 때 쓴 issuer와 문자열이 완전히 같아야 한다. 다르면 공격으로 보고 문서를 버린다.
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": [
"https://auth.example.com"
],
"bearer_methods_supported": ["header"],
"scopes_supported": [
"invoices:read",
"invoices:write"
],
"resource_documentation": "https://mcp.example.com/docs"
}
위는 401과 최소 Protected Resource Metadata다. resource에 fragment를 넣지 말고 scheme을 생략하지 마라. 끝 슬래시는 사이트 전체에서 맞춘다. 의미가 없으면 붙이지 않는 편이 낫다. AS를 여러 개 쓰면 각각 독립 발급자다. 클라이언트는 등록 상태를 나눠 저장한다.
클라이언트 등록: CIMD, 사전 등록, DCR
AS를 찾은 뒤, 인가 요청 전에 client_id가 있어야 한다. 세 가지를 규격 우선순위로: ① Client ID Metadata Documents(CIMD)——client_id 자체가 HTTPS URL이고, AS가 그 JSON을 가져와 redirect_uris를 검사한다; ② 콘솔 사전 등록(기밀 또는 공개); ③ Dynamic Client Registration(RFC 7591) POST /register. DCR은 폐기 예정이며 CIMD를 모르는 AS 호환용으로만 남는다. 새 프로젝트는 CIMD나 사전 등록을 택하라.
authorization_servers가 여럿이면 각각 독립 AS다. client_id, 비밀, token을 AS별로 나눠 저장하라. A의 자격 증명을 B의 token 엔드포인트에 보내지 마라. mix-up / confused deputy의 흔한 입구다.
공개 클라이언트(데스크톱 Host, 브라우저 확장)는 OAuth 2.1 PKCE가 필수다. 기밀 클라이언트도 하는 편이 낫다. 「client_secret을 MCP Server 환경 변수에 넣고 AS로 등록을 중계한다」를 만들지 마라. MCP Server는 Resource Server이지 Client가 아니다.
인가 코드 + PKCE + resource: Access Token 받기
등록 다음이 인가 코드 흐름이다. 브라우저를 열기 전에 Client는 반드시: PKCE code_verifier / code_challenge를 만들고; 인가 요청과 token 요청 모두에 resource(MCP Server 정규 URI, RFC 8707)를 붙이고; scope를 고르고(401의 scope 우선, 없으면 scopes_supported); 검증된 AS issuer를 code_verifier와 같은 요청 기록에 남긴다. 동의 후 콜백 iss는 RFC 9207대로 기록값과 단순 문자열 비교한다. 대소문자 접기나 끝 슬래시 제거를 하지 마라.
Token 요청은 code, code_verifier, resource를 실어 보낸다. 성공 응답은 JSON이다. access_token, token_type은 Bearer, expires_in, 있을 수 있는 refresh_token과 scope. refresh token이 있다고 가정하지 마라. MCP Server의 scopes_supported와 401 scope에 offline_access를 넣지 마라. 그것은 Client가 AS에 요청하는 것이지 자원 요구가 아니다. AS metadata 핵심 필드는 아래와 같다. authorization_response_iss_parameter_supported가 true면 iss 없는 콜백은 거부한다.
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth/authorize",
"token_endpoint": "https://auth.example.com/oauth/token",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"registration_endpoint": "https://auth.example.com/oauth/register",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"response_types_supported": ["code"],
"token_endpoint_auth_methods_supported": [
"none",
"client_secret_basic"
],
"authorization_response_iss_parameter_supported": true,
"scopes_supported": [
"invoices:read",
"invoices:write",
"offline_access"
]
}
Access Token은 Client에게 불투명한 경우가 많다. 장애 분석에서는 JWT인 경우가 많다. 먼저 iss, aud(또는 resource), scope, exp, client_id를 본다. aud는 이 MCP Server에 묶여야 한다. 결제 API용 token으로 tools/call을 돌리면 안 된다. 아래 payload는 올바른 aud다. 다른 API URI로 바꾸면 Resource Server는 401이어야 한다.
{
"iss": "https://auth.example.com",
"sub": "user_1842",
"aud": "https://mcp.example.com/mcp",
"client_id": "https://host.example.com/oauth/client.json",
"scope": "invoices:read",
"exp": 1790000000,
"iat": 1789996400
}
Bearer로 Remote MCP API를 호출하고 audience 검증
token을 받은 뒤 Client → MCP Server의 모든 HTTP 홉에 Authorization: Bearer를 넣는다. 발견, tools/list, tools/call, resources/read도 같다. Token을 query에 넣으면 안 된다. JSON-RPC 메서드 이름과 arguments는 그대로 body에 있다. 인증 헤더와 봉투는 두 층이다. Stateless MCP에는 「한 번 로그인하면 이후 검사 생략」 Session이 없다. 각 요청이 스스로 인증한다. Stateless MCP를 보라.
POST /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "searchInvoices",
"arguments": {
"startDate": "2026-01-01",
"endDate": "2026-01-31",
"status": "paid"
}
}
}
Server는 OAuth 2.1 §5.2로 token을 검증한다. 서명 또는 introspection, exp, iss, RFC 8707 audience. 실패는 모두 401. audience 뒤에도 이 AS가 발급했는지 확인한다. Server는 타인 대상 token을 받거나 전달하면 안 된다. Client도 다른 자원의 token을 이 MCP Server에 보내면 안 된다. confused deputy의 단단한 제약이다.
인가가 통과해도 arguments가 올바른 것은 아니다. Bearer가 답하는 것은 「누구, 어느 자원, 어떤 scope」뿐이다. searchInvoices의 날짜와 열거는 여전히 parse + Schema + 업무 규칙이 필요하다. 「token이 유효하다」를 tools/call의 유일한 문으로 두지 마라. 모양 계약은 MCP와 JSON Schema.
Scope, 403, JSONVue 연동
token은 유효하지만 scope가 부족하면 Server는 403을 주고 WWW-Authenticate에 error="insufficient_scope", 이번 작업에 필요한 scope, 같은 resource_metadata를 넣는다. 이번 작업에 필요한 scope를 한 번의 도전에 모아라. 하나씩 빠뜨려 동의를 두 번 받지 마라. Client step-up은 「이전 scope ∪ 도전 scope」로 해서 다른 도구가 이미 받은 권한을 잃지 않게 한다.
| HTTP | 의미 | 다음에 할 일 |
|---|---|---|
| 401 | 미인증, 또는 token 무효 / 만료 / audience 오류 | PRM을 읽고 재인가 또는 token 갱신 |
| 403 + insufficient_scope | token은 유효, 권한 부족 | step-up: scope를 합쳐 다시 인가 |
| 400 | 인가 요청이 깨짐 | 먼저 Client 요청을 고친다. 업무를 재시도하지 않는다 |
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="invoices:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="Write scope required to create a credit note"
연동에서는 JSON이 적어도 네 장 나온다. Protected Resource Metadata, AS metadata, token 응답, (JWT면) payload. 여기에 tools/call의 params.arguments. 역할이 다르니 하나의 「범용 검증」 파일로 섞지 마라. 실패 표본을 하나씩 남겨라. authorization_servers 없는 PRM, issuer가 어긋난 AS metadata, aud가 틀린 JWT, 필드가 빠진 arguments.
브라우저에서 끝낼 수 있다: JSON 포맷으로 metadata parse를 보고, JWT 디코드로 aud / scope / exp를 보고, JSON Schema 검증으로 PRM 계약을 대고, JSON Diff로 갱신 전후 payload, 또는 모델 arguments와 MCP params를 비교한다. 데이터는 기기를 떠나지 않는다.
이어서: MCP란 무엇인가, Stateless MCP, MCP와 JSON Schema, A2A vs MCP, AI Agent란 무엇인가.
자주 묻는 질문 FAQ
로컬 STDIO MCP에도 OAuth 2.1이 필요한가?
아니다. 규격은 STDIO에서 이 브라우저 인가를 돌리지 말라고 한다. 자격 증명은 환경 변수에서 가져온다. OAuth 2.1은 HTTP Remote MCP용이다. 같은 도구가 STDIO와 Streamable HTTP를 함께 내놓을 수 있다. 401, PRM, Bearer가 필요한 것은 HTTP 면뿐이다.
Access Token을 JSON-RPC params나 _meta에 넣어도 되나?
규격 구현으로는 안 된다. Access Token은 HTTP Authorization 헤더에 있어야 하고 query에 나타나면 안 된다. 봉투의 _meta는 프로토콜 버전 등이지 로그인이 아니다. body에 쓰면 로그, 프록시, 모델 문맥에 실린다.
직접 JWT를 발급하고 Server가 공유 키로 검증하면 MCP OAuth를 구현한 것인가?
서명 검증만으로는 부족하다. Client가 찾는 것은 Authorization Server이지 「Server에 구운 키」가 아니다. RFC 9728 metadata, authorization_servers, 인가/token 요청의 resource, audience 바인딩, 401 / 403 도전이 여전히 필요하다. 자체 AS가 JWT를 내도 된다. 발견과 resource 매개변수는 생략할 수 없다.
API Gateway가 이미 JWT를 검증한다. 또 해야 하나?
게이트웨이가 서명과 만료를 대신할 수 있다. MCP Host는 여전히 AS를 찾고 resource를 붙이며 WWW-Authenticate를 읽어야 한다. audience를 묶지 않고 「아무 유효 JWT」만 검사하면 다른 API용 token이 들어온다. MCP를 평범한 REST로만 막으면 Host가 핸드셰이크를 못 하는 경우가 많다.
요약과 다음 단계
Remote MCP의 OAuth 2.1은 「로그인 페이지를 더하는 것」이 아니다. 401 발견, AS metadata, 클라이언트 등록, PKCE, resource, Access Token, 매 홉 Bearer라는 전송 층 계약이다. MCP Server는 Resource Server다. token을 내는 것은 Authorization Server다.
출하 순서: 먼저 401 + Protected Resource Metadata를 기존 MCP Client가 파싱하게 하고, 다음에 AS를 붙이고, 마지막에 tools/call 업무를 쓴다. metadata와 JWT 표본은 JSONVue에 두고 Schema 검증과 분리한다. 프로토콜 입구는 MCP란 무엇인가. 무상태 전송은 Stateless MCP.