DPoP 증명 검증: jti, htm, htu, nonce가 막는 재전송과 막지 못하는 재전송

OAuth 2.0 DPoP 증명 JWT를 받은 서버가 무엇을 확인해야 하는지, jti·htm·htu·iat·nonce가 재전송을 어디까지 줄이는지 RFC 9449 하나만 근거로 정리합니다.

oauthdpopjwtsecurityauthentication

DPoP(Demonstrating Proof of Possession)를 쓰는 클라이언트는 요청마다 DPoP 헤더에 짧은 JWT를 하나 실어 보낸다. 이 JWT를 DPoP 증명(proof)이라고 부른다. 증명은 클라이언트의 개인 키로 서명되고, 헤더에 그 공개 키가 들어 있다. 서버가 서명만 확인하고 넘어가면 증명은 “이 키를 가진 누군가가 언젠가 만든 JWT”라는 뜻밖에 되지 않는다. 그 증명이 지금 이 요청을 위해 만들어졌다는 것은 jti, htm, htu, iat, nonce를 확인해야 비로소 말할 수 있다.

이 글의 근거는 RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)(2023년 9월, Standards Track) 하나뿐이다. 2026-10-28에 원문을 확인했다. JWT, JWK, HTTP 의미론처럼 RFC 9449가 참조하는 다른 문서의 세부 내용은 이 글에서 다루지 않는다. 특정 라이브러리나 서비스가 이 규칙을 어떻게 구현하는지도 다루지 않는다.

DPoP가 하는 일과 하지 않는 일

RFC 9449 초록은 DPoP를 애플리케이션 계층의 소유 증명으로 OAuth 2.0 토큰을 송신자 제약(sender-constraining)하는 메커니즘이라고 설명하고, 이를 통해 액세스 토큰과 리프레시 토큰의 재전송 공격을 탐지할 수 있다고 적는다. 토큰이 특정 공개 키에 묶이므로, 토큰만 훔친 쪽은 그 키로 서명한 증명을 만들 수 없다.

두 가지는 처음부터 범위 밖이다.

  • 클라이언트 인증이 아니다. §1은 “DPoP itself is not used for client authentication”이라고 쓰고, §3도 이 메커니즘이 클라이언트 인증 방법이 아니라고 다시 밝힌다.
  • 요청 본문과 헤더의 무결성을 보장하지 않는다. §11.7은 증명이 HTTP 메서드와 URI만 다룬다고 설명한다. 메시지 내용이나 헤더를 바꿀 수 있는 공격자는 요청을 재전송할 수도 있다. 같은 절은 보통 TLS가 이를 충분히 막아 준다고 하고, §2는 DPoP를 항상 HTTPS와 함께 써야 한다(MUST)고 규정한다.

증명 JWT에 들어가는 것

§4.2가 정한 구조다. JOSE 헤더에는 세 필드가 모두 필수다.

헤더 필드 요구 사항
typ 값은 dpop+jwt
alg 등록된 비대칭 서명 알고리즘. none이나 대칭(MAC) 알고리즘 식별자여서는 안 된다(MUST NOT)
jwk 클라이언트의 공개 키. 개인 키를 담아서는 안 된다(MUST NOT)

페이로드 클레임은 다음과 같다.

클레임 필수 여부 내용
jti 필수 증명의 고유 식별자
htm 필수 이 JWT가 붙은 요청의 HTTP 메서드
htu 필수 요청의 대상 URI. 쿼리와 프래그먼트는 빼고 넣는다
iat 필수 JWT 생성 시각
ath 액세스 토큰과 함께 보호된 리소스에 접근할 때 필수 액세스 토큰 값의 ASCII 인코딩에 대한 SHA-256 해시를 base64url로 인코딩한 값
nonce 서버가 DPoP-Nonce 헤더로 nonce를 준 경우 필수 서버가 최근에 준 nonce

jti는 같은 맥락의 다른 증명과 값이 겹칠 확률이 무시할 수 있을 만큼 작아야 한다. §4.2는 생성 방법으로 최소 96비트의 의사난수 데이터를 base64url 등으로 인코딩하는 방법과 버전 4 UUID를 예로 든다. 확장이나 프로필, 배포 환경의 요구에 따라 다른 헤더 파라미터나 클레임을 더 넣는 것도 허용된다(MAY).

