OAuth 2.0 엔드포인트 제대로 이해하기
OAuth 2.0을 처음 배울 때는 “authorization code를 받아서 access token으로 바꾼다”는 큰 그림만 머리에 담아두어도 충분한데요.
하지만 실제로 연동을 구현하거나 디버깅하는 단계로 넘어가면, 어떤 엔드포인트에 어떤 파라미터를 어떤 조건으로 보내야 하는가가 성패를 좌우합니다.
“동의 화면까지는 뜨는데 토큰 교환에서 invalid_grant가 뜬다”, “리다이렉트가 자꾸 invalid_redirect_uri로 거부된다” 같은 이슈는 거의 전부 특정 엔드포인트의 파라미터 규약을 놓쳐서 생기거든요.
OAuth 2.0 자체가 처음이라면 OAuth 2.0 쉽게 이해하기부터 읽고 오시면 좋습니다.
이 글은 개념은 이미 알고 있다는 전제로, /authorize·/token·/revoke·/introspect·/register 다섯 엔드포인트의 요청과 응답, 자주 마주치는 에러, 그리고 클라이언트 인증 방식까지 한 자리에 정리합니다.
엔드포인트 한눈에 보기
OAuth 2.0이 정의한 엔드포인트는 크게 다섯 개로 좁혀집니다.
Authorization Endpoint와 Token Endpoint는 RFC 6749에서 인가 흐름의 양대 축으로 정의되었고, Revocation Endpoint는 RFC 7009에서, Introspection Endpoint는 RFC 7662에서, Client Registration Endpoint는 RFC 7591에서 나중에 덧붙여졌습니다.
앞의 두 개는 거의 모든 AS(Authorization Server)가 제공하지만, 뒤의 세 개는 선택입니다.
토큰의 수명 주기를 다루는 네 엔드포인트(/authorize·/token·/revoke·/introspect)와, 클라이언트 자체의 수명 주기를 다루는 /register로 역할을 나눠보면 머리에 잘 들어옵니다.
호출 채널도 구분해두면 좋습니다.
/authorize는 사용자의 브라우저를 통한 front channel로 호출되고, 나머지 넷은 서버 간 직접 통신인 back channel로 호출됩니다.
브라우저 히스토리나 Referer 헤더에 민감한 값이 노출되지 않도록 하려는 설계입니다.
경로 이름은 관용일 뿐 스펙이 강제하지는 않습니다.
어떤 AS는 /oauth2/v1/authorize를 쓰고, 다른 AS는 /oauth/authorize를 씁니다.
그래서 최근에는 경로를 하드코딩하지 않고 OAuth 메타데이터 엔드포인트에서 런타임에 조회하는 게 모범 사례로 자리 잡았는데요.
이 글에서는 경로보다는 엔드포인트별 역할에 집중하겠습니다.
Authorization Endpoint
/authorize는 사용자를 리다이렉트시켜 동의를 받는 엔드포인트로, HTTP GET으로 호출합니다.
쿼리 파라미터만으로 의도를 전달하기 때문에, 파라미터 이름을 외워두면 디버깅이 훨씬 편해집니다.
필수 파라미터부터 살펴볼까요?
response_type은 어떤 응답을 원하는지 지정하는데, OAuth 2.1 기준으로는 사실상 code 하나만 사용합니다.
client_id는 사전 등록된 클라이언트 식별자, redirect_uri는 인가가 끝난 뒤 사용자를 돌려보낼 주소입니다.
여기서 redirect_uri는 등록해둔 값과 정확히 일치(exact match) 해야 합니다.
경로 끝 슬래시 하나 차이로 invalid_redirect_uri가 뜨는 일이 생각보다 흔해요.
권장 파라미터도 함께 챙겨야 합니다.
scope은 요청할 권한 범위를 공백으로 구분해 나열하고, state는 CSRF 방어를 위해 클라이언트가 만든 무작위 문자열입니다.
콜백에서 돌아온 state가 보낸 것과 같은 값인지 반드시 검증해야 해요.
PKCE를 쓸 때는 code_challenge와 code_challenge_method=S256도 함께 보냅니다.
OAuth 2.1부터는 모든 클라이언트에 PKCE가 의무이므로 이 두 파라미터는 사실상 필수로 보셔도 됩니다.
GET /authorize
?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
&scope=openid%20calendar.read
&state=xyz
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: as.example.com
성공 응답은 redirect URI에 code와 state를 쿼리 파라미터로 붙여서 돌아옵니다.
실패 응답 역시 쿼리 파라미터로 에러 정보가 실려 오는데, 자주 마주치는 에러 코드는 네 가지입니다.
invalid_request는 파라미터 형식이 잘못된 경우, unauthorized_client는 이 클라이언트가 해당 response_type을 쓸 수 없는 경우, access_denied는 사용자가 동의를 거부한 경우, invalid_scope은 요청한 스코프가 유효하지 않은 경우에 해당합니다.
Token Endpoint
/token은 HTTP POST로 호출하며 본문은 application/x-www-form-urlencoded 형식입니다.
가장 헷갈리는 부분이 grant_type에 따라 파라미터 집합이 완전히 달라진다는 점인데요.
OAuth 2.1에서 살아남은 grant_type은 대략 네 가지로 추릴 수 있습니다.
authorization_code— 앞서 받은code를 토큰으로 교환.code,redirect_uri,client_id필수. PKCE를 썼다면code_verifier도 포함.redirect_uri는 인가 요청에 썼던 값과 정확히 같아야 함refresh_token— 만료된 access token을 갱신.refresh_token값을 보내면 새 access token과 (rotation이 활성화된 경우) 새 refresh token이 반환됨. 공개 클라이언트는 rotation이 의무이므로 받자마자 기존 refresh token을 폐기해야 함client_credentials— 사용자 없이 클라이언트 자신의 권한으로 토큰을 발급. 서버 간 통신(machine-to-machine)에서 씀. 이 방식에서는 refresh token을 발급하지 않는 게 원칙urn:ietf:params:oauth:grant-type:token-exchange— RFC 8693이 정의한 토큰 교환. 한 토큰을 다른 audience용 토큰으로 변환할 때 쓰며, MCP나 서비스 메시 맥락에서 자주 등장
POST /token HTTP/1.1
Host: as.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
성공 응답은 JSON이고, 핵심 필드는 access_token, token_type, expires_in 세 가지입니다.
token_type은 거의 항상 Bearer이지만, DPoP를 쓴다면 DPoP가 올 수 있어요.
오래 가는 세션이 필요하다면 refresh_token이 함께 돌아오고, 스코프가 축소되어 발급된 경우에는 AS가 scope 필드로 실제 승인된 범위를 알려줍니다.
{
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"scope": "calendar.read"
}
에러 응답은 HTTP 400과 함께 JSON으로 돌아오며 error와 선택적으로 error_description이 담깁니다.
자주 보는 에러는 invalid_grant(코드·토큰이 만료·재사용·불일치), invalid_client(클라이언트 인증 실패), unsupported_grant_type, invalid_scope 정도입니다.
이 중 invalid_grant가 가장 까다롭습니다.
세 가지 원인이 한 코드 아래 묶여 있어서 로그만 봐서는 어떤 상황인지 짚어내기 어렵거든요.
“코드 재사용인지, 만료인지, redirect_uri 불일치인지”를 구분하려면 AS 쪽 로그를 함께 보거나 요청을 재구성해 검증하는 게 빠릅니다.
Token Revocation Endpoint
RFC 7009가 정의한 /revoke는 토큰을 서버 측에서 무효화하는 용도입니다.
token 파라미터에 폐기할 토큰을 넣고, 선택적으로 token_type_hint로 access_token 또는 refresh_token을 명시해 서버가 빨리 찾도록 돕습니다.
POST /revoke HTTP/1.1
Host: as.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
token=45ghiukldjahdnhzdauz
&token_type_hint=refresh_token
응답이 재미있는데요.
성공이든 실패든 기본적으로 200 OK를 반환하는 게 원칙입니다.
토큰이 이미 만료되었거나 애초에 존재하지 않아도 성공으로 처리하는데, 이는 공격자가 토큰 존재 여부를 추측하지 못하게 막으려는 방어적 설계입니다.
유일하게 400이 나올 수 있는 경우는 클라이언트 인증 실패(invalid_client)일 때입니다.
refresh token을 폐기하면 그 refresh token으로 발급되었던 access token들도 함께 무효화하는 게 권장 사항이지만, 스펙상으로는 구현자의 재량으로 남겨져 있습니다. 이 부분은 로그아웃 UX를 설계할 때 직접 AS 문서를 확인해봐야 하는 지점입니다.
Token Introspection Endpoint
RFC 7662가 정의한 /introspect는 Resource Server가 access token의 유효성과 메타데이터를 조회할 때 씁니다.
주로 불투명(opaque) 토큰을 쓸 때 필요한데, token 파라미터만으로 POST 요청을 보내면 됩니다.
{
"active": true,
"scope": "calendar.read",
"client_id": "s6BhdRkqt3",
"username": "dale",
"exp": 1714153600,
"aud": "https://api.example.com",
"iss": "https://as.example.com"
}
응답의 첫 단서는 active 필드입니다.
true면 유효한 토큰, false면 무효한 토큰이라는 뜻인데요.
유효할 때는 scope, client_id, username, exp, aud, iss 같은 필드가 함께 돌아오고, 무효할 때는 {"active": false}만 반환해야 합니다.
무효 토큰에 대해 추가 정보를 노출하면 공격자에게 유효한 client_id 공간을 스캔할 단서를 주는 셈이기 때문이에요.
다만 introspection은 API 호출 때마다 AS를 왕복하게 만들어 성능 부담이 큽니다. 그래서 요즘은 JWT 형식의 access token(RFC 9068)을 써서 Resource Server가 매 요청마다 AS를 호출하지 않고 자체적으로 서명을 검증하는 방식이 기본이고, introspection은 불투명 토큰을 쓰거나 실시간 폐기 반영이 중요한 영역(금융·의료 등)에서 주로 쓰입니다.
Client Registration Endpoint
RFC 7591이 정의한 /register는 클라이언트가 사전 협의 없이 AS에 자기 자신을 동적으로 등록할 때 쓰는 엔드포인트입니다.
앞의 네 엔드포인트가 토큰의 수명 주기를 다룬다면, /register는 클라이언트 자체의 수명 주기를 다룬다는 점에서 결이 다릅니다.
요청은 HTTP POST에 application/json 본문을 실어 보냅니다.
본문에는 클라이언트 메타데이터를 담는데, 주요 필드는 다음과 같습니다.
redirect_uris— 허용할 리다이렉트 URI 목록. authorize 요청의 exact-match 검증에 쓰임token_endpoint_auth_method— 아래 섹션에서 다룰 클라이언트 인증 방식 중 하나를 미리 선언grant_types/response_types— 이 클라이언트가 쓸 grant type과 response type 선언client_name/client_uri/logo_uri— 동의 화면에 노출되는 사람이 읽는 메타데이터scope— 이 클라이언트가 요청할 수 있는 스코프 범위
POST /register HTTP/1.1
Host: as.example.com
Content-Type: application/json
Authorization: Bearer ey...initial-access-token
{
"redirect_uris": ["https://client.example.com/cb"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_name": "Dale's MCP Client",
"scope": "calendar.read"
}
Authorization 헤더의 Initial Access Token은 선택 요소입니다. AS가 등록을 공개(open registration)로 열어둔 경우에는 필요 없고, 보호(protected registration)하는 경우에는 사전에 발급된 Bearer 토큰을 함께 보내야 해요. 스팸성 등록을 막으려는 정책인데, 엔터프라이즈 AS는 대부분 보호 모드를 씁니다. 이 토큰은 “아직 존재하지 않는 클라이언트”를 인증할 수는 없으니 클라이언트 인증(client authentication)과는 다른 층위로 생각하시면 됩니다.
성공 응답은 201 Created와 함께 JSON으로 돌아옵니다.
{
"client_id": "s6BhdRkqt3",
"client_secret": "cf136dc3c1fc93f31185e5885805d",
"client_id_issued_at": 1714153600,
"client_secret_expires_at": 1745689600,
"registration_access_token": "reg-...",
"registration_client_uri": "https://as.example.com/register/s6BhdRkqt3",
"redirect_uris": ["https://client.example.com/cb"],
"token_endpoint_auth_method": "none"
}
client_id와 client_secret은 앞으로 /token 호출에 쓸 자격 증명이고, client_secret_expires_at이 0이면 만료가 없다는 의미입니다.
그리고 registration_access_token과 registration_client_uri는 RFC 7592가 정의한 관리 프로토콜로 넘어가는 고리입니다.
이 두 값을 이용하면 등록된 클라이언트 정보를 GET으로 조회하고, PUT으로 갱신하고, DELETE로 삭제할 수 있어요.
에러 응답은 400과 함께 error와 error_description이 담겨서 돌아옵니다.
자주 마주치는 코드는 invalid_redirect_uri, invalid_client_metadata, invalid_software_statement 세 가지입니다.
특히 redirect_uris와 grant_types·response_types의 조합이 맞지 않을 때 invalid_client_metadata가 많이 뜨므로, 스펙에서 허용하는 조합을 먼저 확인하는 습관이 중요합니다.
클라이언트 인증 방식
/token·/revoke·/introspect는 모두 클라이언트 인증을 요구합니다.
스펙은 여러 방식을 허용하는데, 보통 AS Metadata의 token_endpoint_auth_methods_supported 필드로 “이 AS가 지원하는 방식”을 알려줍니다.
대칭 키 기반부터 비대칭 키 기반까지 정리하면 대략 이렇습니다.
client_secret_basic—client_id:client_secret을 Base64로 인코딩해 HTTP Basic 헤더로 전송. 스펙이 가장 권장하는 형태client_secret_post— body에client_id와client_secret을 폼 파라미터로 전송. URL 로깅 등에 새어 나갈 위험이 있어 덜 선호됨none— 공개 클라이언트(SPA·모바일 앱)가 secret 없이 호출하는 경우. 이때는 PKCE가 보안의 전부client_secret_jwt— 대칭 키(client_secret)로 서명한 JWT를client_assertion에 담아 전송. secret이 본문이 아닌 서명 형태로만 노출됨private_key_jwt— 클라이언트의 개인 키로 서명한 JWT를 전송. 대칭 키 유출 위험을 근본적으로 제거tls_client_auth/self_signed_tls_client_auth— mTLS로 클라이언트를 인증. Open Banking 같은 규제 영역에서 사용
개인 프로젝트라면 client_secret_basic으로도 충분하지만, 공개 클라이언트라면 none과 PKCE 조합이 표준입니다.
엔터프라이즈 환경이라면 private_key_jwt나 mTLS가 점점 기본값이 되어가는 추세인데, 키 순환이 자동화되고 유출 위험이 훨씬 낮기 때문입니다.
마치며
OAuth 2.0의 다섯 엔드포인트는 역할이 명확히 나뉘어 있어서, 한 번 지도를 그려두면 구현과 디버깅이 훨씬 수월해집니다.
토큰의 수명 주기는 /authorize·/token·/revoke·/introspect가, 클라이언트 자체의 수명 주기는 /register가 담당한다는 구도만 기억해두면 전체 그림을 잃지 않습니다.
새 연동을 붙일 때는 먼저 AS가 어떤 엔드포인트와 어떤 클라이언트 인증 방식을 지원하는지 파악하고, grant_type 선택부터 에러 코드 처리까지 스펙이 규정한 대로 따라가는 게 가장 빠른 길이에요.
특히 MCP Authentication처럼 클라이언트가 런타임에 새로 등장하는 환경에서는 /register가 연동의 출발점이 됩니다. 사용자가 새 서버를 연결할 때마다 클라이언트를 사전 등록해둘 수 없으니, 동적 등록이 없으면 연동 자체가 성립하지 않으니까요.
실전에서는 이 엔드포인트들의 경로를 하드코딩하지 않는 것이 최근의 모범 사례입니다.
.well-known 메타데이터로 동적으로 발견하는 방식이 표준으로 자리 잡았는데, 이 이야기는 OAuth 2.0 메타데이터와 엔드포인트 동적 발견에서 이어서 다룹니다.
OAuth 2.1 기반의 최신 사례가 궁금하다면 MCP Authentication도 함께 읽어보시면 좋겠습니다.
더 자세한 내용은 RFC 6749 - The OAuth 2.0 Authorization Framework와 RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol을 참고하세요.
This work is licensed under
CC BY 4.0