본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout
TechProjectsBooksAbout
© 2026 Kreath. All rights reserved.
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 2장: OAuth 2.1과 OIDC - 현대 인증의 기반
2026년 6월 12일·아키텍처·

2장: OAuth 2.1과 OIDC - 현대 인증의 기반

OAuth 2.1이 OAuth 2.0의 모범 사례를 어떻게 통합했는지, OIDC의 인증 레이어 구조, PKCE와 DPoP의 동작 원리를 코드와 다이어그램으로 설명합니다.

16분1,105자1개 섹션
securityprotocoldesign-patternsinfrastructureperformance
공유
modern-auth2 / 10
12345678910
이전1장: 인증과 권한 관리의 진화 - 전통에서 현대로다음3장: Passkeys와 WebAuthn - 비밀번호 없는 미래

OAuth 2.1과 OIDC - 현대 인증의 기반

OAuth 2.0은 2012년 RFC 6749로 발표된 이후 인터넷 인가의 사실상 표준이 되었습니다. 하지만 10년 이상 사용되면서 수많은 보안 취약점이 발견되고, 모범 사례(Best Practice)가 별도의 RFC로 축적되었습니다. OAuth 2.1은 이 모범 사례를 하나의 명세로 통합한 것입니다. 이번 장에서는 OAuth 2.1의 핵심 변화와 OIDC, 그리고 최신 보안 메커니즘인 PKCE와 DPoP를 깊이 있게 다룹니다.

OAuth 2.0에서 2.1로: 무엇이 달라졌는가

OAuth 2.1은 완전히 새로운 프로토콜이 아닙니다. OAuth 2.0의 핵심 개념을 유지하면서, 보안상 문제가 있는 기능을 제거하고 필수 보안 사항을 명세에 포함시킨 것입니다.

제거된 기능들

Implicit Grant 제거: Implicit Grant는 액세스 토큰을 URL 프래그먼트에 직접 포함시켜 반환하는 방식이었습니다. 브라우저 히스토리에 토큰이 남고, 리퍼러 헤더를 통해 유출될 수 있다는 치명적 문제가 있었습니다.

ROPC(Resource Owner Password Credentials) Grant 제거: 클라이언트 애플리케이션이 사용자의 비밀번호를 직접 수집하는 이 방식은 OAuth의 근본 목적인 "자격 증명 위임"과 모순됩니다. OAuth 2.1에서 완전히 제거되었습니다.

Warning

기존 시스템에서 Implicit Grant나 ROPC를 사용 중이라면, Authorization Code + PKCE 플로우로 마이그레이션해야 합니다. 이 두 방식은 보안 감사에서 즉시 지적 사항이 됩니다.

필수화된 보안 사항

항목OAuth 2.0OAuth 2.1
PKCE선택 (RFC 7636)필수
Refresh Token Rotation권장필수
Redirect URI Exact Match권장필수
Bearer Token in URI허용금지

Authorization Code + PKCE 플로우

PKCE(Proof Key for Code Exchange)는 원래 모바일 앱의 인가 코드 가로채기 공격을 방지하기 위해 설계되었지만, OAuth 2.1에서는 모든 클라이언트에 필수입니다.

PKCE의 동작 원리

PKCE는 인가 요청을 시작하는 클라이언트와 인가 코드를 교환하는 클라이언트가 동일한지 증명하는 메커니즘입니다.

구현 코드

pkce-flow.ts
typescript
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;
}
Tip

code_verifier는 클라이언트 측에서만 보관하며 네트워크로 전송하지 않습니다(인가 요청 시에는 code_challenge만 전송). 토큰 교환 시에만 code_verifier를 전송하고, 인가 서버가 SHA256 해시를 비교합니다. 이 구조 덕분에 인가 코드를 탈취해도 토큰 교환이 불가능합니다.

OpenID Connect (OIDC)

OAuth 2.0은 본질적으로 인가(Authorization) 프로토콜입니다. "이 앱이 내 데이터에 접근해도 되는가?"에 대한 답을 제공하지만, "이 사용자가 누구인가?"에 대한 표준화된 답은 없었습니다. OpenID Connect(OIDC)는 OAuth 2.0 위에 인증(Authentication) 레이어를 추가한 프로토콜입니다.

OAuth 2.0 vs OIDC

ID Token의 구조

OIDC의 핵심은 ID 토큰(ID Token)입니다. JWT 형식으로 발급되며, 인증된 사용자에 대한 클레임(Claims)을 담고 있습니다.

