Back

DPoP 완전 정복: 탈취된 OAuth 토큰을 쓸모없게 만드는 방법

액세스 토큰은 베어러 토큰이에요. 토큰 문자열을 손에 넣은 사람이라면, 로그 파일에서 훔쳤든 CDN이 뚫렸든 XSS를 이용했든 중간자 공격이든, 정당한 유저처럼 그대로 쓸 수 있다는 뜻이거든요. 토큰은 누가 들고 있는지 모르고, 알고 싶지도 않아요.

이게 현대 OAuth 배포의 근본적인 보안 약점이고, 10년 넘게 공공연한 비밀이었거든요. 보안 감사 때마다 지적되고, 위협 모델링 할 때마다 인정하면서도 실질적인 해결책은 너무 복잡하거나(브라우저에서 mTLS?) 너무 제한적(짧은 토큰 수명은 폭발 반경만 줄여줄 뿐 폭발 자체를 막지는 못하죠)이었어요.

DPoP(Demonstrating Proof-of-Possession)가 이 판을 바꿔요. RFC 9449로 정의됐고, FAPI 2.0에서 필수인 sender-constrained 토큰을 구현하는 두 가지 방법 중 하나(나머지는 mTLS)이죠. 핵심 원리는 간단해요. 토큰을 클라이언트의 개인 키에 암호학적으로 바인딩하는 거예요. 토큰만 갖고 있어서는 아무 소용이 없어요. 모든 API 요청마다 "나 진짜 이 키 갖고 있어"라는 암호학적 증명을 같이 보내야 하거든요. 탈취된 토큰? 그냥 쓸모없는 문자열이에요.

이 가이드에서는 전부 다뤄볼게요. 베어러 토큰이 왜 근본적으로 위험한지, DPoP의 암호학이 어떻게 동작하는지, TypeScript로 클라이언트와 서버 양쪽을 완전 구현하는 법, 리플레이 방지를 위한 논스 처리, 플랫폼별 키 저장 전략, 그리고 베어러에서 sender-constrained 토큰으로의 실전 마이그레이션 경로까지.


베어러 토큰 문제의 본질

베어러 토큰은 현금이랑 똑같아요. 지폐를 들고 있는 사람이 곧 주인. 신분증 확인도 없고, PIN도 없고, 생체인증도 없어요. 일부러 이렇게 만든 거거든요. RFC 6750 정의 자체가 "토큰을 보유한 누구든 암호학적 키 소유를 증명하지 않고도 리소스에 접근 가능"이에요.

그 단순함 덕분에 OAuth 2.0 도입이 빨랐지만, 토큰 탈취가 치명적으로 효과적이게 된 것도 사실이에요.

토큰이 탈취되는 경로

공격 표면이 꽤 넓어요:

토큰 유출 벡터:
  1. XSS → document.cookie나 localStorage 읽기
  2. 로그 수집 → URL 파라미터나 헤더에 토큰이 로그에 찍힘
  3. CDN/프록시 → 중간 서비스가 Authorization 헤더를 캐시하거나 로깅
  4. 브라우저 확장 → 악성 확장프로그램이 요청 헤더를 읽음
  5. 중간자 공격 → 손상된 TLS 종단
  6. 의존성 공급망 → 감염된 npm 패키지가 토큰을 빼돌림

2025년 Verizon DBIR 보고서에 따르면 토큰 탈취가 엔터프라이즈 환경에서 MFA 우회 기법의 31%를 차지하고, 탈취된 자격 증명은 전체 침해의 22%에 존재해요. 짧은 토큰 수명이 창을 줄여주긴 하지만, 15분짜리 액세스 토큰도 공격자한테는 15분간의 전체 API 접근인 거거든요. 리프레시 토큰 로테이션도 도움이 되지만, 로테이션 전에 리프레시 토큰 자체가 탈취되면 장기 접근이 가능해져요.

기존 대응책이 부족한 이유

