WebAuthn signCount가 알려주는 것: 카운터 비교와 복제 의심 신호
WebAuthn Level 3 기준으로 signCount가 무엇을 세는지, 0이 계속 오는 경우, RP가 비교하는 정확한 조건, 값이 줄거나 같을 때 왜 증거가 아닌 신호인지, BE·BS 플래그와의 관계를 정리합니다.
WebAuthn으로 로그인을 검증하는 서버(Relying Party, 이하 RP)는 인증기가 돌려준 authenticator data 안에서 signCount라는 4바이트 값을 받는다. 이 값은 인증기가 복제됐는지 RP가 알아채도록 돕기 위한 서명 카운터다. 실제로 다루다 보면 몇 가지 질문이 생긴다. 값이 계속 0으로 오면 잘못된 것인가, 저장된 값보다 작거나 같은 값이 오면 로그인을 막아야 하는가, 동기화되는 자격 증명이라면 다르게 봐야 하는가.
이 글은 W3C Recommendation인 Web Authentication Level 3(2026년 8월 25일판)을 근거로 하며, 2026-10-15에 확인했다. CTAP 같은 FIDO Alliance 명세나 특정 플랫폼·제조사의 카운터 동작은 근거로 쓰지 않았다. 그래서 “어느 인증기가 어떤 값을 보낸다”는 이야기는 다루지 않는다. 아래에서 명세의 요구(SHOULD, MUST 등)와 명세가 RP 정책에 맡긴 부분은 구분해서 적는다.
authenticator data 안의 위치
§6.1에 따르면 authenticator data는 37바이트 이상인 바이트 배열이고, 앞부분은 다음 순서로 고정돼 있다.
| 필드 | 길이 | 내용 |
|---|---|---|
rpIdHash |
32바이트 | RP ID의 SHA-256 해시 |
flags |
1바이트 | UP(비트 0), UV(비트 2), BE(비트 3), BS(비트 4), AT(비트 6), ED(비트 7) |
signCount |
4바이트 | 서명 카운터, 32비트 부호 없는 big-endian 정수 |
0부터 세면 signCount는 33번째부터 36번째 바이트다. 부호 없는 32비트 값이므로 최댓값은 2^32 − 1이다. 저장 컬럼을 부호 있는 32비트 정수로 잡으면 2^31 이상의 값을 담지 못한다. 이 마지막 점은 명세의 문장이 아니라 필드 정의에서 나오는 구현상 주의점이다.
등록과 인증에서 카운터가 움직이는 방식
§6.1.1은 인증기가 서명 카운터를 구현해야 한다(SHOULD)고 적는다. 반드시(MUST)가 아니므로 구현하지 않은 인증기도 명세 안에 있다. 카운터는 자격 증명마다 따로 두거나 인증기 전체에 하나만 둘 수 있다.
등록(authenticatorMakeCredential, §6.3.2)에서 새 자격 증명의 첫 카운터 값은 인증기 종류에 따라 정해진다.
- 자격 증명별 카운터를 지원하면 카운터를 새로 만들고 0으로 초기화한다.
- 전역 카운터를 지원하면 그 전역 카운터의 현재 값을 쓴다.
- U2F 기기라면 0으로 둔다. U2F 기기는 카운터를 지원하더라도 자격 증명을 만들 때는 값을 돌려주지 않기 때문이다.
- 카운터를 지원하지 않으면 0으로 두고 계속 0을 유지한다.
인증(authenticatorGetAssertion, §6.3.3)이 성공할 때마다 인증기는 카운터를 “어떤 양수만큼” 올린다. 1씩 오른다는 규정은 없으므로 RP가 받는 값 사이에 간격이 있어도 그 자체로는 이상한 일이 아니다. 카운터를 구현하지 않은 인증기는 계속 0을 보낸다.
RP 쪽에서는 등록할 때(§7.1) authenticator data의 signCount를 credential record에 저장하고, 이후 인증마다 그 값과 새 값을 비교한다. §4의 정의대로 credential record의 signCount는 그 자격 증명을 쓴 ceremony에서 받은 가장 최근 카운터 값이다.
RP가 비교하는 정확한 조건
§7.2의 해당 단계를 그대로 옮기면 이렇다.
authData.signCount와 저장된credentialRecord.signCount중 하나라도 0이 아니면 아래 비교를 한다. 둘 다 0이면 이 비교 단계는 실행되지 않는다.- 새 값이 저장값보다 크면 서명 카운터는 유효하다.
- 새 값이 저장값보다 작거나 같으면, 인증기가 복제됐을 수 있다는 신호지만 증거는 아니다.
몇 가지 경우를 표로 정리하면 다음과 같다.
| 저장값 | 새 값 | 결과 |
|---|---|---|
| 0 | 0 | 비교하지 않음 (카운터를 쓰지 않는 인증기에서 나오는 모양) |
| 0 | 7 | 유효 |
| 41 | 42 | 유효 |
| 41 | 50 | 유효 (증가 폭은 정해져 있지 않음) |
| 41 | 41 | 복제 의심 신호 |
| 41 | 12 | 복제 의심 신호 |
| 41 | 0 | 복제 의심 신호 (저장값이 0이 아니므로 비교 대상) |
마지막 줄은 놓치기 쉽다. “0이면 카운터 미지원이니 건너뛴다”는 식으로 새 값만 보고 판단하면, 이전에 0이 아닌 값을 보내던 자격 증명이 갑자기 0을 보내는 경우를 지나치게 된다. 명세의 조건은 두 값 중 하나라도 0이 아니면 비교하는 것이다.
작거나 같은 값은 증거가 아니라 신호다
§7.2는 저장값보다 작거나 같은 값이 왔을 때 생각할 수 있는 원인으로 세 가지를 예로 든다.
- 자격 증명 개인 키의 사본이 둘 이상 있고 동시에 쓰이고 있다.
- 인증기가 오작동하고 있다.
- RP가 assertion 응답을 인증기에서 만들어진 순서와 다른 순서로 처리하는 경쟁 상태(race condition)가 있다.
§6.1.1은 여기에 더해, 카운터 불일치를 발견하더라도 지금 처리 중인 요청이 복제된 인증기에서 왔는지 원래 인증기에서 왔는지는 알 수 없다고 적는다. 복제가 실제로 일어났다 해도 방금 로그인한 쪽이 공격자라고 단정할 근거는 없다는 뜻이다.
그래서 명세는 대응을 RP에게 맡긴다. §7.2에 따르면 이 경우 저장된 signCount를 새 값으로 갱신할지, 인증 ceremony를 실패로 처리할지는 RP마다 정할 일(Relying Party-specific)이다. RP는 자기 운영 특성을 평가해 이 정보를 위험 점수에 반영해야 한다(should). §6.1.1도 RP가 위험 허용 범위와 값이 늘지 않을 만한 정당한 운영상 이유를 고려해 상황에 맞게 대응하라고 한다. §6.1.1은 보호 수단이 제한적인 인증기일수록 복제 탐지가 더 중요하다는 점도 덧붙인다.
명세가 “불일치하면 반드시 거부하라”고 하지 않는다는 점은 정책을 짤 때 중요하다. 오작동이나 처리 순서 문제로도 같은 결과가 나올 수 있으니, 카운터 불일치 하나만으로 계정을 자동으로 잠그는 정책은 정당한 사용자를 막을 수 있다. 기록을 남기고, 다른 위험 신호와 함께 보고, 필요하면 추가 확인을 요구하는 식의 단계적 대응을 검토할 만하다. 이 문단의 권고는 명세의 요구가 아니라 위 서술에서 이끌어 낸 판단이다.
비교 단계의 의사 코드
아래는 카운터 비교 부분만 보여 주는 정책 예시용 의사 코드다. 완전한 WebAuthn 검증 절차가 아니다. 서명 검증, challenge·origin·RP ID 해시 확인, UP·UV 플래그 확인 등 §7.2의 다른 단계는 하나도 생략할 수 없고, 아래 코드는 그 단계들을 이미 통과했다는 전제에서만 의미가 있다.
# 정책 예시용 의사 코드. §7.2의 다른 검증 단계를 모두 통과한 뒤에만 실행한다.
stored = credentialRecord.signCount
new = authData.signCount # 32비트 부호 없는 big-endian 정수
if new == 0 and stored == 0:
result = "not_compared" # 명세상 비교 단계를 실행하지 않음
elif new > stored:
result = "valid"
else:
result = "signal" # 복제 가능성의 신호, 증거 아님
update = true
if result == "signal":
log_risk_signal(credentialId, stored, new)
action = rp_policy(credentialId, risk_context)
# 예: 기록 후 허용, 추가 인증 요구, 검토 대기, ceremony 실패 처리
if action == "fail":
return fail_ceremony()
update = action.updates_count # 갱신 여부도 RP 정책
if update:
credentialRecord.signCount = new
자격 증명별 카운터와 전역 카운터
§6.1.1은 인증기가 자격 증명별 카운터를 구현해야 한다(SHOULD)고 한다. 카운터 값이 여러 RP 사이에서 공유되면 사용자를 연결하는 상관 식별자(correlation handle)로 쓰일 수 있기 때문이다. 전역 카운터도 허용되지만(MAY) 사용자 프라이버시 면에서 덜 바람직하다고 적는다. 같은 절은 하드웨어 고장 같은 이유로 카운터가 실수로 줄어들지 않도록 하라고(SHOULD) 인증기에 요구한다.
RP 입장에서 기억할 점은, 어떤 방식이든 RP가 받는 값은 “직전 값보다 큰가”로만 판단한다는 것이다. 증가 폭이나 연속성은 비교 조건에 들어 있지 않다.
BE·BS 플래그가 말하는 것과 말하지 않는 것
Level 3에는 백업과 관련된 두 플래그가 있다. 동기화되는 자격 증명을 다룰 때 카운터와 섞어 해석하기 쉬워서 여기서 같이 본다.
- BE (Backup Eligibility, 비트 3). 자격 증명을 만든 인증기가 생성 시점에 백업 가능 여부를 정한다. §6.1.3에 따르면 이 값은
authenticatorMakeCredential에서 정해지고 바뀌면 안 된다(MUST NOT). BE가 1이면 multi-device credential, 0이면 single-device credential이라고 부른다. - BS (Backup State, 비트 4). 지금 백업돼 있는지를 나타내며 시간에 따라 바뀔 수 있다. BE가 0인데 BS가 1인 조합은 허용되지 않고, §7.2는 RP가 이를 확인하도록 한다.
§4의 용어 정의는 백업 가능한 자격 증명이 피어 간 동기화, 클라우드 동기화, 로컬 네트워크 동기화, 수동 가져오기·내보내기 같은 방법으로 처음 만든 인증기가 아닌 다른 인증기에 존재하게 될 수 있다고 설명한다. 명세가 정의한 백업이므로, 자격 증명이 여러 기기에 있다는 사실 자체를 복제 공격과 같은 것으로 볼 근거는 없다.
이 플래그들이 말하지 않는 것도 있다.
- BE나 BS 값으로 그 자격 증명의 카운터가 0인지, 어떻게 움직이는지를 알 수 없다. 이 글에서 읽은 절(§6.1.1, §6.1.3, §7.2)에는 두 플래그와
signCount를 연결하는 규칙이 없다. “동기화 자격 증명은 카운터가 늘 0”이라고 단정할 근거도 이 명세에서는 찾을 수 없다. - BS가 1에서 0으로 바뀌는 것을 §6.1.3은 사용자가 백업 서비스를 끄거나 백업 서비스에 문제가 생긴 결과일 수 있다고 설명하고, 다른 인증 수단을 확인하도록 안내하라고 권한다. 복제의 신호로 설명하지 않는다.
BE는 다른 문제다. §7.2는 RP가 백업 상태를 업무 로직이나 정책에 쓰는 경우, 저장된 backupEligible과 이번 BE 값이 일치하는지 확인하도록 한다. 바뀌면 안 되는 값이 바뀐 것이므로 이 확인은 카운터 비교와 별개인 검증 단계다.
점검할 것
- 비교 조건이 “두 값 중 하나라도 0이 아니면”인지 확인한다. 새 값만 보고 0을 건너뛰면 0이 아니던 카운터가 0으로 돌아온 경우를 놓친다.
- 증가 폭을 1로 가정하는 코드가 없는지 본다.
signCount저장 컬럼이 부호 없는 32비트 값을 모두 담을 수 있는지 확인한다.- 작거나 같은 값이 왔을 때 무엇을 할지, 저장값을 갱신할지를 정책으로 명시한다. 명세는 이 결정을 RP에 맡긴다.
- 같은 자격 증명의 assertion을 여러 서버가 동시에 처리한다면, 처리 순서가 뒤바뀌어 생기는 불일치를 어떻게 구분할지 생각해 둔다.
- 카운터 비교 코드가 서명·challenge·origin·RP ID·UP/UV 검증을 대신하지 않는지 확인한다.
참고 자료
2026-10-15에 확인했다.
- Web Authentication: An API for accessing Public Key Credentials Level 3 (W3C Recommendation, 2026-08-25) — §4(Backup Eligibility, Backup State, credential record의 signCount), §6.1, §6.1.1, §6.1.3, §6.3.2, §6.3.3, §7.1, §7.2