Access Token과 Refresh Token의 역할 분리
Access Token과 Refresh Token의 역할 분리
Access Token은 API 접근에 사용하고 짧게 만료한다. Refresh Token은 새 Access Token 발급에만 사용하며 더 강하게 저장하고 폐기 가능하게 관리한다.
목차
- #왜 이 문제가 생기는가
- #두 토큰은 사용처가 다르다
- #수명과 권한 범위를 함께 줄인다
- #토큰 형식은 역할과 별개다
- #웹과 네이티브 앱의 저장 경계
- #갱신 요청의 전체 흐름
- #여러 요청이 동시에 401을 만났을 때
- #로그아웃과 세션 폐기
- #기기별 세션으로 추적하기
- #설계 점검 목록
- #결론
- #관련 노트
왜 이 문제가 생기는가
로그인을 오래 유지하려고 API 호출에 사용하는 토큰의 만료 시간을 30일로 만들면 구현은 단순하다. 하지만 토큰이 로그, 악성 확장 프로그램, XSS, 분실 기기에서 유출되면 공격자도 30일 동안 같은 권한을 행사할 수 있다. 반대로 모든 토큰을 10분 만에 만료시키면 사용자가 계속 로그인해야 한다.
Access Token과 Refresh Token의 분리는 이 두 요구를 조정한다. 자주 노출되는 API 자격 증명은 짧고 제한적으로 사용하고, 로그인 지속 권한은 더 좁은 endpoint와 강한 저장·폐기 정책 아래 둔다. 두 토큰은 단순히 만료 시간만 다른 문자열이 아니라 서로 다른 책임을 가진다.
토큰 값, 사용자와 기기 ID, 만료 시간은 구조를 설명하기 위한 가상 값이다. 실제 서비스 토큰이나 고정된 보안 정답이 아니다.
두 토큰은 사용처가 다르다
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 주된 용도 | 보호된 API 접근 | 새 Access Token 발급 |
| 전달 대상 | Resource Server | Authorization Server의 token endpoint |
| 노출 빈도 | API 요청마다 자주 사용 | 갱신할 때만 사용 |
| 일반적 수명 | 짧음 | 더 길지만 제한·만료 필요 |
| 권한 | 특정 audience와 scope | 동의한 grant와 client에 결합 |
| 유출 대응 | 짧은 만료, sender constraint 등 | 폐기, rotation 또는 sender constraint |
Refresh Token을 일반 API의 Authorization 헤더로 보내면 역할 분리가 무너진다. Resource Server는 refresh token을 받아서는 안 되고, Authorization Server의 제한된 갱신 endpoint만 이를 처리해야 한다.
# 일반 API 요청
GET /documents HTTP/1.1
Authorization: Bearer access-token-value
# 토큰 갱신 요청
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=refresh-token-value
웹 BFF 구조에서는 브라우저가 refresh token을 직접 다루지 않고 HttpOnly 세션 쿠키만 보내며, 서버가 token endpoint와 통신하도록 만들 수도 있다. 실행 환경과 위협 모델에 따라 전달 구조가 달라진다.
수명과 권한 범위를 함께 줄인다
Access Token이 짧게 만료되더라도 모든 API에 관리자 scope로 접근할 수 있다면 유출 순간의 피해가 크다. 수명뿐 아니라 audience와 scope를 최소화한다.
{
"sub": "user-42",
"aud": "documents-api",
"scope": "documents:read documents:write",
"exp": 1788282900
}
문서 API용 토큰을 결제 API가 받아들이지 않도록 각 Resource Server가 audience를 확인한다. 사용자가 허용한 전체 권한보다 현재 client와 작업에 필요한 좁은 scope를 발급한다.
만료 시간은 업계 숫자를 복사하기보다 위험과 사용성을 기준으로 정한다. 금액 이체 토큰과 공개 프로필 조회 토큰은 같은 수명이 필요하지 않다. Refresh Token도 무제한으로 연장하지 않고 절대 수명, 비활성 만료, client·사용자·resource binding을 정책으로 둔다.
토큰 형식은 역할과 별개다
Access Token은 JWT일 수도 있고 서버 조회가 필요한 opaque 문자열일 수도 있다. Refresh Token도 마찬가지다. JWT 모양인지보다 누가 발급하고 어디서 검증하며 어떤 권한을 나타내는지가 역할을 결정한다.
opaque access token: atk_kG7...random...
JWT access token: header.payload.signature
opaque refresh token: rtk_P4m...random...
JWT Access Token은 Resource Server가 로컬 서명 검증을 할 수 있어 빠르지만 즉시 폐기와 권한 변경 반영이 어렵다. opaque token은 introspection이나 서버 session 조회가 필요하지만 중앙 폐기 상태를 확인하기 쉽다. 요구에 따라 선택하며, JWT 검증 정책을 생략하지 않는다.
Refresh Token에는 장기 권한과 재발급 능력이 있으므로 굳이 client가 내용을 읽어야 할 이유가 없는 경우가 많다. 예측 불가능한 opaque 값과 서버 session 레코드를 사용하면 폐기·rotation·기기별 추적을 명확히 구현할 수 있다.
웹과 네이티브 앱의 저장 경계
토큰 저장에는 모든 환경에 통하는 한 줄 정답이 없다. 공격자는 어디에서 코드를 실행할 수 있고 어떤 저장소를 읽을 수 있는지 봐야 한다.
브라우저
localStorage와 sessionStorage 값은 같은 origin에서 실행되는 JavaScript가 읽을 수 있으므로 XSS가 발생하면 토큰도 탈취될 수 있다. Refresh Token을 HttpOnly·Secure 쿠키 또는 BFF 서버 세션 뒤에 두면 JavaScript의 직접 읽기를 막을 수 있다. 다만 쿠키는 요청에 자동으로 붙으므로 SameSite와 CSRF 방어가 별도로 필요하다.
Set-Cookie: __Host-session=opaque-browser-session; Path=/; Secure; HttpOnly; SameSite=Lax
Access Token을 브라우저 메모리에만 두면 새로고침 시 복구 흐름이 필요하지만 장기 저장 노출을 줄일 수 있다. CSP, 출력 escaping, dependency 관리 등 XSS 예방이 여전히 핵심이다.
네이티브 앱
모바일에서는 OS가 제공하는 보호 저장소를 사용하고, 기기 백업·디버그 로그·루팅 환경의 위협을 검토한다. 앱 번들에 client secret을 넣어 confidential client처럼 취급하면 안 된다. 공개 client에는 PKCE와 refresh token rotation 또는 sender-constrained token 같은 대책을 적용한다.
위젯이나 확장 프로그램이 본 앱과 일부 데이터를 공유하더라도 장기 Refresh Token을 모든 프로세스에 복사할 필요는 없다. 각 실행 환경이 실제로 필요한 API와 수명을 기준으로 최소 권한을 발급한다.
갱신 요청의 전체 흐름
sequenceDiagram
participant C as Client
participant A as Authorization Server
participant R as Resource Server
C->>R: API + Access Token A1
R-->>C: 401 invalid/expired token
C->>A: Refresh Token R1로 갱신
A->>A: client·grant·scope·만료 검증
A-->>C: Access Token A2 + 필요 시 Refresh Token R2
C->>R: 원 요청 + A2
R-->>C: 성공 응답갱신 성공 응답은 캐시되지 않도록 하고 토큰 원문이 access log에 남지 않게 한다.
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
{
"access_token": "short-lived-access-token",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "next-refresh-token"
}
Refresh Token의 scope를 갱신 과정에서 임의로 넓히지 않는다. 새 access token의 권한은 원래 승인된 범위 안이어야 한다.
여러 요청이 동시에 401을 만났을 때
화면 진입 시 API 다섯 개가 동시에 만료된 Access Token을 사용하면 갱신 요청도 다섯 번 발생할 수 있다. Rotation을 사용한다면 첫 요청이 Refresh Token을 교체한 뒤 나머지 요청은 이미 사용한 토큰을 제시하게 된다.
클라이언트에서는 진행 중인 갱신 Promise 하나를 공유한다.
let refreshInFlight: Promise<string> | null = null;
async function getFreshAccessToken(): Promise<string> {
if (!refreshInFlight) {
refreshInFlight = requestTokenRefresh()
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function retryAfterAuthentication(request: Request) {
const accessToken = await getFreshAccessToken();
return fetch(withBearerToken(request, accessToken));
}
재시도는 한 번으로 제한하고 갱신 endpoint의 401을 다시 갱신하려는 루프에서 제외한다. 본 요청이 POST라면 Access Token 갱신 후 자동 재시도해도 안전한지 멱등성을 확인한다.
로그아웃과 세션 폐기
클라이언트 저장소에서 토큰을 지우는 것만으로 서버의 Refresh Token 권한이 사라지지는 않는다. 로그아웃 시 서버 session 또는 grant를 폐기하고 관련 쿠키를 만료시킨다.
async function logoutCurrentSession(sessionId: string, actorId: string) {
await refreshSessionRepository.revoke({ sessionId, actorId });
await auditLog.record({ event: "session-revoked", sessionId, actorId });
}
Access Token이 self-contained JWT라면 이미 발급된 토큰은 만료까지 유효할 수 있다. 짧은 수명으로 창을 제한하고, 고위험 작업에는 현재 session 상태 재확인이나 denylist·token introspection을 선택적으로 사용할 수 있다.
비밀번호 변경, 계정 잠금, 사용자 요청의 “모든 기기에서 로그아웃”은 현재 세션 하나가 아니라 관련 grant 전체를 폐기해야 할 수 있다. 폐기 범위를 제품 동작으로 명시한다.
기기별 세션으로 추적하기
Refresh Token 하나를 모든 기기에 공유하면 한 기기만 로그아웃하거나 탈취 지점을 파악하기 어렵다. 로그인 인스턴스별 session을 만든다.
CREATE TABLE refresh_sessions (
id CHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
client_id VARCHAR(80) NOT NULL,
token_family_id CHAR(36) NOT NULL,
token_hash CHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
last_used_at DATETIME NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL
);
서버에는 토큰 원문 대신 비교 가능한 안전한 hash를 저장하고, 사용자에게 기기 이름·최근 사용·발급 위치를 보여 줄 때는 개인정보와 정확도를 고려한다. IP나 user agent는 세션 보조 정보이지 확실한 기기 인증 수단이 아니다.
설계 점검 목록
- Refresh Token이 일반 Resource Server로 전송되지 않는가?
- Access Token의 audience, scope, 수명이 최소화되어 있는가?
- 실행 환경별 저장 위협과 CSRF·XSS를 함께 검토했는가?
- 갱신 요청을 single-flight로 묶고 무한 재시도를 막는가?
- 비밀번호 변경·로그아웃·계정 잠금의 폐기 범위가 정의되어 있는가?
- 토큰 원문을 DB와 로그에 남기지 않는가?
- 기기별 Refresh Session의 절대·비활성 만료가 있는가?
Access Token은 API 접근에 사용하고 짧게 만료한다. Refresh Token은 새 Access Token 발급에만 사용하며 더 강하게 저장하고 폐기 가능하게 관리한다.
결론
Access Token은 제한된 Resource Server와 scope에 짧게 접근하는 자격 증명이고, Refresh Token은 Authorization Server에서 새 Access Token을 얻는 장기 권한이다. 두 토큰의 endpoint, 저장소, audience, 폐기 정책을 분리하고 기기별 Refresh Session을 서버에서 추적한다. 갱신 동시성과 로그아웃 범위까지 설계해야 짧은 Access Token이 실제 보안 이점으로 이어진다.