대응책한계
짧은 토큰 수명창은 줄이지만 탈취 자체를 막지 못해요. 5분 토큰도 5분간의 접근이에요.
리프레시 토큰 로테이션레이스 컨디션: 공격자가 로테이션 전에 사용. 발급 시점에 탈취되면 무용지물.
토큰 바인딩 (RFC 8471)브라우저 지원이 전혀 안 돼서 사실상 사망.
mTLS (RFC 8705)보안성은 뛰어나지만 브라우저 클라이언트에서는 비현실적. 인증서 관리 부담이 막대.
HttpOnly 쿠키XSS는 방어하지만 CSRF 위험이 생기고 크로스 오리진 API에서는 안 먹혀요.

DPoP가 딱 이 빈자리를 채워요. TLS 클라이언트 인증서 없이 브라우저, 모바일 앱, 서버 간 통신 전부에서 돌아가는 애플리케이션 레이어 보안이죠.


DPoP는 어떻게 동작할까요?

개념 자체는 단순해요. 클라이언트가 키 쌍을 만들고, 매 요청마다 "나 이 키 갖고 있어"를 증명하고, 서버가 토큰을 그 키에 묶는 거예요:

DPoP 플로우:
  1. 클라이언트가 비대칭 키 쌍 생성 (예: EC P-256)
  2. 클라이언트가 인가 서버에 토큰 요청
     → 개인 키로 서명한 DPoP 증명 JWT 포함
     → 증명에 포함: HTTP 메서드, 대상 URL, 고유 ID, 타임스탬프
  3. 인가 서버가 증명 검증 후 토큰 발급
     → 토큰에 공개 키 썸프린트를 담은 'cnf' 클레임 포함
  4. 클라이언트가 리소스 서버 호출
     → 토큰 + 새로운 DPoP 증명 (서명됨, 토큰 해시 포함)
  5. 리소스 서버가 검증:
     → 증명 서명이 토큰에 바인딩된 키와 일치하는지
     → HTTP 메서드와 URL이 실제 요청과 일치하는지
     → 증명이 신선한지 (타임스탬프 + 선택적 논스)
     → 토큰 해시가 제시된 토큰과 일치하는지

핵심은 이거예요. 공격자가 액세스 토큰을 탈취해도, 개인 키가 없으면 유효한 DPoP 증명을 만들 수가 없어요. 토큰을 훔쳐봤자 아무 쓸모가 없는 거죠.

DPoP 증명 JWT 구조

모든 요청에는 특정 구조를 가진 서명된 JWT인 DPoP 증명이 포함돼요:

DPoP 증명 JWT 구조:

HEADER:
{
  "typ": "dpop+jwt",        // 반드시 이 값이어야 함
  "alg": "ES256",           // 비대칭 알고리즘 (ES256, RS256 등)
  "jwk": {                  // 공개 키 (토큰 요청 시)
    "kty": "EC",
    "crv": "P-256",
    "x": "...",
    "y": "..."
  }
}

PAYLOAD:
{
  "htm": "POST",            // 요청의 HTTP 메서드
  "htu": "https://auth.example.com/token",  // 대상 URL
  "iat": 1712400000,        // 발급 시각 (Unix 타임스탬프)
  "jti": "unique-id-abc123", // 고유 ID (리플레이 방지)
  "ath": "fUHyO2r2Z3DZ..."  // 액세스 토큰 해시 (리소스 서버 요청 시)
  "nonce": "server-nonce"   // 서버 제공 논스 (요구 시)
}

두 가지 핵심 클레임이 DPoP를 일반 JWT와 구분해요:

  1. ath (Access Token Hash): 리소스 서버 호출 시에만 존재해요. 액세스 토큰의 SHA-256 해시를 base64url 인코딩한 값이에요. 증명을 특정 토큰에 바인딩해서 다른 토큰에 대한 재사용을 막아요.

  2. nonce: 서버가 DPoP-Nonce 응답 헤더로 제공하는 불투명 값이에요. jti 고유성 검사를 넘어서 서버 측 리플레이 방지를 가능하게 해줘요.