ID Token Payload 예시
json
{
  "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 vs Access Token

Info

ID Token과 Access Token은 근본적으로 다른 목적을 가집니다. ID Token은 "사용자가 누구인지" 클라이언트에게 알려주기 위한 것이고, Access Token은 "API에 접근할 권한"을 증명하기 위한 것입니다. ID Token을 API 인증에 사용해서는 안 됩니다.

토큰 사용 예시
typescript
// 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)

토큰 인트로스펙션(Token Introspection, RFC 7662)은 리소스 서버가 토큰의 유효성을 인가 서버에 직접 질의하는 메커니즘입니다. 불투명(Opaque) 토큰을 사용하거나, JWT가 만료 전에 폐기되었는지 확인할 때 필수적입니다.

Token Introspection 요청
http
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
Token Introspection 응답
json
{
  "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"
}
Warning

토큰 인트로스펙션은 매 API 요청마다 인가 서버에 추가 호출을 발생시킵니다. 성능이 중요한 시스템에서는 인트로스펙션 결과를 짧은 TTL로 캐싱하거나, JWT 자체 검증과 인트로스펙션을 조합하는 전략이 필요합니다.

DPoP: 토큰 도난 방지의 최전선

DPoP(Demonstration of Proof-of-Possession, RFC 9449)는 Bearer 토큰의 근본적 한계를 해결하는 메커니즘입니다. Bearer 토큰은 "소지자가 곧 권한자"입니다. 토큰이 탈취되면 누구든 사용할 수 있습니다. DPoP는 토큰을 특정 클라이언트의 키 쌍에 바인딩하여 이 문제를 해결합니다.

DPoP 동작 원리

DPoP Proof 생성

dpop-proof.ts
typescript
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(/=+$/, "");
}

DPoP vs Bearer Token 비교

특성Bearer TokenDPoP Token
토큰 탈취 시 위험즉시 악용 가능개인키 없이 사용 불가
리플레이 공격취약jti + iat로 방지
구현 복잡도낮음중간
서버 부하낮음서명 검증 추가
표준화 상태RFC 6750RFC 9449
Tip

DPoP는 금융 API(Open Banking)나 의료 시스템처럼 토큰 보안이 매우 중요한 영역에서 먼저 도입되고 있습니다. 일반 웹 애플리케이션에서는 HTTPS + 적절한 토큰 수명 + Refresh Token Rotation이면 충분한 경우가 많습니다.

OIDC Discovery와 자동 설정

OIDC는 인가 서버의 설정 정보를 자동으로 발견할 수 있는 Discovery 엔드포인트(Well-Known Configuration)를 표준화했습니다.

/.well-known/openid-configuration 응답 예시
json
{
  "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과 지원 기능을 동적으로 파악할 수 있어, 설정 하드코딩을 최소화할 수 있습니다.

실전 설계 시 고려사항

Grant Type 선택 가이드

Refresh Token 보안

OAuth 2.1에서 Refresh Token Rotation이 필수화된 이유는 토큰 탈취 감지를 가능하게 하기 때문입니다. 한 번 사용된 Refresh Token이 재사용되면, 인가 서버는 해당 토큰 체인 전체를 무효화합니다.

refresh-token-rotation.ts
typescript
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을 살펴봅니다.

이 글이 도움이 되셨나요?

관련 글

아키텍처

3장: Passkeys와 WebAuthn - 비밀번호 없는 미래

WebAuthn 프로토콜과 FIDO2 표준의 구조, 패스키의 등록 및 인증 플로우, 플랫폼 인증기와 크로스 디바이스 인증의 동작 원리를 설명합니다.

2026년 6월 15일·16분
아키텍처

1장: 인증과 권한 관리의 진화 - 전통에서 현대로

HTTP Basic Auth부터 토큰 기반 인증, 패스키까지 인증과 권한 관리의 역사를 짚어보고, 아이덴티티와 인증, 인가의 개념을 명확히 구분합니다.

2026년 6월 9일·18분
아키텍처

4장: Keycloak과 Auth0 - 아이덴티티 플랫폼 비교

Keycloak, Auth0, Zitadel의 아키텍처와 기능을 비교하고, 셀프 호스팅과 SaaS 간의 트레이드오프, 렐름/테넌트 설계, 페더레이션 전략을 정리합니다.

2026년 6월 18일·17분
이전 글1장: 인증과 권한 관리의 진화 - 전통에서 현대로
다음 글3장: Passkeys와 WebAuthn - 비밀번호 없는 미래

댓글

목차

약 16분 남음
  • OAuth 2.1과 OIDC - 현대 인증의 기반
    • OAuth 2.0에서 2.1로: 무엇이 달라졌는가
      • 제거된 기능들
      • 필수화된 보안 사항
    • Authorization Code + PKCE 플로우
      • PKCE의 동작 원리
      • 구현 코드
    • OpenID Connect (OIDC)
      • OAuth 2.0 vs OIDC
      • ID Token의 구조
      • ID Token vs Access Token
    • 토큰 인트로스펙션(Token Introspection)
    • DPoP: 토큰 도난 방지의 최전선
      • DPoP 동작 원리
      • DPoP Proof 생성
      • DPoP vs Bearer Token 비교
    • OIDC Discovery와 자동 설정
    • 실전 설계 시 고려사항
      • Grant Type 선택 가이드
      • Refresh Token 보안
    • 마치며