RFC 9449가 토큰 요청 예시로 든 증명을 디코딩하면 다음과 같다. 서명은 생략했다. 먼저 JOSE 헤더다.

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA",
    "crv": "P-256"
  }
}

다음은 페이로드다.

{
  "jti": "-BwC3ESc6acc2lTc",
  "htm": "POST",
  "htu": "https://server.example.com/token",
  "iat": 1562262616
}

액세스 토큰 없이 토큰 엔드포인트에 보내는 요청이고 서버가 nonce를 주지도 않았으므로 ath와 nonce가 없다.

서버가 확인해야 하는 12가지

§4.3은 증명을 검증하는 서버가 다음을 모두 확인해야 한다(MUST)고 정한다. 순서는 상관없다고 명시돼 있다.

  1. DPoP 요청 헤더가 둘 이상이 아니다.
  2. 헤더 값이 형식이 올바른 JWT 하나다.
  3. §4.2가 요구하는 클레임이 모두 있다.
  4. typ이 dpop+jwt다.
  5. alg가 등록된 비대칭 서명 알고리즘이고, none이 아니며, 애플리케이션이 지원하고, 로컬 정책상 허용된다.
  6. jwk에 든 공개 키로 서명이 검증된다.
  7. jwk에 개인 키가 없다.
  8. htm이 현재 요청의 HTTP 메서드와 일치한다.
  9. htu가 JWT를 받은 요청의 HTTP URI와 일치한다. 쿼리와 프래그먼트는 무시한다.
  10. 서버가 클라이언트에 nonce를 준 적이 있다면, nonce 클레임이 그 값과 일치한다.
  11. iat이나 nonce에 담긴 서버 관리 타임스탬프로 정한 생성 시각이 허용 범위 안에 있다.
  12. 액세스 토큰과 함께 보호된 리소스에 제시됐다면, ath가 그 액세스 토큰의 해시와 같고, 액세스 토큰이 묶인 공개 키가 증명의 공개 키와 일치한다.

htu 비교에 대해서는 거짓 음성을 줄이기 위해 RFC 3986 §6.2.2의 구문 기반 정규화와 §6.2.3의 스킴 기반 정규화를 적용하라고 권한다(SHOULD). 서명 검증(6번)을 통과했다고 나머지를 건너뛰면 안 되는 이유가 여기서 드러난다. 서명은 키 소유만 보여 줄 뿐, 증명이 어느 요청을 위한 것인지는 8번부터 12번이 확인한다.

4번과 5번은 다른 용도의 서명된 JWT를 증명으로 쓰는 경우와 맞닿아 있다. §11.5는 서명된 증명을 받는 서버가 typ이 dpop+jwt인지 확인해야 한다(MUST)고 다시 적고, §11.6은 none 알고리즘을 허용해서는 안 된다(MUST NOT)고 적는다. alg를 검증하는 일반적인 이야기는 JWT 검증에서 alg를 믿으면 안 되는 이유에서 다뤘다.

htm과 htu가 막는 것

§11.1은 증명을 손에 넣은 공격자가 같은 엔드포인트에 그 증명을 재전송할 수 있다는 데서 출발한다. htm과 htu는 증명을 특정 메서드와 URI에 묶는다. 그래서 토큰 엔드포인트에 보낸 POST 증명을 리소스 서버의 GET 요청에 붙여 쓰는 것은 8번과 9번에서 걸린다.

반대로 메서드와 URI가 같은 요청이라면 htm과 htu만으로는 구별되지 않는다. htu는 쿼리를 빼고 비교하므로, 경로가 같고 쿼리만 다른 요청도 여기서는 같은 대상으로 보인다. 이 틈을 줄이는 것이 시간 제한과 jti, 그리고 nonce다.

iat과 jti: 시간 창 안에서만 받기