전체 구현: 클라이언트 쪽

이제 TypeScript로 DPoP 클라이언트를 직접 구현해볼게요. 키 생성은 Web Crypto API, JWT 작업은 jose 라이브러리를 사용해요.

키 쌍 생성

// dpop-client.ts import { SignJWT, exportJWK, calculateJwkThumbprint } from 'jose'; import { v4 as uuidv4 } from 'uuid'; interface DPoPKeyPair { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey; thumbprint: string; } async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { // 추출 불가능한 EC P-256 키 쌍 생성 const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', }, false, // non-extractable: 개인 키 추출 불가 ['sign', 'verify'] ); const publicJwk = await exportJWK(keyPair.publicKey); const thumbprint = await calculateJwkThumbprint( publicJwk as Parameters<typeof calculateJwkThumbprint>[0], 'sha256' ); return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, publicJwk, thumbprint, }; }

generateKeyfalse 넣는 게 핵심이에요. 개인 키를 추출 불가능으로 만드는 거거든요. 같은 페이지에서 돌아가는 JS 코드라도 원시 키 데이터를 읽을 수 없어요. 키는 브라우저 크립토 엔진 안에서만 살아있어요.

DPoP 증명 생성

interface DPoPProofOptions { keyPair: DPoPKeyPair; method: string; url: string; accessToken?: string; // 리소스 서버 요청 시 필수 nonce?: string; // 서버 제공 논스 } async function createDPoPProof(options: DPoPProofOptions): Promise<string> { const { keyPair, method, url, accessToken, nonce } = options; const payload: Record<string, unknown> = { htm: method.toUpperCase(), htu: url, jti: uuidv4(), iat: Math.floor(Date.now() / 1000), }; // 리소스 서버 요청 시 액세스 토큰 해시 추가 if (accessToken) { const encoder = new TextEncoder(); const tokenBytes = encoder.encode(accessToken); const hashBuffer = await crypto.subtle.digest('SHA-256', tokenBytes); const hashArray = new Uint8Array(hashBuffer); payload.ath = base64urlEncode(hashArray); } // 서버 제공 논스가 있으면 추가 if (nonce) { payload.nonce = nonce; } const proof = await new SignJWT(payload) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: keyPair.publicJwk, }) .sign(keyPair.privateKey); return proof; } function base64urlEncode(buffer: Uint8Array): string { const binary = String.fromCharCode(...buffer); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); }

DPoP 인식 HTTP 클라이언트

여기가 진짜 핵심이에요. 논스 자동 재시도까지 포함해서 DPoP 라이프사이클 전체를 처리하는 HTTP 클라이언트 래퍼:

class DPoPClient { private keyPair: DPoPKeyPair | null = null; private nonces: Map<string, string> = new Map(); // origin → nonce async initialize(): Promise<void> { this.keyPair = await generateDPoPKeyPair(); } async fetch( url: string, options: RequestInit & { accessToken?: string } = {} ): Promise<Response> { if (!this.keyPair) throw new Error('DPoP 클라이언트 미초기화'); const method = options.method || 'GET'; const origin = new URL(url).origin; const proof = await createDPoPProof({ keyPair: this.keyPair, method, url, accessToken: options.accessToken, nonce: this.nonces.get(origin), }); const headers = new Headers(options.headers); headers.set('DPoP', proof); if (options.accessToken) { headers.set('Authorization', `DPoP ${options.accessToken}`); } const response = await fetch(url, { ...options, headers }); // 논스 챌린지 처리 const newNonce = response.headers.get('DPoP-Nonce'); if (newNonce) { this.nonces.set(origin, newNonce); if (response.status === 401 || response.status === 400) { const wwwAuth = response.headers.get('WWW-Authenticate') || ''; if (wwwAuth.includes('use_dpop_nonce')) { return this.fetch(url, options); // 저장된 논스로 재시도 } } } return response; } }

토큰 요청

async function requestToken( client: DPoPClient, tokenEndpoint: string, authCode: string, codeVerifier: string ): Promise<{ access_token: string; refresh_token: string }> { const response = await client.fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, code_verifier: codeVerifier, client_id: 'my-spa-client', redirect_uri: 'https://app.example.com/callback', }).toString(), }); if (!response.ok) { throw new Error(`토큰 요청 실패: ${response.status}`); } return response.json(); }

