3부 11장. OAuth 2.1: 인증을 우회하지 않고 정면 돌파하기
3부 11장. OAuth 2.1: 인증을 우회하지 않고 정면 돌파하기
로컬 stdio 서버를 다룰 때는 마음이 꽤 편했다. 같은 컴퓨터 안에서, 같은 사용자 권한으로, 같은 프로세스 경계 안에서 움직이기 때문이다. 이때는 운영체제가 이미 1차 문지기 역할을 해 준다. 하지만 PKM-MCP를 원격 HTTP 서버로 바꾸는 순간 분위기가 달라진다. 서버 주소를 아는 누구나 문을 두드릴 수 있고, 그 문 앞에서 "누구세요?"를 제대로 묻지 않으면 결국 남의 노트를 대신 읽고, 쓰고, 지우는 일이 벌어진다.
그래서 원격 MCP 서버에서 인증은 옵션 장식이 아니다. MCP의 최신 인증 방향은 매우 분명하다. stdio는 MCP 차원의 OAuth 흐름을 따르지 않고 환경 변수나 OS 권한에 기대는 편이 맞고, 원격 HTTP 기반 서버는 OAuth 2.1 흐름을 따르는 것이 기본값에 가깝다.1 편법으로 발급한 정적 API 키 한 줄은 처음엔 달콤하지만, 사용자 식별, 권한 분리, 동의 화면, 회수, 감사 추적까지 생각하면 결국 빚이 된다.
PKM-MCP도 이제 그 단계를 넘어간다. 지금부터는 "내 서버에 접속한 클라이언트"를 믿는 대신, "이 요청이 누구의 어떤 권한으로 들어왔는가"를 검증해야 한다. 그래야 메모 검색 서버가 진짜 개인 지식 서버가 된다.