§11.1의 첫 번째 요구는 시간이다. 서버는 생성된 지 제한된 시간 안의 증명만 받아야 한다(MUST). 괄호 안에 “가급적 몇 초나 몇 분 정도의 짧은 기간”이라고 덧붙어 있지만, RFC가 구체적인 숫자를 정하지는 않는다. 클럭 차이를 고려해 iat이 약간 미래인 증명을 받아들이는 것은 허용된다(MAY).

시간 창 안에서의 재전송은 jti로 다룬다. §11.1은 대상 URI의 맥락에서, 증명이 받아들여질 수 있는 시간 창 동안 각 증명의 jti를 저장해 두고 같은 값이 다시 오면 거절할 수 있다(can)고 설명한다. 이것은 MUST가 아니라 가능한 방법으로 서술돼 있다. 같은 절은 엄격하게 지키면 이 단일 사용 검사가 재전송을 아주 강하게 막아 주지만, 예컨대 같은 엔드포인트를 맡은 여러 서버가 상태를 공유하지 않는 환경에서는 실현하기 어려울 수 있다고 인정한다.

jti를 추적하는 서버라면 불필요하게 긴 jti를 거절하거나 해시만 저장하는 편이 좋다고 같은 절이 조언한다. 이 문장은 소문자 should로 쓰여 있어 규범 키워드는 아니다.

정리하면 시간 창은 RFC가 요구하는 하한선이고, jti 저장은 그 창 안의 재전송까지 막고 싶을 때 서버가 고르는 수단이다. jti 저장소를 두지 않는다면 시간 창의 길이가 곧 재전송이 가능한 기간의 상한이 된다. 이것은 §11.1의 서술에서 이끌어 낸 해석이다.

nonce: 서버가 시간을 정한다

iat은 클라이언트가 정하는 값이다. §11.2는 클라이언트를 장악한 공격자가 iat을 미래 시각으로 정해 증명을 미리 만들어 둘 수 있다고 지적한다. 서버가 제공하는 nonce는 미리 만든 증명을 현재 nonce의 수명을 넘어 쓰는 것을 막는다. 서버가 nonce를 언제 바꿀지 스스로 정하므로, 현재 nonce로 미리 만든 증명은 그 nonce의 수명이 끝나면 쓸 수 없다. 다만 수명이 끝나기 전에는 여전히 쓸 수 있으므로, 그 안의 재전송은 계속 jti로 처리해야 한다.

흐름은 서버 종류에 따라 응답 코드가 다르다.

  • 인가 서버(§8). nonce가 필요한데 증명에 없으면 HTTP 400과 use_dpop_nonce 오류, 그리고 DPoP-Nonce 헤더로 응답한다. 클라이언트는 그 값을 nonce 클레임에 넣어 다시 요청한다.
  • 리소스 서버(§9). HTTP 401과 WWW-Authenticate: DPoP error="use_dpop_nonce", 그리고 DPoP-Nonce 헤더로 응답한다.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce"
DPoP-Nonce: <서버가 정한 값>

nonce에 관한 주요 규칙은 이렇다.

  • nonce 값은 예측할 수 없어야 한다(MUST). 클라이언트에게는 불투명한 값이다(§8).
  • 증명의 nonce가 인가 서버가 최근에 준 nonce와 정확히 일치하지 않으면 인가 서버는 요청을 거절해야 한다(MUST)(§8).
  • 서버는 이후 응답의 DPoP-Nonce 헤더로 새 nonce를 줄 수 있고, 클라이언트는 다음 요청부터 그 값을 써야 한다(MUST). DPoP-Nonce가 담긴 응답은 캐시되지 않게 하는 것이 권장된다(SHOULD)(§8.2).
  • 클라이언트에게 nonce를 준 뒤에는 nonce 클레임이 없는 증명을 받아서는 안 된다(MUST NOT)(§11.3). 이 규칙이 없으면 공격자는 nonce를 빼고 보내는 것만으로 nonce 검사를 우회할 수 있다.
  • nonce는 그것을 발급한 서버에서만 써야 한다(§9).

