claude.ai 커넥터가 내 사이트에 로그인하기 — OAuth 2.1 인가 서버를 직접 세운 기록
사이트 네 곳의 MCP에 claude.ai 커넥터와 Claude Code가 로그인할 수 있게 인가 서버를 직접 만들었습니다. 401 응답 하나에서 시작하는 발견·등록·인가 흐름과, 두 번째 로그인부터 깨지던 문제, DNS 리바인딩, 네 벌로 갈라진 검증 코드를 정리한 과정을 적었습니다.

claude.ai 커넥터가 내 사이트에 로그인하기
OAuth 2.1 인가 서버를 직접 세우고, 사이트 네 곳이 한 패키지로 토큰을 검증하기까지
왜 만들었나
Sandcorp의 사이트 네 곳(이 포트폴리오, 뉴스 사이트 Foresight, 방문 집계, 챗봇 플랫폼 Flow)에는 MCP 서버가 붙어 있습니다. 이 글도 claude.ai와 Claude Code에서 MCP 도구를 불러 썼습니다. 글을 만들고, 이미지를 올리고, 발행하는 일까지 모두 MCP로 했습니다.
MCP는 글을 쓰고 지우는 관리 권한이라 아무나 부를 수 있으면 안 됩니다. 그런데 claude.ai 커넥터는 미리 비밀값을 나눠 줄 수 있는 상대가 아닙니다. 사용자가 커넥터 주소만 넣으면, 커넥터가 스스로 어디서 로그인해야 하는지 찾고, 자기를 등록하고, 사용자의 허락을 받아 토큰을 얻어야 합니다. MCP 인가 명세는 이 과정을 OAuth 2.1과 몇 가지 표준으로 정해 두었습니다.
처음에는 사이트마다 따로 인증을 붙였습니다. 사이트가 넷이 되자 같은 코드가 네 벌 생겼고, 같은 버그도 따로 생겼습니다. 그래서 로그인과 토큰 발급은 인가 서버 하나(auth.sandcorp.co.kr)가 맡고, 사이트는 공용 패키지로 토큰을 검증만 하게 나눴습니다. 인가 서버는 Django와 authlib로 일주일 동안 만들었습니다.
시연: 401 응답 하나에서 로그인 경로를 찾기까지
커넥터가 토큰을 얻기까지
- 401 받기RFC 9728
토큰 없이 MCP를 부르면 401과 보호 리소스 메타데이터 주소가 온다
- 인가 서버 찾기RFC 8414
메타데이터의 authorization_servers에서 인가 서버를, 그 서버의 메타데이터에서 엔드포인트를 읽는다
- 자기 등록등록만으로는 권한 없음
client_id로 쓸 https 주소에 자기 소개 문서를 두거나(CIMD), /register로 등록한다(DCR)
- 로그인과 동의/authorize
사용자가 비밀번호·TOTP로 로그인하고 스코프를 허락한다. 요청에는 PKCE와 resource가 꼭 들어간다
- 토큰 받기/token
인가 코드를 10분짜리 액세스 토큰(JWT)과 90일짜리 리프레시 토큰으로 바꾼다
- 사이트가 검증공용 패키지
사이트는 공개 키(JWKS)로 서명을 확인하고, aud가 자기 주소와 똑같은지 본다
인가 서버가 지키는 규칙
| 항목 | 규칙 | 이유 |
|---|---|---|
| 인가 코드 | 5분, 한 번만 교환. DB에는 해시만 | 같은 코드가 두 번 오면 그 동의에서 나온 리프레시 토큰을 모두 끊는다 |
| 액세스 토큰 | Ed25519 서명 JWT, 수명 10분, aud는 사이트 하나 | 한 사이트의 토큰을 다른 사이트에 쓰지 못하게 |
| 리프레시 토큰 | 32바이트 무작위 값, 90일, 쓸 때마다 새 값으로 회전 | 이미 쓴 값이 다시 오면 탈취로 보고 묶음 전체를 폐기 |
| 서명 키 | 활성 키 하나. 회전 뒤 옛 키는 10분 동안 JWKS에 남김 | 이미 나간 토큰이 만료될 때까지만 검증되게 |
| 로그인 제한 | IP는 15분에 5번 틀리면 15분 차단, 계정은 1·2·4…60초 지연 | 계정을 잠그면 누구나 대표를 잠글 수 있다(아래 3번) |
| 등록·토큰 요청 | 등록은 IP당 1시간 10번, 토큰은 1분 60번 | 넘으면 429와 Retry-After |
- PKCE는 인가 코드를 가로챈 사람이 토큰으로 바꾸지 못하게, 요청을 시작한 쪽만 아는 값을 함께 보내는 방식입니다. 여기서는 S256만 받습니다.
부딪힌 문제와 해결
1. Claude Code가 두 번째 로그인부터 거부당했다
Claude Code로 처음 로그인할 때는 잘 됐는데, 다음 로그인에서 Redirect URI http://localhost:41235/callback is not supported by client. 오류가 났습니다. 등록할 때 쓴 주소는 localhost:33418이었습니다.
원인은 리다이렉트 주소를 문자열 그대로 비교한 데 있었습니다. Claude Code 같은 MCP 클라이언트는 로그인할 때마다 비어 있는 포트를 새로 잡습니다. RFC 8252 §7.3은 이렇게 자기 컴퓨터(loopback)로 돌아오는 주소는 포트를 빼고 비교하라고 정해 두었습니다.
→ loopback 주소끼리는 scheme·host·path·query만 비교하고 포트는 무시하게 고쳤습니다. localhost·127.0.0.1·[::1]은 RFC대로 서로 다른 주소로 봅니다. 등록 단계에서 막히던 IPv6 loopback([::1])도 받게 했습니다.
2. 클라이언트 소개 문서를 가져오다 내부망으로 갈 수 있었다
CIMD에서는 client_id가 https 주소이고, 인가 서버가 그 주소의 JSON 문서를 직접 가져옵니다. 남이 정한 주소로 서버가 요청을 보내니, 내부망 주소를 넣으면 서버가 내부를 두드리게 됩니다(SSRF). 그래서 처음부터 주소를 풀어 사설·루프백 IP면 거부했습니다.
그런데 검사할 때 한 번, 연결할 때 또 한 번 DNS를 풀고 있었습니다. 그 사이에 DNS 응답을 바꾸면 검사는 공인 IP로 통과하고, 연결은 사설 IP로 갑니다(DNS 리바인딩).
→ 검사한 IP로만 TCP 연결을 열게 바꿨습니다. TLS의 SNI와 인증서 확인, Host 헤더는 원래 호스트 이름으로 보냅니다. 리다이렉트는 따라가지 않고, 크기 64KB·시간 5초 제한도 그대로 둡니다. 가져온 문서는 1시간(최대 24시간) 캐시합니다.
3. 로그인 잠금이 오히려 공격 수단이 됐다
처음에는 계정 기준으로 5번 틀리면 15분 잠갔습니다. 그런데 계정은 대표 한 사람뿐이고 계정 이름도 알려져 있습니다. 누구든 다른 IP에서 다섯 번만 틀리면 대표를 15분씩 쫓아낼 수 있었습니다.
→ 계정 기준 잠금을 없애고 지연으로 바꿨습니다. n번째 연속 실패 뒤에는 min(60, 2^(n-1))초가 지나야 다음 시도를 받습니다. 지연 중에는 비밀번호가 맞아도 판정하지 않고 「N초 뒤 다시 시도하세요」만 보여 줍니다. 요청을 붙잡아 두지 않고, 다음 허용 시각을 DB에 적어 둡니다. TOTP가 있어 무차별 대입 위험은 낮고, IP 기준 차단은 그대로 둡니다.
4. 사이트 네 곳의 검증 코드가 따로 놀았다
토큰 검증, JWKS 캐시, 보호 리소스 메타데이터, 401·403 응답, 관리 화면 로그인을 사이트마다 구현했습니다. 두 곳은 파일이 거의 같았지만(차이 16~45줄), 나머지 두 곳은 다른 변형이었습니다. 규칙 하나를 바꾸면 네 곳을 고쳐야 했습니다.
→ 사이트 쪽 코드를 공용 패키지 sandcorp-authkit으로 묶어 인가 서버 저장소 안에 두었습니다. 토큰 계약을 정하는 곳과 그 계약을 쓰는 코드를 같은 곳에서 함께 시험하기 위해서입니다. 패키지는 설정 파일이나 환경 변수를 읽지 않고 모든 값을 인자로 받습니다. 그래서 사이트마다 인증 코드는 어댑터 파일 하나만 남았습니다. 의존성은 pyjwt[crypto] 하나입니다.
옮기면서 사이트마다 달랐던 동작을 하나로 맞췄습니다. 예를 들어 JWKS 조회가 실패하면 옛 키를 그대로 쓰고, 모르는 kid가 와도 60초 안에는 다시 조회하지 않습니다. 엉터리 kid를 뿌리는 요청이 인가 서버를 두드리지 못하게 하려는 것입니다. 패키지 저장소가 private이라, Docker 빌드에서는 BuildKit의 SSH 전달로 받고 키가 최종 이미지에 남지 않게 했습니다.
남은 한계
- 액세스 토큰은 즉시 끊을 수 없습니다. 사이트는
jti거부 목록을 보지 않으므로, 리프레시 토큰을 폐기해도 이미 나간 액세스 토큰은 최대 10분 동안 유효합니다. - 강의실 서비스 Codemit은 아직 자체 인가 서버를 씁니다. 사이트 쪽 검증만 공용 패키지로 옮기는 일이 남았습니다.
- CIMD는 공개 클라이언트(
token_endpoint_auth_method=none)만 받습니다. 비밀값을 가진 클라이언트는 관리 명령으로만 등록합니다. - 인가 서버와 패키지는 private 저장소라 코드는 공개하지 않았습니다. 대신 이 글의 메타데이터 주소는 누구나 열어 볼 수 있습니다.

