운영 가능한 MCP 서버에 OAuth 2.1을 붙이는 과정

로컬에서 동작하던 MCP 서버를 여러 사용자와 클라이언트가 쓰는 서비스로 바꾸자 도구 구현보다 인증 흐름이 더 큰 과제가 됐습니다. 클라이언트마다 discovery와 redirect 처리 방식이 달랐고, token의 issuer와 audience, scope를 구분하지 않으면 다른 서비스용 권한까지 받아들일 수 있었습니다. 이 문제를 해결하기 위해 OAuth 2.1 authorization code와 PKCE를 적용했습니다. 인증 이후 인스턴스 상태를 없애는 과정은 Stateless MCP 서버 설계로 이어집니다.

구현의 핵심은 로그인 성공 자체가 아니라, 발급된 token이 현재 MCP 서버와 요청한 사용자에게 유효한지 매번 검증하는 일이었습니다.

로그인 성공 뒤에도 잘못된 토큰을 받을 수 있었다

MCP 클라이언트마다 discovery, redirect URI, token 저장 방식에 차이가 있었고 단순 Bearer token 입력만으로는 안전한 사용자 연결 경험을 제공하기 어려웠습니다. 서버가 여러 인스턴스로 늘어나면 인증 요청 상태와 토큰을 로컬 메모리에 둘 수 없었습니다. 콜백이 다른 인스턴스로 들어와도 같은 흐름을 이어야 했습니다.

Resource Server가 매 요청 확인한 것

access token이 존재한다는 사실만으로 도구 호출을 허용하지 않았습니다. resource server는 issuer, audience, 만료, scope를 매 요청 검증하고 도구별 권한과 다시 대조했습니다. 인증 서버에서 성공한 로그인이 다른 서비스의 권한으로 번역되지 않도록 audience 검증을 특히 분리했습니다.

  • Authorization Code와 PKCE를 기본으로 하고 implicit flow는 사용하지 않습니다.
  • state, code verifier, token은 만료 시간을 가진 외부 저장소에 둡니다.
  • OAuth discovery metadata와 실제 endpoint 동작을 클라이언트 호환성 테스트로 고정합니다.

Discovery부터 도구 호출까지 이어지는 흐름

클라이언트가 protected resource metadata와 authorization server metadata를 발견한 뒤 인증 요청을 시작합니다. 서버는 state와 PKCE 정보를 Redis에 저장하고 사용자 인증 완료 후 일회용 code를 발급합니다.

token endpoint는 code, redirect URI, verifier를 검증하고 짧은 access token과 필요한 경우 refresh token을 발급합니다. 사용된 code는 원자적으로 제거해 재사용을 막습니다. MCP 요청 필터는 JWT 또는 opaque token을 검증해 사용자 컨텍스트를 만들고, 각 도구는 전달된 컨텍스트의 권한 범위 안에서만 백엔드 API를 호출합니다.

Callback 상태를 특정 Pod에 두지 않았다

http.oauth2ResourceServer { oauth2 ->
  oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(scopeConverter()) }
}
http.authorizeHttpRequests { auth ->
  auth.requestMatchers("/.well-known/**").permitAll()
  auth.requestMatchers("/mcp/**").hasAuthority("SCOPE_mcp.tools")
}
{"issuer":"https://auth.example.com","authorization_endpoint":"https://auth.example.com/oauth2/authorize","token_endpoint":"https://auth.example.com/oauth2/token","code_challenge_methods_supported":["S256"]}
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="mcp.tools"

서로 다른 클라이언트와 토큰을 교차 검증했다

정상 시나리오보다 다른 client id로 발급한 token, 잘못된 redirect URI, 누락된 state를 섞은 교차 테스트가 더 많은 문제를 찾았습니다. discovery 문서의 주소도 reverse proxy 뒤에서 실제 외부 URL과 일치해야 했습니다. 한 클라이언트에서만 성공하는 구현은 표준 흐름을 지원한다고 보지 않았습니다.

고정 API Key는 구현이 단순해 내부 자동화에 적합하지만 사용자별 위임과 회수가 어렵습니다. Session Cookie는 웹 UI에는 자연스럽지만 MCP client와 결합됩니다. 외부 MCP client를 지원해야 했기 때문에 위임과 회수, scope 제어가 가능한 OAuth 2.1과 PKCE를 선택했습니다.

  • state 불일치, code 재사용, verifier 오류, 만료 경계를 테스트합니다.
  • redirect URI는 정확 일치 규칙을 사용하고 wildcard를 피합니다.
  • 토큰과 authorization code를 로그에 남기지 않습니다.
  • issuer·audience·만료·서명·scope 각각의 실패 응답을 테스트합니다.
  • key rotation 중 이전·새 JWKS가 안전하게 공존하는지 확인합니다.
  • 401과 403을 구분하고 token 본문 없이 correlation id만 기록합니다.

표준 인증으로 바꾼 뒤 남은 제약

사용자는 장기 토큰을 직접 복사하지 않고 표준 브라우저 인증으로 MCP 클라이언트를 연결할 수 있었습니다. 인증 상태를 Redis로 이동해 어느 인스턴스가 콜백과 MCP 요청을 받아도 흐름이 유지됐습니다.

OAuth 구성은 authorization server마다 discovery와 client registration 지원 범위가 다릅니다. 지원하지 않는 기능을 추측으로 보완하지 않고 허용할 공급자와 필수 metadata를 배포 설정에 명시했습니다.

MCP 인증에서 UI보다 중요했던 것

브라우저 로그인이 성공했다는 사실만으로 MCP 요청이 안전해지지는 않습니다. Resource Server는 매 요청에서 issuer, audience, 만료와 scope를 다시 확인하고 도구마다 필요한 권한을 좁혀야 합니다.

state와 PKCE verifier, callback 결과를 특정 pod의 메모리에 두지 않은 덕분에 인증 도중 배포가 일어나도 흐름을 이어갈 수 있었습니다. 인증 UI보다 각 상태를 누가 검증하고 얼마나 보관하는지가 운영 안정성을 결정했습니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다