nonce가 곧 일회용 값이라는 뜻은 아니다. §11.1은 클라이언트가 같은 nonce를 여러 번 써도 괜찮다고 밝히고, jti를 추적해 nonce의 수명 동안 중복을 거절한다면 토큰 재전송 위험이 더 늘어나지 않는다고 설명한다. 즉 nonce는 증명의 유효 기간을 서버 시계로 끊어 주고, 같은 기간 안의 중복은 여전히 jti가 맡는다. §11.1은 서버 시각을 담은 nonce로 증명 수명을 제한하면 클라이언트와 서버의 클럭 차이와 상관없이 같은 효과를 얻을 수 있다고도 적는다.

nonce를 쓰지 않는 배포에 대해서 §11.2는 수명이 긴 DPoP 제약 액세스 토큰을 발급하지 말고(SHOULD NOT) 짧은 액세스 토큰과 리프레시 토큰을 쓰라고 권한다. 사전 생성된 증명은 ath 때문에 어차피 해당 액세스 토큰의 수명 안에서만 쓸 수 있으므로, 토큰 수명이 짧을수록 피해 기간도 짧아진다.

각 장치가 막는 범위

장치 RFC 9449의 요구 강도 막는 것 남는 틈
서명 + jwk 검증 MUST(§4.3) 키를 갖지 않은 쪽의 증명 생성 키 소유만 증명하며 요청과의 연결은 없음
htm, htu 일치 검사 MUST(§4.3) 다른 메서드나 URI로의 재사용 같은 메서드와 URI에 대한 재전송, 쿼리 차이
iat 시간 창 제한된 시간만 수락 MUST(§11.1) 오래된 증명의 재전송 창 안의 재전송, 미래 iat로 미리 만든 증명
jti 저장 가능한 방법(can)(§11.1) 창 안의 같은 증명 재전송 상태를 공유하지 않는 여러 서버
서버 nonce 제공은 선택, 제공 후 검사는 MUST(§8, §11.3) 현재 nonce의 수명을 넘어선 사전 생성 증명의 사용. 증명 수명 제한이 클라이언트와 서버의 시계 일치에 기대지 않게 됨 nonce 수명 안의 사전 생성 증명과 재전송은 막지 못하며 jti가 맡아야 함
ath 액세스 토큰과 쓸 때 필수(§4.2, §4.3) 다른 액세스 토큰과 함께 쓰는 것 같은 액세스 토큰과 함께 하는 재전송은 막지 못함. 시간 창과 jti 검사가 여전히 필요

어느 장치도 클라이언트 안에서 공격자의 코드가 실행되는 상황까지 막지는 못한다. §11.4는 그런 경우 DPoP의 보장이 성립하지 않는다고 설명한다. 키를 내보낼 수 없게 만들면 키 자체의 탈취는 막을 수 있지만, 클라이언트가 온라인인 동안 공격자가 증명을 만들어 쓰는 것까지 막지는 못한다.

점검할 것

  • 서명만 검증하고 끝내지 않는지, §4.3의 12가지를 모두 확인하는지 본다.
  • alg를 증명 안의 값이 아니라 서버가 허용한 목록과 대조하는지, none과 대칭 알고리즘을 거절하는지 확인한다.
  • htu 비교 전에 쿼리와 프래그먼트를 떼고 RFC 3986의 정규화를 적용하는지 본다.
  • iat 허용 범위를 몇 초에서 몇 분 수준으로 좁게 잡았는지, 미래 쪽 허용 폭을 클럭 차이만큼만 두었는지 확인한다.
  • jti 저장소를 쓴다면 대상 URI와 시간 창 단위로 보관하는지, 여러 서버가 같은 저장소를 보는지 확인한다.
  • 서버가 그 클라이언트에 nonce를 제공한 상태에서 nonce가 없는 증명이 오면 거절하는지 본다.
  • nonce를 쓰지 않는다면 DPoP 제약 액세스 토큰의 수명이 짧은지 확인한다.

인가 코드 단계의 보호는 OAuth 2.0 PKCE에서 다뤘다. PKCE는 코드를 토큰으로 바꾸는 순간을 보호하고, DPoP는 발급된 토큰을 키에 묶는다.

참고 자료

2026-10-28에 확인했다.

블로그로