Refresh Token Rotation으로 재사용 공격 줄이기
Refresh Token Rotation으로 재사용 공격 줄이기
서버는 토큰 원문 대신 해시와 토큰 family를 저장한다. 이미 사용된 이전 토큰이 다시 들어오면 재사용 공격으로 보고 해당 family 전체를 폐기할 수 있다.
목차
- #왜 이 문제가 생기는가
- #rotation이 탐지하는 공격
- #token family와 상태 모델
- #토큰 원문 대신 hash를 저장한다
- #갱신을 하나의 transaction으로 처리하기
- #두 갱신 요청이 동시에 들어오는 경우
- #재사용을 탐지하면 family를 폐기한다
- #grace window의 보안과 사용성 trade-off
- #절대 만료와 비활성 만료
- #클라이언트에서 지켜야 할 규칙
- #반드시 테스트할 실패 시나리오
- #결론
- #관련 노트
왜 이 문제가 생기는가
Refresh Token은 Access Token보다 오래 살아 있고 새 토큰을 발급받을 권한을 가진다. 공격자가 하나를 복사하면 사용자가 비밀번호를 바꾸거나 세션을 폐기하기 전까지 Access Token을 계속 만들 수 있다. 단순히 만료 시간을 줄이면 로그인 유지 경험도 함께 짧아진다.
Refresh Token Rotation은 갱신에 성공할 때마다 새 Refresh Token을 발급하고 기존 토큰을 더 이상 사용할 수 없게 만든다. 나중에 이미 사용된 토큰이 다시 제시되면 정상 client와 공격자 중 누군가가 토큰을 복제해 사용했다는 신호로 볼 수 있다. 다만 서버는 어느 쪽이 공격자인지 알 수 없으므로 안전한 대응과 정상 사용자의 재로그인 비용을 함께 설계해야 한다.
테이블, token hash, family ID와 서비스 코드는 rotation의 원리를 설명하기 위해 만든 가상 예제다. 특정 인증 시스템의 실제 스키마나 비밀값을 사용하지 않았다.
rotation이 탐지하는 공격
고정된 Refresh Token R1을 정상 사용자와 공격자가 모두 가지고 있다고 하자. 만료 전까지 둘 다 같은 R1로 새 Access Token을 요청할 수 있고, 서버는 복제가 일어났다는 사실을 알기 어렵다.
Rotation에서는 정상 사용자가 R1을 사용하면 서버가 R1을 소비하고 R2를 발급한다. 이후 공격자가 복사해 둔 R1을 사용하면 이미 소비된 토큰이므로 재사용을 탐지한다.
sequenceDiagram
participant U as 정상 Client
participant A as Authorization Server
participant X as 공격자
Note over U,X: 공격자가 R1을 복사함
U->>A: R1로 갱신
A-->>U: A2 + R2, R1은 used
X->>A: 탈취한 R1 재사용
A->>A: reused R1 탐지
A-->>X: invalid_grant
A->>A: R1 family 전체 폐기반대로 공격자가 R1을 먼저 사용하면 공격자가 R2를 받고 정상 사용자의 다음 R1 요청에서 재사용이 드러난다. 서버는 먼저 온 요청만으로 공격자와 정상 사용자를 구분할 수 없다. 그래서 family 전체를 폐기하고 정상 사용자에게도 재인증을 요구하는 보수적 대응이 필요할 수 있다.
token family와 상태 모델
한 로그인 grant에서 rotation으로 이어진 Refresh Token들을 같은 family로 묶는다.
Family F1
R1 (used) → R2 (used) → R3 (active)
각 토큰은 최소한 다음 상태를 구분할 수 있다.
stateDiagram-v2
[*] --> Active: 발급
Active --> Used: 정상 갱신
Active --> Revoked: 로그아웃·보안 이벤트
Active --> Expired: 만료
Used --> ReuseDetected: 다시 제시됨
ReuseDetected --> FamilyRevoked: 관련 active 토큰 폐기한 행을 계속 덮어쓰면 이전 토큰이 재사용되었을 때 어느 family인지 추적하기 어렵다. 사용한 token 레코드를 최소한 탐지 기간 동안 보존하고 successor 관계를 기록한다.
CREATE TABLE refresh_tokens (
id CHAR(36) PRIMARY KEY,
family_id CHAR(36) NOT NULL,
user_id BIGINT NOT NULL,
client_id VARCHAR(80) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
parent_token_id CHAR(36) NULL,
status VARCHAR(16) NOT NULL,
issued_at DATETIME NOT NULL,
used_at DATETIME NULL,
expires_at DATETIME NOT NULL,
absolute_expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
INDEX idx_refresh_family (family_id, status)
);
실제 스키마는 DB와 조회 패턴에 맞춰야 한다. 핵심은 token 하나의 상태와 로그인 grant 전체인 family 상태를 원자적으로 바꿀 수 있는 구조다.
토큰 원문 대신 hash를 저장한다
데이터베이스가 유출되었을 때 저장된 Refresh Token 원문이 즉시 사용되지 않도록 서버에는 hash를 저장한다. 토큰 자체는 충분히 긴 CSPRNG 바이트로 생성한다.
function issueRefreshToken() {
const raw = crypto.randomBytes(32).toString("base64url");
const hash = crypto
.createHash("sha256")
.update(raw, "utf8")
.digest("hex");
return { raw, hash };
}
사용자가 선택한 비밀번호와 달리 서버가 생성한 Refresh Token은 충분한 entropy를 가져야 하므로 빠른 cryptographic hash로 lookup할 수 있다. 예측 가능한 짧은 token이라면 hash 저장만으로 brute force를 막을 수 없다.
원문은 발급 응답에서 한 번 전달하고 서버 로그, tracing attribute, 오류 메시지에 남기지 않는다. 운영자가 token을 검색해야 한다면 원문 대신 앞부분을 마스킹한 식별자나 별도 token ID를 사용한다.
갱신을 하나의 transaction으로 처리하기
갱신은 “기존 토큰 확인 → 소비 → 다음 토큰 생성”이 하나의 원자적 작업이어야 한다.
type RotateCommand = {
rawToken: string;
clientId: string;
};
async function rotateRefreshToken(command: RotateCommand) {
const currentHash = hashRefreshToken(command.rawToken);
const next = issueRefreshToken();
const result = await database.transaction(async (tx) => {
const current = await tx.refreshTokens.findByHashForUpdate(currentHash);
if (!current || current.clientId !== command.clientId) {
throw new InvalidGrantError();
}
if (current.status !== "active") {
await tx.refreshTokens.revokeFamily(current.familyId, "reuse-detected");
throw new RefreshTokenReuseError(current.familyId);
}
if (isExpired(current)) {
await tx.refreshTokens.markExpired(current.id);
throw new InvalidGrantError();
}
await tx.refreshTokens.markUsed(current.id, new Date());
await tx.refreshTokens.insert({
familyId: current.familyId,
userId: current.userId,
clientId: current.clientId,
parentTokenId: current.id,
tokenHash: next.hash,
status: "active",
absoluteExpiresAt: current.absoluteExpiresAt,
});
return { userId: current.userId, rawNextToken: next.raw };
});
return issueTokenResponse(result);
}
코드는 개념 예시다. 중요한 점은 findForUpdate 같은 잠금 또는 조건부 원자 update로 현재 active 토큰을 한 요청만 소비하게 하는 것이다. transaction 안에서 외부 네트워크 호출을 오래 수행하면 lock 시간이 늘어나므로 서명과 응답 생성 책임도 분리해 검토한다.
두 갱신 요청이 동시에 들어오는 경우
잠금 없이 조회 후 update하면 두 요청 모두 active 상태를 읽을 수 있다.
요청 A: R1 active 확인 ───────── R2 생성
요청 B: R1 active 확인 ───────── R3 생성
이제 family에 active successor가 두 개 생겨 rotation의 단일 체인이 깨진다. 다음과 같은 DB 보장을 조합한다.
- 현재 token 행에 row lock을 건다.
UPDATE ... WHERE status = 'active'의 영향 행 수가 정확히 1인지 확인한다.- 한 parent에서 successor가 하나만 생기도록 unique constraint를 둔다.
- family에 허용되는 active leaf 수를 정책으로 보장한다.
UPDATE refresh_tokens
SET status = 'used', used_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'active';
영향 행이 0이면 다른 transaction이 먼저 소비했거나 상태가 바뀐 것이다. 이를 단순 서버 오류로 처리하지 말고 재사용 또는 동시 요청 정책에 따라 판단한다.
클라이언트에서도 갱신 요청을 single-flight로 묶어 정상 동시 요청을 줄인다. 하지만 악성 client를 신뢰할 수 없으므로 서버 원자성은 여전히 필요하다.
재사용을 탐지하면 family를 폐기한다
이미 used인 R1이 다시 제시되면 현재 active successor R2 또는 그 뒤 토큰까지 공격자가 가지고 있을 가능성을 배제할 수 없다. 같은 family의 active 토큰을 모두 폐기한다.
async function handleReuse(tx, reusedToken) {
await tx.refreshTokens.revokeFamily(
reusedToken.familyId,
"refresh-token-reuse",
);
await tx.securityEvents.insert({
type: "REFRESH_TOKEN_REUSE",
userId: reusedToken.userId,
familyId: reusedToken.familyId,
occurredAt: new Date(),
});
}
클라이언트에는 구체적인 탐지 로직을 모두 노출하지 않고 표준적인 invalid_grant 실패로 재인증을 요구할 수 있다. 내부에서는 보안 이벤트를 남기고 위험도에 따라 사용자 알림, 해당 기기 세션 폐기, 전체 계정 세션 검토를 수행한다.
재사용된 토큰이 어느 family에 속했는지 유지하지 않으면 현재 활성 토큰을 찾아 폐기할 수 없다. 관계 정보 보존이 탐지 후 대응의 핵심이다.
grace window의 보안과 사용성 trade-off
모바일 네트워크에서 서버는 갱신을 성공했지만 응답이 유실될 수 있다. client는 새 R2를 받지 못해 R1을 다시 보낸다. 엄격한 rotation은 이를 공격 재사용으로 보고 로그아웃시킨다.
짧은 grace window 안에서 동일 client·동일 요청의 재전송에 같은 결과를 돌려주는 전략을 고려할 수 있다. 그러나 서버가 새 R2 원문을 다시 제공하려면 이를 복구 가능한 형태로 잠시 보관해야 하고, 공격자도 window를 이용할 수 있다.
선택지는 대략 다음과 같다.
| 정책 | 장점 | 비용·위험 |
|---|---|---|
| 즉시 재사용 탐지 | 단순하고 보수적 | 정상 응답 유실도 재로그인 발생 |
| 짧은 grace + 결과 재사용 | 네트워크 재시도에 관대 | 구현 복잡, 탐지 민감도 감소 |
| client 요청 ID와 결과 저장 | 동일 요청 구분 가능 | 저장·만료·원문 보호 필요 |
| sender-constrained token | 복제 token 단독 사용 제한 | 키 관리와 client 지원 필요 |
서버는 정상 사용자와 공격자를 확실히 구분할 수 없다는 한계를 문서화해야 한다. 위험도가 높은 서비스라면 사용성보다 family 폐기를 선택할 수 있고, 불안정한 네트워크가 중요한 모바일 서비스라면 제한된 멱등 응답 정책을 설계할 수 있다.
절대 만료와 비활성 만료
매 갱신마다 만료 시간을 현재 시각 기준으로 계속 연장하면 탈취된 family가 영원히 살아남을 수 있다. 최초 grant의 절대 만료는 successor에게 그대로 상속한다.
const nextTokenTimes = {
issuedAt: now,
expiresAt: min(addDays(now, 7), current.absoluteExpiresAt),
absoluteExpiresAt: current.absoluteExpiresAt,
};
다음 시간을 구분한다.
- Access Token 만료: Resource Server에서 사용할 수 있는 짧은 기간
- Refresh Token 개별 만료: 해당 rotation token을 사용할 수 있는 기간
- 비활성 만료: 일정 기간 갱신하지 않은 session 폐기
- 절대 만료: 최초 로그인 이후 반드시 다시 인증해야 하는 시점
비밀번호 변경, 계정 비활성화, 사용자의 모든 기기 로그아웃 같은 보안 이벤트에도 family를 폐기한다.
클라이언트에서 지켜야 할 규칙
갱신 응답에 새 Refresh Token이 있으면 저장 성공을 원자적으로 처리한 뒤 이후 요청에서 이전 토큰을 사용하지 않는다.
async function refreshSession() {
const response = await authApi.rotate(currentRefreshToken());
await secureTokenStore.replace({
accessToken: response.accessToken,
refreshToken: response.refreshToken,
});
return response.accessToken;
}
브라우저의 HttpOnly cookie rotation이라면 Set-Cookie가 이 교체를 담당하고 JavaScript는 원문을 보지 않는다. 어떤 환경이든 갱신 요청을 동시에 여러 개 보내지 않고, invalid_grant 뒤 무한 재시도하지 않으며, 재인증 화면으로 전환한다.
반드시 테스트할 실패 시나리오
- R1 성공 후 R1을 다시 보내면 family가 폐기되는가?
- R1 동시 요청 두 개 중 하나만 successor를 만드는가?
- 갱신 transaction이 중간에 실패하면 active token이 일관되게 남는가?
- 절대 만료가 rotation마다 연장되지 않는가?
- 다른 client ID에서 같은 token을 사용할 수 없는가?
- 로그아웃·비밀번호 변경이 관련 family를 폐기하는가?
- 응답 유실과 재전송이 정한 grace 정책대로 동작하는가?
- 토큰 원문이 DB, 오류 로그, APM attribute에 남지 않는가?
서버는 토큰 원문 대신 해시와 토큰 family를 저장한다. 이미 사용된 이전 토큰이 다시 들어오면 재사용 공격으로 보고 해당 family 전체를 폐기할 수 있다.
결론
Refresh Token Rotation은 갱신마다 이전 토큰을 소비하고 새 토큰을 발급해, 이미 사용된 토큰의 재제시를 복제 신호로 탐지하는 방식이다. 서버는 token hash와 family 관계를 보존하고 transaction·row lock·제약 조건으로 successor를 하나만 만들어야 한다. 재사용 시 현재 family를 폐기하며, 응답 유실을 위한 grace 정책과 절대·비활성 만료를 보안 요구에 맞게 명시해야 한다.