OAuth Dynamic Client Registration 제대로 이해하기

OAuth 연동을 만들 때 보통 가장 먼저 하는 일은 인가 서버(Authorization Server, 이하 AS)에 애플리케이션을 등록하는 것입니다.
관리 콘솔에 들어가 앱 이름, 리다이렉트 URI, 로고, 권한 범위를 입력하고 client_id와 client_secret을 발급받죠.
구글이나 GitHub처럼 정해진 제공자 한두 곳만 상대한다면 이 방식으로 충분합니다.
그런데 클라이언트와 AS가 미리 서로를 알 수 없는 환경이라면 이야기가 달라집니다. 사용자가 처음 보는 MCP 서버에 AI 클라이언트를 붙이거나, 여러 테넌트가 각자 다른 AS를 운영하는 SaaS 생태계를 생각해 볼까요? 모든 AS 콘솔에 사람이 들어가서 미리 등록해 둘 수는 없습니다. 이때 필요한 표준이 동적 클라이언트 등록(Dynamic Client Registration, 이하 DCR)입니다.
이 글에서는 DCR이 OAuth 전체 흐름에서 어디에 들어가는지, 클라이언트가 어떤 JSON을 보내고 무엇을 돌려받는지, registration_endpoint를 어떻게 발견해야 하는지, 그리고 최근에 자주 비교되는 CIMD와 무엇이 다른지 차근차근 살펴보겠습니다.
OAuth 엔드포인트 전체 지도가 먼저 필요하다면 OAuth 2.0 엔드포인트 제대로 이해하기를 읽고 오시면 좋습니다.
DCR은 토큰 발급이 아니라 클라이언트 등록입니다
DCR을 처음 보면 인가 코드(Authorization Code)나 클라이언트 자격 증명(Client Credentials) 같은 그랜트 유형(grant type)과 헷갈리기 쉽습니다.
하지만 DCR은 액세스 토큰(access token)을 받는 흐름이 아닙니다.
토큰을 받기 전에, 클라이언트가 AS에 자기 자신을 등록해서 client_id를 얻는 흐름입니다.
순서를 단순화하면 이렇습니다.
sequenceDiagram
accTitle: DCR로 클라이언트를 등록한 뒤 OAuth를 시작하는 흐름
accDescr: 클라이언트는 인가 서버 메타데이터를 조회해 등록 엔드포인트를 발견한다. 등록 엔드포인트에 자신의 메타데이터를 보내 client_id와 등록 결과를 받은 뒤 authorize 또는 token 엔드포인트에서 일반 OAuth 흐름을 시작한다.
participant Client as 클라이언트
participant AS as 인가 서버
Client->>AS: 인가 서버 메타데이터 조회
AS-->>Client: registration_endpoint
Client->>AS: 클라이언트 메타데이터 등록
AS-->>Client: client_id와 등록 결과
Client->>AS: /authorize 또는 /token 요청
즉 DCR은 /authorize나 /token보다 앞에 있습니다.
클라이언트가 아직 client_id를 모르는 상태에서 시작하고, DCR이 끝나야 비로소 일반 OAuth 요청에 쓸 식별자를 얻게 됩니다.
이 점 때문에 DCR은 일반적인 “사용자가 로그인하고 동의하는 흐름”과 결이 다릅니다. 사용자의 권한을 얻는 문제가 아니라, AS가 “이 클라이언트를 어떤 리다이렉트 URI, 어떤 인증 방식, 어떤 표시 이름으로 받아들일 것인가”를 결정하는 문제입니다.
registration_endpoint가 있을 때만 시도합니다
DCR을 지원하는 AS는 RFC 8414가 정의한 Authorization Server Metadata에 registration_endpoint를 공개할 수 있습니다.
클라이언트는 먼저 이 메타데이터를 조회해야 합니다.
/register 같은 경로를 추측해서 바로 호출하면 안 됩니다.
{
"issuer": "https://as.example.com",
"authorization_endpoint": "https://as.example.com/oauth2/authorize",
"token_endpoint": "https://as.example.com/oauth2/token",
"registration_endpoint": "https://as.example.com/oauth2/register"
}
여기서 registration_endpoint가 있으면 DCR을 시도할 수 있습니다.
반대로 이 필드가 없다면 해당 AS가 동적 등록을 지원한다고 가정하면 안 됩니다.
그때는 사전 등록된 client_id를 쓰거나, 사용자가 직접 등록 정보를 입력하거나, AS가 지원한다면 CIMD 같은 다른 등록 방식을 써야 합니다.
최근 개방형 OAuth 생태계에서는 보통 이런 순서를 둡니다.
이미 사전 등록된 client_id가 있으면 그걸 먼저 쓰고, 없으면 AS Metadata를 확인합니다.
client_id_metadata_document_supported: true라면 CIMD를 시도하고, registration_endpoint가 있으면 DCR을 시도합니다.
둘 다 없으면 자동 등록은 포기해야 합니다.
이 구분을 놓치면 클라이언트가 엉뚱한 등록 방식을 시도하게 됩니다. CIMD는 AS가 클라이언트 메타데이터 문서를 읽어 오는 방식이고, DCR은 클라이언트가 AS에 등록 요청을 써 넣는 방식입니다. 둘 다 “사전 등록 없이 시작한다”는 문제를 풀지만, AS가 공개해야 하는 메타데이터 신호가 다릅니다.
등록 요청에는 클라이언트 메타데이터를 보냅니다
DCR 요청은 단순합니다.
클라이언트가 registration_endpoint로 HTTP POST를 보내고, 본문에 JSON 형식의 클라이언트 메타데이터를 담습니다.
이 규약은 RFC 7591에 정의되어 있습니다.
POST /oauth2/register HTTP/1.1
Host: as.example.com
Content-Type: application/json
Accept: application/json
{
"redirect_uris": ["https://client.example.com/callback"],
"client_name": "Example Calendar Client",
"client_uri": "https://client.example.com",
"logo_uri": "https://client.example.com/logo.png",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "calendar.read calendar.write",
"token_endpoint_auth_method": "client_secret_basic"
}
먼저 볼 필드는 redirect_uris입니다.
Authorization Code 흐름에서 AS가 authorization code를 돌려보낼 수 있는 콜백 URL 목록이기 때문입니다.
나중에 /authorize 요청에 들어온 redirect_uri는 이 목록과 정확히 일치해야 합니다.
부분 일치나 와일드카드는 보안 사고로 이어지기 쉽습니다.
grant_types와 response_types도 함께 봐야 합니다.
grant_types는 이 클라이언트가 토큰 엔드포인트에서 어떤 grant type을 쓸 수 있는지 선언합니다.
authorization_code, refresh_token, client_credentials 같은 값이 들어갑니다.
response_types는 인가 엔드포인트에서 어떤 응답을 받을지 선언합니다.
Authorization Code 흐름이라면 보통 code입니다.
두 필드는 서로 맞아야 합니다.
예를 들어 grant_types에 authorization_code를 넣었다면 response_types에는 보통 code가 들어가야 자연스럽습니다.
token_endpoint_auth_method는 토큰 엔드포인트에서 클라이언트를 어떻게 인증할지 정합니다.
서버에서 secret을 안전하게 보관할 수 있다면 client_secret_basic이나 private_key_jwt를 쓸 수 있습니다.
SPA, 모바일 앱, 데스크톱 앱처럼 secret을 숨기기 어려운 공개 클라이언트라면 none을 쓸 수 있는데요.
이 경우에는 PKCE가 사실상 핵심 방어선이 됩니다.
client_secret이 없으니 authorization code만 훔친 공격자가 토큰으로 바꾸지 못하도록 code_verifier 검증을 반드시 붙여야 합니다.
client_name, client_uri, logo_uri는 동의 화면에 보이는 사람이 읽는 정보입니다.
보안 필드는 아니지만 사용자가 어떤 앱에 권한을 주는지 판단하는 데 중요합니다.
AS는 이 값을 그대로 믿고 보여주기보다 정책에 따라 검증하거나 제한하는 편이 안전합니다.
초기 액세스 토큰(Initial Access Token)이 필요할 수 있습니다
DCR이라고 해서 항상 아무나 등록할 수 있는 것은 아닙니다. AS는 등록 엔드포인트를 공개(open registration)로 열 수도 있고, 보호(protected registration)할 수도 있습니다.
공개 등록에서는 위 예시처럼 Authorization 헤더 없이 요청할 수 있습니다. 상호 운용성은 좋지만 등록 스팸, 저장소 남용, 가짜 앱 등록 같은 위험이 커집니다. 그래서 실제 운영 환경에서는 보호 등록을 택하는 경우가 많습니다.
보호 등록에서는 초기 액세스 토큰을 함께 보냅니다.
이 토큰은 아직 등록되지 않은 클라이언트의 client_secret이 아닙니다.
등록 엔드포인트를 호출할 권한이 있는 개발자나 배포 주체에게 사전에 발급된 Bearer 토큰입니다.
POST /oauth2/register HTTP/1.1
Host: as.example.com
Content-Type: application/json
Accept: application/json
Authorization: Bearer eyJhbGciOi...
{
"redirect_uris": ["https://client.example.com/callback"],
"client_name": "Example Calendar Client",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
이 구조를 헷갈리면 안 됩니다.
초기 액세스 토큰은 “이 등록 요청을 보내도 되는가”를 판단하는 토큰입니다.
DCR 응답으로 발급되는 client_id와 client_secret은 “이후 OAuth 흐름에서 이 클라이언트가 누구인가”를 나타내는 자격 증명입니다.
서로 쓰이는 시점과 목적이 다릅니다.
등록 응답으로 client_id를 받습니다
등록이 성공하면 AS는 보통 201 Created와 함께 클라이언트 정보를 돌려줍니다.
응답에서 먼저 봐야 할 값은 client_id입니다.
클라이언트는 이제 이 값을 /authorize와 /token 요청에서 사용할 수 있습니다.
HTTP/1.1 201 Created
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"client_id": "s6BhdRkqt3",
"client_secret": "cf136dc3c1fc93f31185e5885805d",
"client_id_issued_at": 1736294400,
"client_secret_expires_at": 1767830400,
"redirect_uris": ["https://client.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic",
"registration_client_uri": "https://as.example.com/oauth2/register/s6BhdRkqt3",
"registration_access_token": "reg-23410913-abewfq.123483"
}
AS는 클라이언트가 요청한 메타데이터를 그대로 받아들일 수도 있고, 정책에 맞게 바꿀 수도 있습니다.
예를 들어 클라이언트가 client_secret_post를 요청했지만 AS가 client_secret_basic만 허용한다면 응답에서 다른 값이 돌아올 수 있습니다.
따라서 클라이언트는 “내가 보낸 값”이 아니라 “AS가 응답으로 확정한 값”을 저장해야 합니다.
client_secret_expires_at도 놓치면 안 됩니다.
이 값이 있으면 secret 만료 시점을 뜻하고, 0이면 만료되지 않는다는 의미입니다.
만료되는 secret을 받았다면 갱신 정책을 별도로 갖춰야 합니다.
공개 클라이언트처럼 token_endpoint_auth_method가 none이면 client_secret이 아예 없을 수 있습니다.
마지막의 registration_client_uri와 registration_access_token은 RFC 7592가 정의한 등록 관리 프로토콜로 이어집니다.
이 값을 지원하는 AS에서는 클라이언트가 나중에 등록 정보를 조회, 수정, 삭제할 수 있습니다.
다만 모든 AS가 RFC 7592까지 구현하는 것은 아니므로, 이 필드가 항상 온다고 기대하면 안 됩니다.
등록 후에는 일반 OAuth 흐름으로 돌아갑니다
DCR이 끝났다고 access token이 생기는 것은 아닙니다.
클라이언트는 이제 막 client_id를 받은 상태입니다.
사용자 권한이 필요한 앱이라면 이후에 Authorization Code + PKCE 흐름을 시작해야 합니다.
GET /authorize
?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&scope=calendar.read
&state=xyz
&code_challenge=...
&code_challenge_method=S256
사용자가 동의하면 authorization code를 받고, /token에서 grant_type=authorization_code로 토큰을 교환합니다.
이때 confidential client라면 DCR 응답으로 받은 client_secret이나 등록한 키로 클라이언트 인증을 합니다.
public client라면 client_secret 없이 client_id와 code_verifier를 보냅니다.
DCR은 여기서 보이지 않지만, 앞에서 등록한 메타데이터가 계속 영향을 줍니다.
redirect_uri가 등록 목록에 없으면 인가 요청이 거절됩니다.
grant_types에 없는 grant type을 쓰면 토큰 요청이 거절됩니다.
token_endpoint_auth_method와 다른 방식으로 인증하면 invalid_client가 날 수 있습니다.
결국 DCR은 이후 OAuth 흐름의 규칙표를 만드는 단계라고 볼 수 있습니다.
DCR과 CIMD는 무엇이 다를까요?
DCR과 CIMD는 같은 문제를 다룹니다. 둘 다 “클라이언트를 미리 등록할 수 없는 환경에서 어떻게 client_id를 얻을 것인가”에 대한 답입니다. 하지만 접근 방식은 꽤 다릅니다.
DCR은 쓰기 모델입니다.
클라이언트가 AS의 registration_endpoint에 JSON을 보내고, AS는 그 결과를 저장합니다.
성공하면 AS가 새 client_id를 발급합니다.
이 모델은 기존 OAuth 서버 구조와 잘 맞습니다.
클라이언트별 정책, secret, 등록 관리, 감사 로그를 AS 데이터베이스에 남길 수 있기 때문입니다.
CIMD는 읽기 모델입니다.
클라이언트가 자기 메타데이터 문서를 HTTPS URL에 올려 두고, 그 URL 자체를 client_id로 씁니다.
AS는 URL을 읽어 검증할 뿐, 클라이언트를 영구 등록하지 않을 수 있습니다.
상태를 덜 쌓는 대신, 외부 URL을 가져오는 과정에서 서버 측 요청 위조(Server-Side Request Forgery, 이하 SSRF) 방어가 중요해집니다.
무엇을 고를지는 운영 모델에 달려 있습니다. AS가 클라이언트별 상태를 저장하고 관리해야 한다면 DCR이 자연스럽습니다. 반대로 수많은 AS에 같은 클라이언트 신원을 들고 다녀야 하고, 상태를 쌓는 비용이 더 큰 개방형 생태계라면 CIMD가 더 잘 맞을 수 있습니다. MCP 최신 스펙이 CIMD를 앞세우고 DCR을 후순위 호환 경로로 둔 이유도 여기에 있습니다.
구현할 때 조심할 점
DCR은 편하지만, 등록 엔드포인트를 열어 둔다는 점에서 공격 표면도 생깁니다. 인가 서버를 구현한다면 몇 가지는 반드시 챙겨야 합니다.
우선 registration_endpoint를 AS Metadata에 공개한 경우에만 DCR을 지원한다고 봐야 합니다.
클라이언트도 이 필드가 없으면 /register를 추측해서 호출하지 않아야 하고, AS도 문서화되지 않은 등록 경로를 우연히 열어 두지 않는 편이 좋습니다.
리다이렉트 URI는 가장 엄격하게 봐야 합니다.
https://client.example.com/callback과 https://client.example.com/callback/은 다른 값입니다.
쿼리 파라미터, 대소문자, 포트까지 포함해 정확히 비교해야 합니다.
와일드카드나 부분 문자열 매칭은 공격자가 authorization code를 자기 도메인으로 빼돌릴 여지를 만듭니다.
공개 등록을 허용한다면 남용 방어가 필요합니다. 등록 요청 크기 제한, 등록 개수 제한, 속도 제한, 로고와 URI 검증, 위험한 리다이렉트 URI 차단 같은 정책이 있어야 합니다. 보호 등록을 쓴다면 Initial Access Token 발급과 회수 정책을 따로 설계해야 합니다.
클라이언트가 보낸 메타데이터를 그대로 믿어도 안 됩니다.
client_name과 logo_uri는 피싱에 악용될 수 있고, jwks_uri는 외부 URL을 가리키므로 가져오기 정책이 필요합니다.
token_endpoint_auth_method, grant_types, scope은 AS 정책과 맞지 않으면 거절하거나 안전한 값으로 바꿔야 합니다.
마지막으로 public client에서는 PKCE를 필수로 보는 편이 안전합니다.
DCR로 token_endpoint_auth_method: "none"인 클라이언트를 등록했다면, 그 클라이언트는 secret으로 자기 자신을 증명하지 못합니다.
Authorization Code 흐름에서는 code_challenge와 code_verifier가 code 탈취를 막는 핵심 장치가 됩니다.
마치며
DCR은 OAuth 클라이언트 등록을 사람이 하는 작업에서 프로토콜로 옮겨 온 표준입니다.
클라이언트는 AS Metadata에서 registration_endpoint를 발견하고, JSON 메타데이터를 POST해서 client_id와 필요한 경우 client_secret을 받습니다.
그 뒤에야 일반적인 Authorization Code, Client Credentials, Refresh Token 같은 OAuth 흐름을 시작할 수 있습니다.
핵심은 두 가지입니다.
첫째, DCR은 access token 발급이 아니라 client_id 발급과 클라이언트 메타데이터 확정 단계입니다.
둘째, DCR은 AS가 registration_endpoint를 공개했을 때만 시도해야 합니다.
이 두 가지만 구분해도 OAuth 자동 등록 흐름을 훨씬 덜 헷갈리게 볼 수 있습니다.
더 자세한 내용은 RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol, RFC 8414 - OAuth 2.0 Authorization Server Metadata, RFC 7592 - OAuth 2.0 Dynamic Client Registration Management Protocol을 참고하세요.
This work is licensed under CC BY 4.0