로컬에서 stdio로 돌리던 MCP 서버를 사내망 밖으로 꺼내 HTTP로 열려고 하면 제일 먼저 막히는 게 인증이다. stdio는 프로세스를 띄운 쪽이 이미 신뢰 경계 안에 있으니 환경변수로 토큰만 넘기면 끝이지만, 원격 HTTP 서버는 아무나 요청을 던질 수 있다. 저는 이 지점에서 'API 키 헤더 하나 받으면 되지 않나' 하고 시작했다가 스펙이 요구하는 그림이 꽤 다르다는 걸 알고 처음부터 다시 짰다.
원격 MCP 서버에 인증을 붙인다는 건 서버를 OAuth 2.1 리소스 서버로 만든다는 뜻이다. 서버가 직접 로그인을 처리하지 않고, 토큰 없는 요청에 401과 함께 메타데이터 위치를 알려 주면, 클라이언트가 그 메타데이터를 읽어 인가 서버를 찾아 PKCE 인가코드로 토큰을 받아온다. 2026-07-28 리비전 스펙 기준으로 서버가 꼭 구현해야 하는 건 두 가지다. 토큰을 발급받는 당사자는 인가 서버고, MCP 서버는 받은 토큰을 검증하고 거부하는 역할만 맡는다.
리소스 서버가 지는 최소 의무
스펙에서 MCP 서버에 MUST로 거는 요구는 사실 짧다. OAuth 2.0 Protected Resource Metadata(RFC 9728)를 구현할 것, 그리고 자신에게 발급된 토큰인지 audience를 검증할 것. 로그인 화면, 동의 화면, 토큰 발급은 전부 인가 서버 몫이고 MCP 서버는 관여하지 않는다.
Protected Resource Metadata는 "이 리소스를 쓰려면 어느 인가 서버에서 토큰을 받아 와라"를 적어 둔 JSON 한 장이다. 클라이언트가 이걸 읽어야 인가 서버 주소를 알 수 있으므로, 서버 입장에서는 이 문서를 정해진 위치에 올려 두는 게 인증 붙이기의 출발점이다.
토큰 없이 들어오면 길을 알려 준다
인증 안 된 요청이 들어오면 서버는 401을 돌려주되, WWW-Authenticate 헤더에 메타데이터 문서의 위치를 박아 준다. 이게 디스커버리의 시작점이다.
$ curl -i https://mcp.example.com/mcp
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="mcp:tools"
Content-Type: application/json
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Unauthorized"},"id":null}
resource_metadata 파라미터가 핵심이다. 클라이언트는 이 URL을 뜯어 메타데이터 문서를 가지러 간다. scope도 같이 넣어 주면 클라이언트가 처음부터 필요한 권한만 요청하게 유도할 수 있다. 스펙은 이 scope를 그 요청에 필요한 권한의 기준값으로 취급하라고 못 박는다.
.well-known에 메타데이터 한 장 올리기
메타데이터 위치는 RFC 9728이 정한 규칙을 따른다. 리소스 식별자의 호스트와 경로 사이에 /.well-known/oauth-protected-resource를 끼워 넣는다. 리소스가 https://mcp.example.com이면 문서는 https://mcp.example.com/.well-known/oauth-protected-resource에 있고, 리소스에 경로가 붙어 https://mcp.example.com/mcp라면 문서는 /.well-known/oauth-protected-resource/mcp가 된다. 여기서 한 번 걸렸다. 경로 있는 서버인데 /mcp/.well-known/...에 올려 두면 클라이언트가 못 찾는다.
$ curl -s https://mcp.example.com/.well-known/oauth-protected-resource
{
"resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["mcp:tools"],
"bearer_methods_supported": ["header"]
}
필수 필드는 resource 하나다. 여기에는 서버의 canonical URI를 적는다. authorization_servers에 인가 서버의 issuer를 나열하면 클라이언트가 그걸 보고 다음 단계로 간다. bearer_methods_supported는 header만 넣는 게 맞다. 토큰을 쿼리스트링에 싣는 건 스펙이 금지한다.
클라이언트는 이어서 인가 서버의 메타데이터를 가져온다. RFC 8414 또는 OpenID Connect Discovery 중 적어도 하나를 인가 서버가 제공해야 하고, 클라이언트는 둘 다 지원해야 한다.
$ curl -s https://auth.example.com/.well-known/oauth-authorization-server
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true
}
PKCE 인가코드로 토큰 받기
PKCE(Proof Key for Code Exchange)는 인가코드를 가로채도 토큰으로 바꾸지 못하게 막는 장치다. 클라이언트가 임의의 code_verifier를 만들고 그 SHA-256 해시를 code_challenge로 보낸 뒤, 나중에 토큰을 받을 때 원본 code_verifier를 제출해 자기가 그 요청의 주인임을 증명한다. OAuth 2.1에서는 공개 클라이언트든 기밀 클라이언트든 PKCE가 사실상 기본이다.
# code_verifier와 code_challenge 생성 (S256)
$ code_verifier=$(openssl rand -base64 60 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
$ code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -binary -sha256 \
| openssl base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
이제 인가 요청 URL을 만들어 브라우저로 연다. 여기서 MCP가 일반 OAuth와 갈라지는 지점이 resource 파라미터다. 어느 MCP 서버에서 쓸 토큰인지 canonical URI로 못 박아야 한다.
https://auth.example.com/authorize ?response_type=code &client_id=s6BhdRkqt3 &redirect_uri=http://127.0.0.1:8765/callback &code_challenge=$code_challenge &code_challenge_method=S256 &scope=mcp:tools &resource=https%3A%2F%2Fmcp.example.com &state=xyz
사용자가 동의하면 인가 서버가 redirect_uri로 인가코드를 돌려준다. 이때 콜백에 붙어 오는 iss 값을 아까 메타데이터에서 기록해 둔 issuer와 문자열 그대로 비교하는 게 중요하다(RFC 9207). 이 비교를 건너뛰면 여러 인가 서버가 엮일 때 발생하는 mix-up 공격에 노출된다. 대소문자 접기나 끝 슬래시 정규화 없이 받은 값 그대로 비교해야 한다.
인가코드를 토큰으로 바꾸는 요청에도 resource와 code_verifier를 같이 넣는다.
$ curl -s -X POST https://auth.example.com/token \
-d grant_type=authorization_code \
-d code=SplxlOBeZQQYbYS6WxSbIA \
-d redirect_uri=http://127.0.0.1:8765/callback \
-d client_id=s6BhdRkqt3 \
-d code_verifier=$code_verifier \
--data-urlencode resource=https://mcp.example.com
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "mcp:tools"
}
받은 토큰으로 다시 MCP 서버를 치면 통과한다. 토큰은 반드시 Authorization 헤더에 싣고, 모든 요청마다 붙여야 한다.
$ curl -i https://mcp.example.com/mcp \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
HTTP/1.1 200 OK
토큰을 받는 서버가 꼭 하는 검증
서버 쪽 구현에서 가장 자주 빠뜨리는 게 audience 검증이다. 스펙은 MCP 서버가 자신을 대상으로 발급된 토큰인지 반드시 확인하도록 요구한다. JWT라면 aud 클레임이 서버의 canonical URI와 일치하는지 본다. 이걸 안 하면 다른 리소스용으로 발급된 토큰을 그대로 받아 주는 confused deputy 문제가 생긴다.
# 토큰의 aud 클레임이 내 서버 URI와 일치하는지 (의사코드)
if token.aud != "https://mcp.example.com":
return 401 # 나를 위한 토큰이 아니면 거부
스펙은 여기서 한 발 더 나간다. MCP 서버는 자기 인가 서버가 발급한, 자기 리소스용으로 유효한 토큰만 받아야 하고, 그 밖의 토큰은 받지도 중계하지도 말라고 한다. 상류 API로 토큰을 그대로 흘려보내는 token passthrough를 막으려는 조항이다.
권한이 모자랄 때의 응답은 401이 아니라 403이다. 토큰 자체는 유효하지만 scope가 부족한 경우, 서버는 403과 함께 WWW-Authenticate에 error="insufficient_scope"와 필요한 scope를 담아 돌려준다. 그러면 클라이언트가 scope를 넓혀 다시 인가받는 step-up 절차를 밟는다.
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="mcp:tools:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
구현하면서 밟기 쉬운 지뢰
resource 파라미터는 인가 요청과 토큰 요청 둘 다에 넣어야 한다. 그리고 인가 서버가 이 파라미터를 지원하든 말든 클라이언트는 무조건 보내야 한다. 처음엔 토큰 요청에만 넣었다가 발급된 토큰의 audience가 비어 검증에서 막히는 경우를 만났다.
canonical URI는 형식이 까다롭다. 소문자 scheme과 host를 쓰고, 프래그먼트(#...)는 못 붙인다. 끝 슬래시는 의미가 있는 게 아니라면 빼는 쪽으로 통일하는 걸 권한다. https://mcp.example.com과 https://mcp.example.com/를 혼용하면 audience 비교가 어긋난다.
Dynamic Client Registration(RFC 7591)은 이 리비전에서 deprecated로 밀렸다. 신규 구현이라면 Client ID Metadata Documents 쪽을 먼저 보는 게 맞고, DCR은 그걸 지원하지 못하는 인가 서버와의 하위 호환용으로만 남겨 두는 걸 권한다.
인증은 OAuth에 기대 넘겼지만 서버가 여전히 책임지는 건 audience 검증 한 줄이다. 토큰 발급 로직을 직접 짜고 있다면 방향을 잘못 잡은 것이고, 받은 토큰이 정말 나를 위한 것인지 확인하는 코드가 비어 있다면 그게 가장 먼저 메꿀 구멍이다.