로컬 감각으로 원격 서버를 만들면 왜 위험할까
원격 서버에서 가장 흔한 착각은 이것이다. "어차피 내 노트 앱인데, 토큰 하나만 넣으면 되지 않을까?" 문제는 개인 지식 서버가 성장할수록 개인 서버가 아니게 된다는 점이다. 여러 기기에서 접속하고, 다른 클라이언트와 연결하고, 나중에는 팀원이나 다른 사용자에게도 배포하게 된다. 그 순간부터 인증은 로그인 버튼의 문제가 아니라 경계 설정의 문제가 된다.
정적 비밀키 방식이 특히 위험한 이유는 두 가지다. 첫째, 사용자를 구분하기 어렵다. 같은 키를 공유하면 누가 어떤 메모를 바꿨는지 추적이 흐려진다. 둘째, 권한을 쪼개기 어렵다. 검색만 허용하고 삭제는 막고 싶은데, 키 하나로는 이런 차이를 표현하기가 거칠다. 반면 OAuth 2.1은 사용자 동의, 만료, 갱신, 범위 제한, 서버별 토큰 바인딩을 한 흐름 안에 넣어 준다. 즉, 귀찮지만 정답에 가까운 귀찮음이다.
11.1 MCP에서의 OAuth 흐름
원격 MCP에서 인증 흐름을 이해할 때는 등장인물을 세 명으로 나누면 훨씬 쉽다. MCP 클라이언트는 Claude Desktop, VS Code, Cursor 같은 쪽이다. MCP 서버는 PKM-MCP다. 그리고 인증 서버는 Auth0, Keycloak, Okta, Entra ID처럼 로그인과 토큰 발급을 맡는 별도 서비스다. 최신 MCP 방향에서 원격 MCP 서버는 OAuth의 리소스 서버처럼 행동하고, 클라이언트는 그 서버를 대신 호출하기 위한 액세스 토큰을 받는다.2
흐름은 생각보다 단순하다.
클라이언트가 토큰 없이 PKM-MCP에 요청을 보낸다.
PKM-MCP는
401 Unauthorized를 돌려주고,WWW-Authenticate헤더로 인증에 필요한 힌트를 준다.클라이언트는 보호된 리소스 메타데이터 문서를 읽어 어떤 인증 서버를 써야 하는지 알아낸다.
클라이언트는 인증 서버 메타데이터를 읽어
authorize,token, 그리고 필요하면 클라이언트 등록 관련 엔드포인트를 확인한다.클라이언트는 브라우저를 열어 사용자에게 로그인과 동의를 받는다. 이때 Authorization Code + PKCE 흐름을 사용한다.
인증 서버는 클라이언트에게 액세스 토큰을 발급한다. 이 과정에서
resource파라미터를 이용해 "이 토큰은 어떤 MCP 서버용인가"를 묶어 둔다.클라이언트는 이후 모든 HTTP 요청에
Authorization: Bearer ...헤더를 넣어 PKM-MCP를 호출한다.23
핵심은 첫 번째 401 응답이다. 원격 MCP 서버는 "인증이 필요하다"라고만 말하고 끝나지 않는다. 어디에서 인증해야 하는지까지 알려 줘야 한다. MCP 클라이언트는 우선 WWW-Authenticate 헤더의 resource_metadata 힌트를 사용하고, 그 힌트가 없다면 well-known 경로를 따라 보호 리소스 메타데이터를 찾는다.2
PKM-MCP의 보호 리소스 메타데이터는 대략 이런 정보를 담는다.
resource: https://pkm.example.com/mcp
authorization_servers:
- https://auth.example.com
scopes_supported:
- notes:read
- notes:write
- notes:delete
- profile:read여기서 resource 값은 그냥 예쁜 URL이 아니다. 나중에 토큰의 aud 같은 대상 식별과 연결되는, 말하자면 "이 토큰이 도착해야 할 주소"다. 이 개념이 중요해진 이유는 악성 서버가 다른 서버용 토큰을 가로채 쓰는 일을 막기 위해서다. MCP에서 resource 파라미터는 토큰에 택배 송장을 붙이는 것과 비슷하다. 주소가 다르면 배송이 실패해야 정상이다.4
PKCE도 꼭 기억해 두자. 예전 OAuth 글을 읽다 보면 선택 옵션처럼 느껴질 수 있지만, MCP에서 다루는 원격 클라이언트는 대부분 퍼블릭 클라이언트에 가깝다. 즉, 브라우저를 열고 사용자의 로그인을 받아 오지만, 클라이언트 비밀값을 완벽하게 숨길 수는 없다. 그래서 인증 코드를 가로채도 쓸 수 없게 만드는 PKCE가 사실상 필수다.3
클라이언트 등록 방식은 환경에 따라 다르다. 이미 등록된 클라이언트 정보를 쓸 수도 있고, 클라이언트 메타데이터 문서 방식이나 동적 클라이언트 등록을 쓸 수도 있다. 중요한 점은 PKM-MCP 서버 입장에서 이 부분을 직접 다 떠안으려 하지 않는 것이다. 가능하면 기존 인증 서버에 맡기고, 우리는 토큰 검증과 권한 enforcement에 집중하는 편이 낫다.3
11.2 토큰 저장과 갱신
OAuth를 처음 붙이면 시선이 자꾸 "로그인 성공" 화면에만 꽂힌다. 하지만 운영에서 더 중요한 건 로그인 이후다. 토큰은 발급받는 순간보다 보관하고 갱신하는 순간에 더 자주 사고를 낸다.
역할을 나누면 정리가 쉽다.
클라이언트는 액세스 토큰과 리프레시 토큰을 안전하게 저장하고, 만료 전에 갱신하거나 만료 후 다시 인증해야 한다.
인증 서버는 짧게 사는 액세스 토큰과 더 오래 사는 리프레시 토큰을 발급하고, 필요하면 회전과 폐기를 처리한다.
PKM-MCP 같은 리소스 서버는 매 요청마다 토큰의 서명, 발급자, 대상, 만료 시각, 스코프를 검증하고, 문제가 있으면
401또는403을 반환한다.5
이 세 역할이 섞이기 시작하면 설계가 급격히 지저분해진다. 예를 들어 PKM-MCP가 "토큰이 만료됐지만 방금 전 요청이었으니 이번 한 번은 봐주자" 같은 태도를 취하면 안 된다. 리소스 서버는 냉정해야 한다. 만료된 토큰은 만료된 토큰이다. 이런 경우 401을 돌려주고, 클라이언트가 토큰 엔드포인트에서 새 액세스 토큰을 받아 다시 오게 해야 한다.5
액세스 토큰은 매 요청마다 Authorization 헤더로 보내야 한다. 같은 논리 세션 안이라고 해서 생략할 수 없다. 또 토큰을 URL 쿼리 문자열에 넣으면 안 된다. 로그, 프록시, 브라우저 기록에 새어 나가기 쉽기 때문이다.5
토큰 검증 방식은 크게 두 갈래다. 하나는 JWT를 JWKS로 로컬 검증하는 방식이고, 다른 하나는 인증 서버에 introspection을 보내는 방식이다. JWT + JWKS는 빠르다. 처음 키를 가져올 때만 약간 느리고, 이후에는 캐시된 키로 빠르게 검증할 수 있다. 반대로 introspection은 실시간 폐기 반영에는 좋지만, 요청마다 인증 서버 왕복이 추가되어 지연과 의존성이 생긴다.6 PKM-MCP처럼 검색 요청이 자주 오고 응답성이 중요한 서버라면, 기본 선택은 JWT 로컬 검증 쪽이 더 잘 맞는다.
저장 위치도 중요하다. 웹 기반 클라이언트라면 토큰을 localStorage에 던져 넣고 잊어버리고 싶겠지만, 그건 XSS와 친해지는 지름길이다. 일반적인 권고는 보안 저장소를 쓰거나, 웹이라면 httpOnly 쿠키 또는 안전한 메모리 저장 전략을 택하는 것이다.7 데스크톱 MCP 클라이언트라면 운영체제의 키체인이나 안전한 크리덴셜 저장소를 사용하는 편이 좋다. 우리 서버는 그 구현을 통제할 수 없지만, 적어도 "클라이언트가 안전하게 보관하지 못할 수도 있다"는 전제로 토큰 수명을 짧게 설계해야 한다.
실무 감각으로 보면 이런 기준이 무난하다. 액세스 토큰은 15분에서 60분 사이의 짧은 수명을 주고, 리프레시 토큰은 더 길게 두되 회전시키는 것이다. 회전이란 리프레시 토큰을 한 번 사용할 때마다 새 리프레시 토큰을 다시 내주고, 이전 것은 더 이상 못 쓰게 만드는 방식이다. 이렇게 해야 리프레시 토큰이 새더라도 피해 창이 줄어든다.8
정리하면, PKM-MCP는 리프레시 토큰을 직접 들고 사용자 세션을 연명시키는 서버가 아니다. PKM-MCP는 액세스 토큰만 믿고, 그 토큰을 엄격하게 검사하는 서버다. 이 선을 넘지 않아야 설계가 맑다.
11.3 리소스 인디케이터와 스코프 설계
OAuth를 붙였다고 바로 안전해지는 것은 아니다. 토큰이 "누구 것인가"만 확인하고 "어디용인가"를 확인하지 않으면, 다른 서버용 토큰을 들고 와서 우겨 보는 일이 가능해진다. 그래서 MCP는 resource 파라미터를 중요하게 다룬다. 클라이언트는 인증 요청과 토큰 요청에서 resource=https://pkm.example.com/mcp를 명시하고, PKM-MCP는 토큰의 대상이 정말 자기 자신인지 확인해야 한다.4
이 검사를 생략하면 생기는 대표적인 문제가 confused deputy, 즉 엉뚱한 심부름꾼 문제다. 토큰을 받은 서버가 "이거 내가 쓸 수도 있겠네?" 하고 다른 리소스에 들이밀면 큰일이다. 토큰의 aud나 동등한 대상 정보를 PKM-MCP의 canonical resource URL과 비교해야 하는 이유가 바로 여기에 있다.
스코프 설계도 마찬가지다. 처음부터 도구 이름마다 스코프를 하나씩 만드는 건 지나치게 촘촘하고, 반대로 pkm:* 하나로 끝내는 건 너무 거칠다. 좋은 스코프는 도구 이름이 아니라 비즈니스 능력을 기준으로 잡는다. PKM-MCP에서는 아래 정도가 출발점으로 좋다.
| 스코프 | 의미 | PKM-MCP에서 허용하는 일 |
|---|---|---|
notes:read | 메모 읽기 | search_notes, semantic_search_notes, 노트 Resource 조회, 주간 요약 Prompt 실행 |
notes:write | 메모 생성/수정 | 새 메모 추가, 기존 메모 수정, GitHub 이슈를 노트로 가져오기 |
notes:delete | 메모 삭제 | 메모 삭제, 휴지통 비우기 같은 파괴적 작업 |
profile:read | 사용자 기본 정보 읽기 | UI에서 사용자명 표시, 개인 워크스페이스 정보 조회 |
audit:read | 감사 기록 읽기 | 나중에 14장에서 다룰 관리 기능 |
여기서 좋은 점이 보인다. search_notes와 semantic_search_notes는 도구 이름은 다르지만 둘 다 읽기다. 그러니 같은 notes:read에 묶는다. 반대로 delete_note는 이름 하나뿐이어도 위험도가 크므로 notes:delete처럼 별도 스코프로 빼는 편이 낫다. 즉, 스코프는 함수 이름이 아니라 위험도와 권한 경계를 반영해야 한다.
또 하나 중요한 원칙은 단계적 권한 상승, 즉 step-up이다. 사용자가 처음에는 검색만 하다가 나중에 삭제 도구를 쓰고 싶어질 수 있다. 이때 처음부터 notes:delete까지 다 요구하면 과하다. 대신 읽기 위주 작업에는 notes:read만 받고, 삭제가 필요한 순간 403 insufficient_scope와 함께 필요한 스코프를 알려 주면 된다. 그러면 클라이언트는 추가 동의를 받고 다시 시도할 수 있다.9 보안과 사용성의 타협점을 이런 식으로 만든다.
만들기: PKM-MCP에 OAuth 추가, 사용자별 노트 격리하기
이제 PKM-MCP에 실제로 인증을 붙여 보자. 목표는 단순하다. 같은 서버를 여러 사용자가 공유하더라도, 각 사용자는 자기 노트만 검색하고 수정할 수 있어야 한다. 핵심 키는 이메일이 아니라 sub 클레임이다. 이메일은 바뀔 수 있지만, sub는 보통 인증 서버가 발급하는 안정적인 사용자 식별자다.
첫 번째 단계는 데이터 모델을 바꾸는 것이다. 노트 테이블에 반드시 소유자 컬럼을 둔다.
CREATE TABLE notes (
id TEXT PRIMARY KEY,
owner_sub TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_notes_owner_sub_updated_at
ON notes(owner_sub, updated_at DESC);이 한 줄이 별거 아닌 것 같지만, 사실상 사용자 격리의 절반이다. 이제부터 모든 조회, 수정, 삭제 조건에는 owner_sub가 함께 들어가야 한다. note_id만 보고 데이터를 만지는 순간, 멀티 유저 설계는 무너진다.
두 번째 단계는 PKM-MCP의 canonical resource URL을 고정하는 것이다.
import os
PKM_RESOURCE = 'https://pkm.example.com/mcp'
OIDC_ISSUER = os.environ['OIDC_ISSUER']
OIDC_JWKS_URI = os.environ['OIDC_JWKS_URI']이 PKM_RESOURCE는 여러 곳에서 같은 값으로 쓰인다. 보호 리소스 메타데이터의 resource, 토큰 검증의 audience, 그리고 클라이언트가 보내는 resource 파라미터가 모두 이 값과 맞아떨어져야 한다. 주소가 자꾸 바뀌면 토큰 바인딩도 같이 흔들린다.
세 번째 단계는 토큰 검증 헬퍼를 만드는 것이다. 여기서는 JWT + JWKS 방식을 쓴다.
from dataclasses import dataclass
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient(OIDC_JWKS_URI)
@dataclass
class AuthContext:
sub: str
scopes: set[str]
def parse_access_token(auth_header: str) -> AuthContext:
scheme, token = auth_header.split(' ', 1)
if scheme.lower() != 'bearer':
raise ValueError('Bearer 토큰이 필요합니다.')
signing_key = jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=['RS256', 'ES256'],
audience=PKM_RESOURCE,
issuer=OIDC_ISSUER,
)
return AuthContext(
sub=claims['sub'],
scopes=set(claims.get('scope', '').split()),
)네 번째 단계는 에러를 HTTP 의미에 맞게 돌려주는 것이다. 인증이 없거나 토큰이 잘못되면 401, 권한이 모자라면 403이다.
def require_scope(user: AuthContext, needed: str) -> None:
if needed not in user.scopes:
raise PermissionError(needed)HTTP 어댑터 계층에서는 이런 식으로 매핑하면 된다.
토큰이 없거나 만료됨:
401 Unauthorized헤더에
WWW-Authenticate와resource_metadata힌트 포함스코프 부족:
403 Forbidden가능하면
WWW-Authenticate에error=insufficient_scope,scope=필요한값포함
다섯 번째 단계는 실제 도구 로직을 사용자 단위로 감싸는 것이다.
def search_notes_for_user(user: AuthContext, query: str, limit: int = 10):
require_scope(user, 'notes:read')
return repo.search_notes(
owner_sub=user.sub,
query=query,
limit=min(limit, 20),
)
def create_note_for_user(user: AuthContext, title: str, body: str):
require_scope(user, 'notes:write')
return repo.create_note(
owner_sub=user.sub,
title=title,
body=body,
)
def update_note_for_user(user: AuthContext, note_id: str, title: str, body: str):
require_scope(user, 'notes:write')
updated = repo.update_note(
owner_sub=user.sub,
note_id=note_id,
title=title,
body=body,
)
if not updated:
raise LookupError('노트가 없거나 권한이 없습니다.')
return updated
def delete_note_for_user(user: AuthContext, note_id: str):
require_scope(user, 'notes:delete')
deleted = repo.delete_note(owner_sub=user.sub, note_id=note_id)
if not deleted:
raise LookupError('노트가 없거나 권한이 없습니다.')이제 PKM-MCP의 도구 매핑은 자연스럽다. search_notes, semantic_search_notes, note://{id} Resource, "이번 주 메모 요약" Prompt는 모두 notes:read를 요구한다. 2부에서 만들었던 생성·수정 도구는 notes:write, 삭제 도구는 notes:delete를 요구한다. 서버 입장에서는 "도구 이름"보다 "이 동작이 어떤 권한인가"가 더 중요하다.
여섯 번째 단계는 빠르게 검증해 보는 것이다.
# 1) 토큰 없이 호출 -> 401
curl -i https://pkm.example.com/mcp
# 2) 읽기 토큰으로 검색 -> 성공
curl -X POST https://pkm.example.com/mcp \
-H 'Authorization: Bearer READ_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_notes","arguments":{"query":"OAuth"}}}'
# 3) 읽기 토큰으로 삭제 -> 403
curl -X POST https://pkm.example.com/mcp \
-H 'Authorization: Bearer READ_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_note","arguments":{"note_id":"n_123"}}}'이 테스트가 통과하면 최소한의 큰 줄기는 완성이다. 이제 PKM-MCP는 "누가 들어왔는지 모르는 메모 서버"가 아니라, "누구의 노트에 접근하는지 정확히 구분하는 MCP 서버"가 된다.
마지막으로 꼭 기억할 점이 있다. 사용자 격리는 인증 미들웨어 하나로 끝나지 않는다. 검색 SQL, 벡터 검색, Resource 조회, Prompt의 내부 데이터 수집까지 전부 owner_sub를 기준으로 필터링해야 한다. 특히 RAG를 붙인 뒤에는 벡터 DB 쿼리에서도 사용자 필터를 빼먹기 쉽다. 텍스트 DB만 잠그고 임베딩 인덱스는 열어 두면, 정문에 자물쇠를 달고 뒷문을 활짝 열어 둔 셈이다.
OAuth 2.1을 붙인다는 것은 로그인 화면 하나를 더 만드는 일이 아니다. PKM-MCP가 "내 메모 서버"에서 "여러 사용자가 믿고 연결할 수 있는 서버"로 성장하기 위한 성인식에 가깝다. 편법을 버리고 정면으로 들어가면, 이후의 배포, 감사 로그, UI, 레지스트리 등록까지 전부 훨씬 단단해진다.
참고
1Authorization - Model Context Protocol (draft)
2Authorization - Model Context Protocol (draft)
3Authorization - Model Context Protocol (draft)
4Update To MCP Authorization Spec - Resource Parameter (RFC 8707)
5Authorization - Model Context Protocol
7Integrating your own MCP client
8Token Management - MCP in Production | Stanza
9MCP Security II — OAuth 2.1, Resource Indicators, Incremental Scopes
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.