본문 바로가기
삵
AI

MCP 서버에 OAuth 2.1 붙이기

포포스트맨·2026년 11월 2일·조회 1

로컬에서 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 검증 한 줄이다. 토큰 발급 로직을 직접 짜고 있다면 방향을 잘못 잡은 것이고, 받은 토큰이 정말 나를 위한 것인지 확인하는 코드가 비어 있다면 그게 가장 먼저 메꿀 구멍이다.

자주 묻는 질문

MCP 서버가 직접 로그인과 토큰 발급까지 구현해야 하나?

아니다. MCP 서버는 OAuth 2.1 리소스 서버 역할만 한다. 로그인, 동의, 토큰 발급은 별도 인가 서버가 맡는다. 서버가 반드시 구현해야 하는 건 Protected Resource Metadata(RFC 9728) 제공과, 받은 토큰이 자신을 대상으로 발급됐는지 audience를 검증하는 것 두 가지다.

resource 파라미터는 꼭 보내야 하나?

그렇다. 스펙은 클라이언트가 RFC 8707 Resource Indicators를 구현하도록 MUST로 요구한다. 인가 요청과 토큰 요청 둘 다에 MCP 서버의 canonical URI를 resource 값으로 넣어야 하며, 인가 서버가 이 파라미터를 지원하는지와 무관하게 항상 보내야 한다.

Protected Resource Metadata 문서는 어디에 올리나?

RFC 9728 규칙에 따라 리소스 식별자의 호스트와 경로 사이에 /.well-known/oauth-protected-resource를 끼워 넣는다. 리소스가 https://mcp.example.com이면 https://mcp.example.com/.well-known/oauth-protected-resource에, 리소스에 /mcp 경로가 있으면 /.well-known/oauth-protected-resource/mcp에 둔다.

권한 부족과 미인증 응답은 어떻게 구분하나?

토큰이 없거나 무효하면 401 Unauthorized를 돌려주고 WWW-Authenticate에 resource_metadata를 담는다. 토큰은 유효하지만 scope가 부족하면 403 Forbidden에 error=insufficient_scope와 필요한 scope를 담아 돌려준다. 클라이언트는 403을 받으면 scope를 넓혀 step-up 재인가를 시도한다.

PKCE는 공개 클라이언트에서만 쓰나?

아니다. OAuth 2.1에서는 인가코드를 쓰는 모든 클라이언트가 PKCE를 적용한다. 공개 클라이언트뿐 아니라 기밀 클라이언트도 대상이며, code_challenge_method는 S256을 쓴다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.