OAuth 2.1이 OAuth 2.0의 모범 사례를 어떻게 통합했는지, OIDC의 인증 레이어 구조, PKCE와 DPoP의 동작 원리를 코드와 다이어그램으로 설명합니다.
OAuth 2.0은 2012년 RFC 6749로 발표된 이후 인터넷 인가의 사실상 표준이 되었습니다. 하지만 10년 이상 사용되면서 수많은 보안 취약점이 발견되고, 모범 사례(Best Practice)가 별도의 RFC로 축적되었습니다. OAuth 2.1은 이 모범 사례를 하나의 명세로 통합한 것입니다. 이번 장에서는 OAuth 2.1의 핵심 변화와 OIDC, 그리고 최신 보안 메커니즘인 PKCE와 DPoP를 깊이 있게 다룹니다.
OAuth 2.1은 완전히 새로운 프로토콜이 아닙니다. OAuth 2.0의 핵심 개념을 유지하면서, 보안상 문제가 있는 기능을 제거하고 필수 보안 사항을 명세에 포함시킨 것입니다.
Implicit Grant 제거: Implicit Grant는 액세스 토큰을 URL 프래그먼트에 직접 포함시켜 반환하는 방식이었습니다. 브라우저 히스토리에 토큰이 남고, 리퍼러 헤더를 통해 유출될 수 있다는 치명적 문제가 있었습니다.
ROPC(Resource Owner Password Credentials) Grant 제거: 클라이언트 애플리케이션이 사용자의 비밀번호를 직접 수집하는 이 방식은 OAuth의 근본 목적인 "자격 증명 위임"과 모순됩니다. OAuth 2.1에서 완전히 제거되었습니다.
기존 시스템에서 Implicit Grant나 ROPC를 사용 중이라면, Authorization Code + PKCE 플로우로 마이그레이션해야 합니다. 이 두 방식은 보안 감사에서 즉시 지적 사항이 됩니다.
| 항목 | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | 선택 (RFC 7636) | 필수 |
| Refresh Token Rotation | 권장 | 필수 |
| Redirect URI Exact Match | 권장 | 필수 |
| Bearer Token in URI | 허용 | 금지 |
PKCE(Proof Key for Code Exchange)는 원래 모바일 앱의 인가 코드 가로채기 공격을 방지하기 위해 설계되었지만, OAuth 2.1에서는 모든 클라이언트에 필수입니다.
PKCE는 인가 요청을 시작하는 클라이언트와 인가 코드를 교환하는 클라이언트가 동일한지 증명하는 메커니즘입니다.
import crypto from "crypto";
// 1단계: code_verifier 생성
function generateCodeVerifier(): string {
// 43~128자의 URL-safe 랜덤 문자열
return crypto.randomBytes(32)
.toString("base64url")
.substring(0, 128);
}
// 2단계: code_challenge 생성
function generateCodeChallenge(verifier: string): string {
return crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
}
// 3단계: 인가 요청 URL 구성
function buildAuthorizationUrl(config: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scopes: string[];
codeChallenge: string;
state: string;
}): string {
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scopes.join(" "),
code_challenge: config.codeChallenge,
code_challenge_method: "S256",
state: config.state,
});
return `${config.authorizationEndpoint}?${params.toString()}`;
}
// 4단계: 토큰 교환
async function exchangeCodeForTokens(config: {
tokenEndpoint: string;
clientId: string;
code: string;
redirectUri: string;
codeVerifier: string;
}): Promise<TokenResponse> {
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
code: config.code,
redirect_uri: config.redirectUri,
code_verifier: config.codeVerifier,
}),
});
return response.json() as Promise<TokenResponse>;
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
scope: string;
}code_verifier는 클라이언트 측에서만 보관하며 네트워크로 전송하지 않습니다(인가 요청 시에는 code_challenge만 전송). 토큰 교환 시에만 code_verifier를 전송하고, 인가 서버가 SHA256 해시를 비교합니다. 이 구조 덕분에 인가 코드를 탈취해도 토큰 교환이 불가능합니다.
OAuth 2.0은 본질적으로 인가(Authorization) 프로토콜입니다. "이 앱이 내 데이터에 접근해도 되는가?"에 대한 답을 제공하지만, "이 사용자가 누구인가?"에 대한 표준화된 답은 없었습니다. OpenID Connect(OIDC)는 OAuth 2.0 위에 인증(Authentication) 레이어를 추가한 프로토콜입니다.
OIDC의 핵심은 ID 토큰(ID Token)입니다. JWT 형식으로 발급되며, 인증된 사용자에 대한 클레임(Claims)을 담고 있습니다.
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "my-app-client-id",
"exp": 1719903600,
"iat": 1719900000,
"auth_time": 1719899900,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"amr": ["pwd", "otp"],
"at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q",
"name": "홍길동",
"email": "hong@example.com",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}각 클레임의 역할을 정리하면 다음과 같습니다.
| 클레임 | 설명 |
|---|---|
iss | 토큰 발행자 (인가 서버) |
sub | 사용자 고유 식별자 |
aud | 토큰 수신자 (클라이언트 ID) |
exp / iat | 만료 시간 / 발행 시간 |
auth_time | 실제 인증이 수행된 시간 |
nonce | 리플레이 공격 방지용 값 |
acr | 인증 컨텍스트 클래스 |
amr | 사용된 인증 방법 목록 |
at_hash | 액세스 토큰 해시 (토큰 바인딩) |
ID Token과 Access Token은 근본적으로 다른 목적을 가집니다. ID Token은 "사용자가 누구인지" 클라이언트에게 알려주기 위한 것이고, Access Token은 "API에 접근할 권한"을 증명하기 위한 것입니다. ID Token을 API 인증에 사용해서는 안 됩니다.
// ID Token: 클라이언트 앱이 사용자 정보를 확인할 때
function handleIdToken(idToken: string): UserProfile {
const payload = decodeAndVerifyJwt(idToken);
// ID Token의 aud가 내 클라이언트 ID인지 확인
if (payload.aud !== MY_CLIENT_ID) {
throw new Error("ID Token audience mismatch");
}
return {
id: payload.sub,
name: payload.name,
email: payload.email,
};
}
// Access Token: API 서버에 요청할 때
async function callProtectedApi(accessToken: string) {
const response = await fetch("https://api.example.com/data", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.json();
}토큰 인트로스펙션(Token Introspection, RFC 7662)은 리소스 서버가 토큰의 유효성을 인가 서버에 직접 질의하는 메커니즘입니다. 불투명(Opaque) 토큰을 사용하거나, JWT가 만료 전에 폐기되었는지 확인할 때 필수적입니다.
POST /oauth/introspect HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=eyJhbGciOiJSUzI1NiIs...&
token_type_hint=access_token{
"active": true,
"scope": "read write",
"client_id": "my-app",
"username": "hong",
"token_type": "Bearer",
"exp": 1719903600,
"iat": 1719900000,
"sub": "user-123",
"aud": "https://api.example.com",
"iss": "https://auth.example.com"
}토큰 인트로스펙션은 매 API 요청마다 인가 서버에 추가 호출을 발생시킵니다. 성능이 중요한 시스템에서는 인트로스펙션 결과를 짧은 TTL로 캐싱하거나, JWT 자체 검증과 인트로스펙션을 조합하는 전략이 필요합니다.
DPoP(Demonstration of Proof-of-Possession, RFC 9449)는 Bearer 토큰의 근본적 한계를 해결하는 메커니즘입니다. Bearer 토큰은 "소지자가 곧 권한자"입니다. 토큰이 탈취되면 누구든 사용할 수 있습니다. DPoP는 토큰을 특정 클라이언트의 키 쌍에 바인딩하여 이 문제를 해결합니다.
import { SignJWT, generateKeyPair, exportJWK } from "jose";
async function createDPoPProof(config: {
privateKey: CryptoKey;
publicKey: CryptoKey;
method: string; // HTTP 메서드 (GET, POST 등)
url: string; // 요청 대상 URL
accessToken?: string; // 리소스 요청 시 포함
}): Promise<string> {
const publicJwk = await exportJWK(config.publicKey);
const builder = new SignJWT({
htm: config.method,
htu: config.url,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
...(config.accessToken && {
ath: await sha256base64url(config.accessToken),
}),
})
.setProtectedHeader({
alg: "ES256",
typ: "dpop+jwt",
jwk: publicJwk,
});
return builder.sign(config.privateKey);
}
async function sha256base64url(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hash = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}| 특성 | Bearer Token | DPoP Token |
|---|---|---|
| 토큰 탈취 시 위험 | 즉시 악용 가능 | 개인키 없이 사용 불가 |
| 리플레이 공격 | 취약 | jti + iat로 방지 |
| 구현 복잡도 | 낮음 | 중간 |
| 서버 부하 | 낮음 | 서명 검증 추가 |
| 표준화 상태 | RFC 6750 | RFC 9449 |
DPoP는 금융 API(Open Banking)나 의료 시스템처럼 토큰 보안이 매우 중요한 영역에서 먼저 도입되고 있습니다. 일반 웹 애플리케이션에서는 HTTPS + 적절한 토큰 수명 + Refresh Token Rotation이면 충분한 경우가 많습니다.
OIDC는 인가 서버의 설정 정보를 자동으로 발견할 수 있는 Discovery 엔드포인트(Well-Known Configuration)를 표준화했습니다.
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"introspection_endpoint": "https://auth.example.com/introspect",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"scopes_supported": ["openid", "profile", "email"],
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"client_credentials"
],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"private_key_jwt"
],
"dpop_signing_alg_values_supported": ["ES256", "RS256"]
}이 엔드포인트를 활용하면 클라이언트가 인가 서버의 모든 URL과 지원 기능을 동적으로 파악할 수 있어, 설정 하드코딩을 최소화할 수 있습니다.
OAuth 2.1에서 Refresh Token Rotation이 필수화된 이유는 토큰 탈취 감지를 가능하게 하기 때문입니다. 한 번 사용된 Refresh Token이 재사용되면, 인가 서버는 해당 토큰 체인 전체를 무효화합니다.
async function refreshAccessToken(
refreshToken: string,
clientId: string,
tokenEndpoint: string,
): Promise<TokenResponse> {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
}),
});
if (!response.ok) {
// Refresh Token이 무효화된 경우 재인증 필요
if (response.status === 400) {
throw new ReauthenticationRequiredError(
"Refresh token이 폐기되었습니다. 재로그인이 필요합니다."
);
}
throw new TokenRefreshError("토큰 갱신 실패");
}
const tokens: TokenResponse = await response.json();
// 새로운 refresh_token을 저장하고, 기존 것은 폐기
await storeRefreshToken(tokens.refresh_token);
return tokens;
}OAuth 2.1은 10년간 축적된 보안 모범 사례를 하나의 명세로 통합한 것이며, OIDC는 이 위에 표준화된 인증 레이어를 제공합니다. PKCE는 인가 코드 탈취를 방지하고, DPoP는 토큰 자체의 도난 위험을 줄여줍니다.
이 프로토콜들은 이후 장에서 다룰 모든 주제의 기반이 됩니다. 다음 장에서는 비밀번호를 아예 없애는 방향으로 진화 중인 패스키(Passkeys)와 WebAuthn을 살펴봅니다.
이 글이 도움이 되셨나요?
WebAuthn 프로토콜과 FIDO2 표준의 구조, 패스키의 등록 및 인증 플로우, 플랫폼 인증기와 크로스 디바이스 인증의 동작 원리를 설명합니다.
HTTP Basic Auth부터 토큰 기반 인증, 패스키까지 인증과 권한 관리의 역사를 짚어보고, 아이덴티티와 인증, 인가의 개념을 명확히 구분합니다.
Keycloak, Auth0, Zitadel의 아키텍처와 기능을 비교하고, 셀프 호스팅과 SaaS 간의 트레이드오프, 렐름/테넌트 설계, 페더레이션 전략을 정리합니다.