Authorization 스킴이 Bearer에서 DPoP로 바뀐 거 보이죠? 이게 리소스 서버에 "DPoP 증명 검증해"라고 알려주는 신호예요.


전체 구현: 서버 쪽

서버에서 할 일은 두 가지예요. 발급할 때 토큰을 키에 바인딩하는 것, 그리고 리소스 접근할 때 증명을 검증하는 것.

토큰 발급 (인가 서버)

import { jwtVerify, importJWK, calculateJwkThumbprint } from 'jose'; interface DPoPProofPayload { htm: string; htu: string; jti: string; iat: number; ath?: string; nonce?: string; } async function validateDPoPProof( proofJwt: string, expectedMethod: string, expectedUrl: string, expectedNonce?: string, accessToken?: string ): Promise<{ thumbprint: string }> { // 1. 공개 키 추출을 위해 검증 없이 헤더 디코딩 const [headerB64] = proofJwt.split('.'); const header = JSON.parse(atob(headerB64)); // 2. 헤더 검증 if (header.typ !== 'dpop+jwt') { throw new Error('잘못된 typ: dpop+jwt여야 합니다'); } if (!header.jwk) { throw new Error('헤더에 jwk가 없습니다'); } if (header.alg === 'none' || header.alg.startsWith('HS')) { throw new Error('DPoP에는 대칭 알고리즘을 사용할 수 없습니다'); } // 3. 공개 키 임포트 및 서명 검증 const publicKey = await importJWK(header.jwk, header.alg); const { payload } = await jwtVerify(proofJwt, publicKey, { typ: 'dpop+jwt', maxTokenAge: '60s', // 60초 이상 된 증명 거부 }); const claims = payload as unknown as DPoPProofPayload; // 4. HTTP 메서드와 URL 검증 if (claims.htm !== expectedMethod) { throw new Error(`htm 불일치: ${expectedMethod} 예상, ${claims.htm} 수신`); } if (claims.htu !== expectedUrl) { throw new Error(`htu 불일치: ${expectedUrl} 예상, ${claims.htu} 수신`); } // 5. jti 고유성 검증 (리플레이 캐시 확인) const isReplay = await checkAndStoreJti(claims.jti, 300); if (isReplay) { throw new Error('DPoP 증명 리플레이 탐지'); } // 6. 요구 시 논스 검증 if (expectedNonce && claims.nonce !== expectedNonce) { throw new Error('유효하지 않거나 누락된 DPoP 논스'); } // 7. 액세스 토큰 해시 검증 (리소스 서버 요청 시) if (accessToken) { const expectedAth = await computeAth(accessToken); if (claims.ath !== expectedAth) { throw new Error('액세스 토큰 해시 불일치'); } } // 8. 토큰 바인딩을 위한 JWK 썸프린트 계산 const thumbprint = await calculateJwkThumbprint(header.jwk, 'sha256'); return { thumbprint }; } async function computeAth(accessToken: string): Promise<string> { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest( 'SHA-256', encoder.encode(accessToken) ); return base64urlEncode(new Uint8Array(hashBuffer)); } // Redis 기반 JTI 리플레이 캐시 async function checkAndStoreJti( jti: string, windowSeconds: number ): Promise<boolean> { const key = `dpop:jti:${jti}`; const exists = await redis.exists(key); if (exists) return true; await redis.setex(key, windowSeconds, '1'); return false; }

확인(Confirmation) 클레임이 포함된 토큰

인가 서버가 DPoP 바인딩 토큰을 발급할 때, JWK 썸프린트를 담은 cnf(confirmation) 클레임을 포함해요:

async function issueToken( userId: string, dpopThumbprint: string, scopes: string[] ): Promise<string> { const token = await new SignJWT({ sub: userId, scope: scopes.join(' '), cnf: { jkt: dpopThumbprint, // JWK 썸프린트 확인 }, token_type: 'DPoP', }) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setExpirationTime('15m') .setIssuer('https://auth.example.com') .setAudience('https://api.example.com') .sign(serverPrivateKey); return token; }

리소스 서버 검증

// middleware/dpop-validator.ts import { jwtVerify, decodeJwt } from 'jose'; async function validateDPoPRequest(req: Request): Promise<void> { // 1. 헤더에서 DPoP 증명 추출 const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { throw new DPoPError(401, 'DPoP 증명 헤더 누락'); } // 2. 액세스 토큰 추출 const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('DPoP ')) { throw new DPoPError(401, '잘못된 인가 스킴, DPoP 예상'); } const accessToken = authHeader.slice(5); // 3. 토큰 디코딩하여 바인딩된 썸프린트 확인 const tokenClaims = decodeJwt(accessToken); const boundThumbprint = (tokenClaims.cnf as { jkt: string })?.jkt; if (!boundThumbprint) { throw new DPoPError(401, '토큰이 DPoP 바인딩되지 않음 (cnf.jkt 누락)'); } // 4. DPoP 증명 검증 const requestUrl = new URL(req.url).origin + new URL(req.url).pathname; const { thumbprint } = await validateDPoPProof( dpopProof, req.method, requestUrl, getCurrentNonce(), accessToken ); // 5. 증명이 토큰에 바인딩된 키로 서명됐는지 확인 if (thumbprint !== boundThumbprint) { throw new DPoPError(401, 'DPoP 증명 키가 토큰 바인딩과 불일치'); } } class DPoPError extends Error { constructor( public status: number, message: string ) { super(message); } }

리플레이 방지: 논스 관리

jti 클레임으로 클라이언트 쪽 고유성은 확보되지만, 전송 중인 증명을 가로챈 공격자가 시간 윈도우 안에서 리플레이할 수 있거든요. 서버에서 제공하는 논스가 두 번째 방어막이에요:

논스 동작 방식

논스 챌린지 플로우:

  클라이언트 → 리소스 서버: DPoP 증명 (논스 없음)
  리소스 서버 → 클라이언트: 401 + DPoP-Nonce: "abc123"
  클라이언트 → 리소스 서버: DPoP 증명 (nonce: "abc123")
  리소스 서버 → 클라이언트: 200 OK + DPoP-Nonce: "def456"
  클라이언트 → 리소스 서버: DPoP 증명 (nonce: "def456")
  ...계속, 논스는 매 응답마다 교체...

서버 측 논스 구현

class NonceManager { private currentNonce: string; private previousNonce: string | null = null; private rotationInterval: NodeJS.Timeout; constructor(rotationSeconds: number = 30) { this.currentNonce = this.generate(); // 주기적으로 논스 교체 this.rotationInterval = setInterval(() => { this.previousNonce = this.currentNonce; this.currentNonce = this.generate(); }, rotationSeconds * 1000); } private generate(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64urlEncode(bytes); } getCurrent(): string { return this.currentNonce; } isValid(nonce: string): boolean { // 교체 유예 기간 동안 현재와 이전 논스 모두 허용 return nonce === this.currentNonce || nonce === this.previousNonce; } } // Express 미들웨어 function dpopNonceMiddleware(nonceManager: NonceManager) { return (req: Request, res: Response, next: NextFunction) => { // 모든 응답에 현재 논스 포함 res.setHeader('DPoP-Nonce', nonceManager.getCurrent()); next(); }; }

논스 규칙 정리

  1. 논스는 서버별로 분리돼요. 인가 서버 논스와 리소스 서버 논스는 완전히 별개예요. 한 서버의 논스를 다른 서버에 쓰면 안 돼요.

  2. 논스는 불투명 값이에요. 클라이언트는 논스 값을 파싱하거나 디코딩하거나 해석해서는 안 돼요. 그냥 불투명 문자열로 취급하세요.

  3. 이전 논스도 허용하세요. 교체 과정에서 현재와 바로 직전 논스를 둘 다 받아야 진행 중인 요청이 깨지지 않아요.

  4. 논스 헤더는 항상 보내세요. 성공 응답에도 DPoP-Nonce를 포함하세요. 클라이언트가 실패 없이 다음 요청을 위한 논스를 미리 확보할 수 있거든요.


키 저장 전략

DPoP 보안의 핵심은 개인 키를 절대 추출할 수 없게 만드는 거예요. 플랫폼마다 접근법이 달라요:

브라우저 (SPA)

// IndexedDB로 키 쌍 영속화 async function persistKeyPair(keyPair: CryptoKeyPair): Promise<void> { const db = await openDB('dpop-keys', 1, { upgrade(db) { db.createObjectStore('keys'); }, }); // CryptoKey 객체를 IndexedDB에 직접 저장 가능 // 영속화해도 non-extractable 속성 유지 await db.put('keys', keyPair, 'dpop-keypair'); } async function loadKeyPair(): Promise<CryptoKeyPair | null> { const db = await openDB('dpop-keys', 1); return db.get('keys', 'dpop-keypair'); }

핵심 포인트: IndexedDB에 저장된 CryptoKey 객체는 extractable: false 속성을 유지해요. 페이지를 새로고침해도 원시 키 데이터가 JavaScript에 노출되지 않아요.

모바일 (React Native / 네이티브)

플랫폼저장소보안 수준
iOSSecure Enclave (Keychain)하드웨어 기반, 변조 불가
AndroidAndroid Keystore (StrongBox 지원 시)지원 기기에서 하드웨어 기반
React Nativereact-native-keychain + 플랫폼별 백킹하위 플랫폼에 의존

서버 간 통신

백엔드 서비스에서는 환경 변수나 KMS에서 키 쌍을 로드할 수 있어요:

// 환경 변수 또는 KMS 사용 const privateKey = await importPKCS8( process.env.DPOP_PRIVATE_KEY!, 'ES256' );

마이그레이션: 베어러에서 DPoP로

하루아침에 모든 클라이언트한테 DPoP 강제하는 건 무리죠. 단계별로 가야 해요:

1단계: 듀얼 지원

베어러와 DPoP 토큰 모두 수용해요. 신규 클라이언트는 DPoP, 기존 클라이언트는 베어러 유지.

async function validateRequest(req: Request): Promise<TokenClaims> { const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('DPoP ')) { // DPoP 플로우: 증명 + 토큰 바인딩 검증 await validateDPoPRequest(req); return decodeAndVerifyToken(authHeader.slice(5)); } if (authHeader?.startsWith('Bearer ')) { // 레거시 베어러 플로우: 아직 허용 metrics.increment('auth.bearer.used'); // 지원 중단 추적용 return decodeAndVerifyToken(authHeader.slice(7)); } throw new Error('Authorization 헤더 누락 또는 유효하지 않음'); }

2단계: DPoP 기본값

기본적으로 DPoP 바인딩 토큰을 발급하고, 베어러 토큰 사용을 로깅해서 모니터링해요.

app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (dpopProof) { // DPoP 지원 클라이언트: sender-constrained 토큰 발급 const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); } else { // 레거시 클라이언트: 지원 중단 경고와 함께 베어러 토큰 발급 const token = await issueBearerToken(userId, scopes); res.setHeader('Deprecation', 'true'); res.json({ access_token: token, token_type: 'bearer' }); } });

3단계: DPoP 필수

모니터링으로 베어러 사용률이 낮아진 걸 확인한 후, 모든 클라이언트에 DPoP를 강제해요.

app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { return res.status(400).json({ error: 'invalid_dpop_proof', error_description: 'DPoP 증명이 필요합니다. 베어러 토큰은 더 이상 허용되지 않습니다.', }); } const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); });

마이그레이션 지표

지표목표무엇을 알 수 있나
DPoP 채택률3단계 전 90% 이상전반적 마이그레이션 진행률
베어러 토큰 사용률5% 미만으로 감소레거시 클라이언트 업데이트 여부
논스 재시도율요청의 10% 미만논스 교체가 너무 공격적인지
증명 검증 실패율0.1% 미만시계 오차 또는 구현 버그
키 교체 이벤트클라이언트별 추적키 라이프사이클 관리 상태

DPoP vs. 다른 토큰 바인딩 방식

기능베어러 토큰DPoP (RFC 9449)mTLS (RFC 8705)토큰 바인딩 (RFC 8471)
토큰 탈취 방지❌ 없음✅ 암호학적 바인딩✅ TLS 바인딩✅ TLS 바인딩
브라우저 지원✅ 전체✅ Web Crypto API❌ 클라이언트 인증서 UI 없음❌ 브라우저 지원 중단
모바일 지원✅ 전체✅ 플랫폼 크립토⚠️ 인증서 관리 복잡❌ 미구현
구현 복잡도⭐ 간단⭐⭐ 보통⭐⭐⭐ 높음해당 없음 (사망)
성능 오버헤드없음요청당 ~2ms (서명)TLS 핸드셰이크 비용해당 없음
CDN/프록시 호환✅ 가능✅ 가능 (앱 레이어)⚠️ TLS 종단 문제❌ TLS 종단에서 깨짐
규격 성숙도RFC 6750 (2012)RFC 9449 (2023)RFC 8705 (2020)중단

DPoP가 웹과 모바일에서 이기는 이유는 간단해요. 애플리케이션 레이어에서 돌아가니까요. 특수한 TLS 설정도 필요 없고, 클라이언트 인증서도 필요 없고, 브라우저 UI 건드릴 것도 없어요. JavaScript에서 암호학 처리하면 끝이에요.


프로덕션 보안 체크리스트

클라이언트 측

  1. 추출 불가능한 키를 사용하세요. crypto.subtle.generateKey()에서 항상 extractable: false로 설정하세요. 주입된 XSS 페이로드를 포함한 어떤 JavaScript 코드도 원시 개인 키를 읽을 수 없게 돼요.

  2. 키를 주기적으로 교체하세요. 유저가 재인증하거나 세션이 시작될 때 새 키 쌍을 생성하세요. 오래된 토큰 바인딩은 자동으로 무효화돼요.

  3. IndexedDB를 쓰세요, localStorage 말고. CryptoKey 객체는 IndexedDB에만 저장할 수 있어요. localStorage는 문자열만 저장할 수 있어서 키를 추출해야 하는데, 그러면 목적 자체가 무산돼요.

  4. 시계 오차를 처리하세요. iat 클레임은 서버의 허용 창 안에 있어야 해요. 서버 측 검증에서 ±60초의 오차를 허용하는 게 좋아요.

서버 측

  1. JTI 리플레이 캐시를 유지하세요. 증명 허용 창과 일치하는 TTL로 Redis 등의 저장소를 사용하세요. 이게 없으면 시간 창 내에서 증명이 재사용될 수 있어요.

  2. 전부 검증하세요. typ, alg(대칭 알고리즘 거부), htm, htu, iat, jti, ath, nonce를 전부요. 하나라도 빠지면 우회 가능성이 생겨요.

  3. 고보안 컨텍스트에서 논스를 사용하세요. 금융 API나 FAPI 2.0 준수에서는 서버 제공 논스가 필수예요. 일반 API에서는 보안을 강화하지만 첫 요청에 한 번의 라운드트립이 추가돼요.

  4. 매 응답에 논스를 반환하세요. 클라이언트가 실패해야만 논스를 알 수 있게 하지 마세요. 모든 응답에 DPoP-Nonce 헤더를 포함해서 미리 확보할 수 있게 해주세요.

  5. alg: none과 대칭 알고리즘을 거부하세요. DPoP 증명은 반드시 비대칭 알고리즘을 사용해야 해요. HS256을 수락하면 서버와 클라이언트가 비밀을 공유하게 되는데, 그건 DPoP가 피하려는 바로 그 구조거든요.


성능과 비용 모델

DPoP는 모든 요청에 암호화 작업을 추가해요. 실제 영향은 이 정도예요:

작업시간 (P50)시간 (P99)비고
키 생성 (EC P-256)0.5ms2ms세션당 한 번
증명 서명 (ECDSA)0.8ms2.5ms매 요청
증명 검증 (서버)0.3ms1ms매 요청
JTI 캐시 조회 (Redis)0.1ms0.5ms매 요청
썸프린트 계산0.1ms0.3ms토큰 발급 시

요청당 총 오버헤드: 클라이언트 ~1.3ms, 서버 ~0.5ms. 참고로 일반적인 DB 쿼리가 5-50ms 걸리거든요. DPoP 오버헤드는 노이즈 수준이에요.

트레이드오프는 명확해요: 요청당 ~2ms의 추가 지연으로 토큰 탈취 공격이라는 전체 공격 클래스를 제거하는 거예요.


2026년의 현실

DPoP는 미래 표준이 아니라 현재 요구사항이에요. RFC 9449는 2023년 9월에 발표됐고, 금융 서비스 분야의 FAPI 2.0 준수에서 필수인 두 가지 sender-constraint 메커니즘 중 하나가 됐어요. Auth0와 Okta가 DPoP를 직접 지원하고, Microsoft Entra ID는 자체 Proof-of-Possession 토큰 바인딩을 제공해요. WebCrypto API는 모든 주요 브라우저에서 사용 가능해요.

진짜 장벽은 관성이에요. 베어러 토큰으로 인증 시스템을 이미 다 만들어놓은 팀들이 암호학적 증명 추가를 망설이는 거죠. 근데 실제로 구현해보면 별거 아니에요. 키 쌍 하나, 요청마다 JWT 하나, 검증 미들웨어 하나가 전부거든요. jose 라이브러리가 무거운 건 다 알아서 처리해줘요. 위에서 본 DPoP fetch 래퍼가 50줄도 안 되는 거 보셨잖아요.

베어러 토큰은 XSS가 덜 흔하고, 공급망 공격이 드물고, API 아키텍처가 더 단순했던 시대를 위해 설계된 거예요. 그 시대는 끝났어요. 평범한 베어러로 발급하는 모든 토큰은 탈취되면 마찰 제로로 재사용될 수 있는 토큰이에요.

가장 가치가 높은 API 엔드포인트부터 시작하세요. 인증, 결제, 관리자 작업. 베어러 토큰과 함께 DPoP 지원을 추가하세요. 어떤 클라이언트가 업그레이드하는지 모니터링하세요. 커버리지가 충분해지면 베어러 토큰을 완전히 폐기하세요.

여러분의 토큰은 누가 들고 있는지 증명할 수 있어야 해요. DPoP가 수백 줄의 코드와 무시해도 될 수준의 지연으로 그걸 가능하게 해줘요. 하지 않는 것의 비용은 침해를 기다리는 것뿐이에요.

OAuthSecurityDPoPAuthenticationTypeScriptAPI Security

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요