JWT란 무엇인가?
JWT(JSON Web Token)는 사용자나 시스템에 대한 정보를 JSON 형태로 담고, 이를 안전하게 전달하기 위한 토큰 표준이다. 일반적으로 웹 애플리케이션과 게임 서버의 인증 시스템에서 사용한다.
JWT를 사용하면 서버가 별도의 세션 저장소를 조회하지 않고도 토큰의 서명과 클레임을 검증하여 요청의 유효성을 판단할 수 있다.
다만 JWT 자체가 사용자를 인증하거나 권한을 부여하는 것은 아니다. 서버가 토큰을 발급하고, 서명을 검증하고, 토큰에 포함된 정보를 신뢰할 수 있는지 판단하는 과정이 필요하다.
JWT를 이용한 인증 시스템을 설계할 때는 다음 항목을 고려해야 한다.
- JWT의 구조와 서명 검증
- Access Token과 Refresh Token의 역할 분리
- 토큰 만료와 갱신
- Refresh Token Rotation 및 재사용 탐지
- 로그아웃과 토큰 무효화
- 토큰 저장 위치와 브라우저 보안
- HttpOnly, Secure, SameSite 쿠키 설정
JWT의 구조
JWT는 일반적으로 다음 세 부분으로 구성된다.
Header.Payload.Signature
각 부분은 Base64URL 인코딩으로 표현되며, 점(.)으로 구분한다.
Header
Header에는 토큰의 유형과 서명 알고리즘 등의 정보가 포함된다.
{
"alg": "HS256",
"typ": "JWT"
}
alg: 서명에 사용하는 알고리즘typ: 토큰 유형
예를 들어 HS256은 비밀키를 이용한 HMAC-SHA256 서명 알고리즘이다.
서버는 클라이언트가 전달한 alg 값을 무조건 신뢰해서는 안 된다. 서버에서 허용한 알고리즘을 명시적으로 지정하고 검증해야 한다.
Payload
Payload에는 사용자 식별자, 발급 시간, 만료 시간 등의 클레임(Claim)이 포함된다.
{
"sub": "user-123",
"role": "user",
"iat": 1792832400,
"exp": 1792833300,
"iss": "api.example.com",
"aud": "game-client"
}
주요 클레임은 다음과 같다.
| 클레임 | 의미 |
|---|---|
sub | 토큰의 주체, 일반적으로 사용자 식별자 |
iat | 토큰 발급 시각 |
exp | 토큰 만료 시각 |
iss | 토큰 발급자 |
aud | 토큰을 사용할 대상 |
nbf | 토큰을 사용하기 시작할 수 있는 시각 |
jti | 토큰의 고유 식별자 |
Payload는 암호화되어 있지 않다. Base64URL은 인코딩일 뿐이므로 토큰을 획득한 사람은 Payload를 디코딩할 수 있다.
따라서 비밀번호, 비밀번호 해시, 개인정보, 결제 정보 등의 민감한 데이터를 JWT에 넣어서는 안 된다.
Signature
Signature는 Header와 Payload가 변조되지 않았는지 검증하기 위한 서명이다.
HS256을 사용하는 경우 개념적으로 다음과 같이 생성한다.
HMAC-SHA256(
secret,
Base64URL(Header) + "." + Base64URL(Payload)
)
공격자가 Payload의 사용자 식별자를 변경하면 기존 서명과 일치하지 않으므로 서버의 검증에 실패한다.
JWT의 서명은 데이터의 무결성과 발급 주체에 대한 신뢰를 검증하는 수단이다. 서명은 Payload를 숨기지 않으며, 토큰 자체의 탈취를 방지하지도 않는다.
JWT 서명 검증
서버는 인증이 필요한 요청을 받을 때 JWT의 유효성을 검증해야 한다.
검증 과정은 다음과 같다.
- 토큰의 형식이 올바른지 확인한다.
- 허용된 서명 알고리즘인지 확인한다.
- 서명을 검증한다.
- 만료 시간인
exp를 확인한다. - 발급 전 사용 제한인
nbf를 확인한다. - 발급자
iss와 대상aud가 서비스 설정과 일치하는지 확인한다. - 토큰 유형과 용도가 해당 요청에 적합한지 확인한다.
- 필요한 경우 사용자 상태와 토큰 폐기 여부를 확인한다.
서명이 유효하더라도 사용자가 정지되었거나, 토큰이 폐기되었거나, 해당 리소스에 대한 권한이 없다면 요청을 거부해야 한다.
또한 JWT의 role 클레임만 확인하고 모든 권한을 결정하는 방식은 주의해야 한다. 토큰 발급 이후 권한이 변경되었을 수 있으므로, 높은 보안 수준이 필요한 작업에서는 최신 사용자 상태나 권한 정보를 추가로 확인할 수 있다.
Access Token과 Refresh Token
Access Token과 Refresh Token은 목적이 다르다. 두 토큰을 분리하면 인증 요청에 사용하는 토큰의 수명을 짧게 유지하면서도 사용자가 매번 로그인하지 않도록 만들 수 있다.
Access Token
Access Token은 API에 접근할 때 사용하는 토큰이다.
예를 들어 다음과 같은 요청에 사용한다.
GET /api/profile
Authorization: Bearer <access_token>
서버는 토큰을 검증한 후 사용자 식별자와 권한을 확인하고 요청을 처리한다.
Access Token의 특징은 다음과 같다.
- API 접근 권한을 증명하는 데 사용한다.
- 일반적으로 짧은 만료 시간을 설정한다.
- 탈취되면 만료되거나 별도로 무효화될 때까지 악용될 수 있다.
- 만료 시간을 짧게 설정하면 탈취에 따른 피해 시간을 줄일 수 있다.
예를 들어 Access Token의 유효 기간을 10~15분으로 설정할 수 있다. 이는 예시이며, 실제 만료 시간은 서비스의 보안 요구사항과 사용자 경험에 따라 결정해야 한다.
Refresh Token
Refresh Token은 새로운 Access Token을 발급받기 위한 토큰이다.
Access Token이 만료되었을 때 Refresh Token을 사용하면 사용자는 로그인 정보를 다시 입력하지 않고도 인증을 이어갈 수 있다.
일반적인 특징은 다음과 같다.
- Access Token보다 긴 유효 기간을 가진다.
- 일반 API 요청의 인증 수단으로 사용하지 않는다.
- 토큰 갱신 전용 엔드포인트에서만 사용한다.
- 탈취 시 장기간 악용될 위험이 있으므로 서버 측 관리와 폐기 정책이 필요하다.
예를 들어 Refresh Token의 절대 유효 기간을 7일로 설정할 수 있다.
Access Token과 Refresh Token은 각각의 용도에 맞게 발급하고 검증해야 한다. 서로 다른 토큰 유형, 발급 목적, 대상, 검증 규칙을 사용하면 한 종류의 토큰을 다른 용도로 오용하는 위험을 줄일 수 있다.
토큰 만료와 갱신
토큰 만료는 인증 정보가 무기한 유효하게 유지되는 것을 방지하는 장치다.
Access Token이 만료되면 서버는 일반적으로 인증 실패를 반환한다. 클라이언트는 이 응답을 확인한 뒤 Refresh Token을 이용해 토큰 갱신을 시도할 수 있다.
일반적인 흐름은 다음과 같다.
클라이언트
|
| API 요청 + Access Token
v
API 서버
|
| Access Token 만료
v
401 Unauthorized
|
| Refresh Token으로 갱신 요청
v
인증 서버
|
| Refresh Token 검증
v
새 Access Token 발급
|
v
API 재요청
갱신 요청은 예를 들어 다음과 같이 설계할 수 있다.
POST /auth/refresh
Cookie: refreshToken=<refresh_token>
서버는 Refresh Token의 서명, 만료, 발급 목적, 세션 상태, 폐기 여부 등을 검증한 후 새로운 Access Token을 발급한다.
Refresh Token Rotation을 사용하는 경우에는 기존 Refresh Token을 폐기하고 새로운 Refresh Token도 함께 발급한다.
클라이언트는 토큰 갱신이 완료된 후 원래 요청을 다시 시도할 수 있다. 단, 모든 401 Unauthorized 응답에서 무조건 갱신을 시도하면 안 된다. 잘못된 자격 증명이나 갱신 불가능한 상태에서도 무한 재시도할 수 있기 때문이다.
또한 여러 API 요청이 동시에 만료되는 상황을 고려해야 한다. 여러 갱신 요청이 동시에 발생하면 토큰 교체 과정에서 충돌할 수 있으므로 클라이언트에서는 갱신 요청을 단일화하고, 서버에서는 토큰 상태를 원자적으로 변경하는 방식이 필요하다.
Refresh Token Rotation
Refresh Token Rotation은 토큰 갱신 시 기존 Refresh Token을 폐기하고 새로운 Refresh Token을 발급하는 방식이다.
기존 Refresh Token을 계속 재사용하도록 허용하면 토큰이 탈취되었을 때 공격자가 오랜 기간 갱신을 반복할 수 있다.
Rotation을 적용하면 갱신할 때마다 사용 가능한 Refresh Token이 변경된다.
Rotation이 없는 구조
로그인
|
v
Refresh Token A 발급
|
+--> Access Token 갱신
|
+--> Access Token 갱신
|
+--> Access Token 갱신
기존 Refresh Token A가 계속 유효하다면, 이를 탈취한 공격자도 반복적으로 토큰을 갱신할 수 있다.
Rotation을 적용한 구조
로그인
|
v
Refresh Token A
|
| 갱신
v
Refresh Token A 폐기
Refresh Token B 발급
|
| 갱신
v
Refresh Token B 폐기
Refresh Token C 발급
이 방식에서는 각 갱신 단계에서 이전 토큰을 더 이상 정상적으로 사용할 수 없도록 처리한다.
다만 Rotation만 적용한다고 탈취 위험이 완전히 제거되는 것은 아니다. 공격자가 아직 사용되지 않은 최신 Refresh Token을 탈취하면 이를 사용할 수 있기 때문이다. 따라서 재사용 탐지, 세션 폐기, 안전한 토큰 저장을 함께 고려해야 한다.
Refresh Token 재사용 탐지 및 폐기
Refresh Token Rotation에서 중요한 기능 중 하나는 이미 사용되었거나 폐기된 Refresh Token이 다시 제출되었는지 탐지하는 것이다.
예를 들어 정상적인 사용자가 Refresh Token A를 사용해 갱신했다면 서버는 A를 폐기하고 B를 발급한다.
이후 누군가 다시 A를 제출하면 다음 두 가지 상황을 고려할 수 있다.
- 공격자가 탈취한 이전 토큰을 재사용했다.
- 정상 클라이언트의 중복 요청이나 네트워크 지연 때문에 이전 토큰이 다시 제출되었다.
서버는 두 상황을 무조건 구분할 수 있는 것은 아니다. 따라서 토큰 재사용을 탐지했을 때의 처리 정책과 동시 요청 제어를 함께 설계해야 한다.
서버 측 토큰 상태 관리
Refresh Token의 상태를 추적하기 위해 서버는 토큰 원문을 저장하는 대신 해시값이나 토큰 식별자를 저장할 수 있다.
예를 들어 다음과 같은 테이블을 구성할 수 있다.
CREATE TABLE refresh_tokens (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
family_id CHAR(36) NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
replaced_by CHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
각 필드의 역할은 다음과 같다.
| 필드 | 역할 |
|---|---|
user_id | 토큰 소유 사용자 |
token_hash | Refresh Token의 해시값 |
family_id | 하나의 로그인 세션에서 이어지는 토큰 계열 식별자 |
expires_at | 토큰 만료 시각 |
revoked_at | 토큰 폐기 시각 |
replaced_by | 후속 토큰의 해시값 |
created_at | 토큰 생성 시각 |
실제 구현에서는 인덱스, 외래 키, 데이터 보존 정책 등을 추가로 고려해야 한다.
JWT 자체를 저장할 필요는 없다. 서버가 받은 토큰을 안전한 방식으로 해시한 후 저장된 값과 비교할 수 있다. 무작위성이 충분한 토큰을 대상으로 SHA-256과 같은 해시를 사용하는 방식이 가능하다.
재사용 탐지 흐름
Refresh Token 제출
|
v
토큰 해시 조회
|
v
토큰 상태 확인
|
+--> 유효하고 미사용 상태
| |
| v
| 기존 토큰 폐기
| 새 토큰 발급
|
+--> 이미 폐기된 토큰
|
v
재사용 탐지 정책 실행
|
v
관련 토큰 계열 폐기
이미 폐기된 토큰이 다시 제출되었다면 해당 토큰이 속한 family_id를 기준으로 관련 Refresh Token을 모두 폐기할 수 있다.
예를 들어 다음과 같이 처리할 수 있다.
UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE family_id = ?
AND revoked_at IS NULL;
이렇게 하면 같은 로그인 세션에서 발급된 후속 토큰까지 폐기할 수 있다.
단, 재사용 탐지 시 모든 기기의 세션을 종료할지, 해당 로그인 세션만 종료할지는 정책에 따라 결정해야 한다. 일반적으로는 영향을 받은 토큰 계열을 폐기하고 재인증을 요구하는 방식이 합리적이다.
동시 요청과 원자성
토큰 재사용 탐지 로직은 데이터베이스 트랜잭션과 원자성을 고려해야 한다.
서버가 동시에 두 개의 갱신 요청을 받으면 두 요청이 모두 기존 토큰을 유효하다고 판단할 수 있다. 이 경우 서로 다른 후속 토큰이 발급되거나 정상적인 요청이 재사용으로 오인될 수 있다.
따라서 다음과 같은 대책을 고려해야 한다.
- 데이터베이스 트랜잭션 사용
- 행 잠금 또는 조건부 업데이트
- 기존 토큰의 미사용 상태를 확인하는 원자적 변경
- 동일 클라이언트의 갱신 요청 단일화
- 중복 요청과 실제 재사용을 구분하기 위한 명확한 정책
특히 이전 토큰이 이미 교체된 상태라면, 서버는 단순히 새 토큰을 발급하는 방식으로 우회해서는 안 된다. 그렇지 않으면 Rotation의 보안 목적이 약화된다.
로그아웃과 토큰 무효화
JWT는 일반적으로 자체적으로 유효성을 검증할 수 있도록 설계되어 있다. 따라서 서버가 JWT를 발급한 후 별도 상태를 확인하지 않는 구조에서는 로그아웃을 요청해도 기존 Access Token이 만료될 때까지 유효할 수 있다.
즉, 클라이언트에서 토큰을 삭제하는 것과 서버에서 토큰을 무효화하는 것은 서로 다른 작업이다.
클라이언트 토큰 삭제
로그아웃 시 브라우저나 앱에서 Access Token과 Refresh Token을 삭제한다.
이렇게 하면 해당 클라이언트는 더 이상 토큰을 이용한 정상적인 요청을 수행할 수 없다.
그러나 토큰이 이미 공격자에게 탈취되었다면 공격자의 토큰까지 자동으로 무효화되지는 않는다.
Refresh Token 폐기
서버는 로그아웃 요청을 받으면 해당 로그인 세션의 Refresh Token을 폐기한다.
이후 해당 토큰을 이용한 갱신을 거부한다.
이 방식은 장기 세션의 지속적인 사용을 차단하지만, 이미 발급된 Access Token은 별도의 검증 장치가 없다면 만료 전까지 계속 유효할 수 있다.
Access Token 즉시 무효화
Access Token도 즉시 무효화해야 한다면 다음 방법을 고려할 수 있다.
- 토큰의
jti를 기준으로 폐기 목록을 관리한다. - 사용자나 세션의 인증 버전을 관리하고 토큰의 버전과 비교한다.
- 요청마다 서버 측 세션 상태를 확인한다.
- Access Token의 유효 기간을 짧게 설정하고 Refresh Token을 폐기한다.
폐기 목록이나 세션 상태를 매 요청마다 확인하면 JWT의 무상태 검증이라는 장점이 일부 줄어든다. 따라서 보안 요구사항과 성능 사이의 균형이 필요하다.
예를 들어 일반적인 API에서는 짧은 Access Token과 Refresh Token 폐기를 사용하고, 결제·관리자 권한 변경처럼 즉각적인 무효화가 중요한 작업에서는 서버 측 상태를 추가로 확인할 수 있다.
토큰 저장 위치와 보안
토큰을 어디에 저장하는지는 인증 시스템의 보안 수준에 직접적인 영향을 준다.
localStorage
localStorage는 브라우저에 데이터를 지속적으로 저장할 수 있는 기능이다.
장점은 구현이 간단하고 JavaScript에서 쉽게 접근할 수 있다는 것이다.
그러나 같은 출처에서 실행되는 JavaScript도 저장된 토큰을 읽을 수 있다. 따라서 XSS(Cross-Site Scripting) 공격으로 악성 코드가 실행되면 토큰이 탈취될 수 있다.
특히 장기 유효 기간을 가진 Refresh Token을 localStorage에 저장하면 피해 범위가 커질 수 있다.
sessionStorage
sessionStorage는 일반적으로 브라우저 탭의 세션 단위로 데이터를 유지한다.
localStorage보다 수명이 짧을 수 있지만 JavaScript에서 접근할 수 있다는 점은 동일하다. 따라서 XSS 공격에 대한 근본적인 방어 수단은 아니다.
메모리
Access Token을 JavaScript 메모리에만 보관하면 페이지를 새로고침하거나 애플리케이션이 종료될 때 토큰이 사라질 수 있다.
지속 저장소에 토큰을 남기지 않는다는 장점이 있지만, 실행 중인 페이지에서 악성 JavaScript가 동작하면 메모리에 있는 토큰 역시 탈취될 수 있다.
HttpOnly 쿠키
HttpOnly 쿠키는 JavaScript에서 쿠키 값을 직접 읽을 수 없도록 설정한다.
브라우저가 요청에 쿠키를 자동으로 포함하므로 Refresh Token을 안전하게 관리하는 방법으로 활용할 수 있다.
다만 쿠키가 자동으로 전송된다는 특성 때문에 CSRF(Cross-Site Request Forgery) 공격을 고려해야 한다. SameSite 설정, Origin 검증, 필요한 경우 CSRF 토큰 등을 함께 적용해야 한다.
저장 위치 비교
| 저장 위치 | JavaScript 접근 | 주요 고려사항 |
|---|---|---|
localStorage | 가능 | XSS에 의한 토큰 탈취 |
sessionStorage | 가능 | XSS 및 탭 세션 수명 |
| 메모리 | 가능 | 실행 중인 페이지의 XSS |
| HttpOnly 쿠키 | 직접 접근 불가 | CSRF 및 쿠키 설정 |
어떤 저장 위치도 모든 공격을 방지하지는 못한다. 실제 설계에서는 XSS 방어, CSRF 방어, 토큰 만료, 폐기 정책, TLS 적용을 함께 고려해야 한다.
HttpOnly, Secure, SameSite 쿠키
쿠키를 사용해 토큰을 전달할 때는 브라우저가 제공하는 보안 속성을 올바르게 설정해야 한다.
예를 들어 Refresh Token을 쿠키에 저장하는 경우 다음과 같이 설정할 수 있다.
Set-Cookie: refreshToken=<refresh_token>; HttpOnly; Secure; SameSite=Lax; Path=/auth/refresh; Max-Age=604800
위 설정은 예시이며, 실제 서비스의 도메인 구성과 인증 흐름에 따라 조정해야 한다.
HttpOnly
HttpOnly는 JavaScript에서 쿠키에 직접 접근하는 것을 제한한다.
Set-Cookie: refreshToken=<token>; HttpOnly
일반적인 JavaScript 코드에서는 다음과 같이 해당 쿠키 값을 읽을 수 없다.
document.cookie
이를 통해 XSS 공격이 발생하더라도 악성 스크립트가 HttpOnly 쿠키의 값을 직접 읽어 외부로 전송하는 위험을 줄일 수 있다.
하지만 XSS 자체를 방지하는 것은 아니다. 악성 스크립트가 브라우저 안에서 실행되면 사용자를 대신해 요청을 전송하는 공격은 여전히 가능하다.
Secure
Secure는 HTTPS 연결에서만 쿠키가 전송되도록 제한한다.
Set-Cookie: refreshToken=<token>; Secure
HTTPS를 적용하지 않으면 네트워크에서 쿠키가 노출될 위험이 있으므로 인증 쿠키에는 일반적으로 Secure를 설정해야 한다.
운영 환경에서는 HTTPS를 사용하고, 개발 환경에서는 환경별 쿠키 설정을 분리하는 것이 좋다.
SameSite
SameSite는 다른 사이트에서 시작된 요청에 쿠키를 어느 범위까지 포함할지 제어한다.
대표적인 설정은 다음과 같다.
Strict
SameSite=Strict
교차 사이트 요청에 쿠키가 포함되는 것을 강하게 제한한다. 보안 측면에서 유리할 수 있지만 외부 사이트에서 서비스로 이동하는 일부 흐름에서 사용자 경험에 영향을 줄 수 있다.
Lax
SameSite=Lax
일반적인 웹 서비스에서 사용할 수 있는 절충안이다. 일부 최상위 탐색 요청에는 쿠키가 포함될 수 있으므로 모든 CSRF 공격을 막는다고 간주해서는 안 된다.
None
SameSite=None; Secure
교차 사이트 요청에서도 쿠키가 필요할 때 사용한다. None을 지정하는 쿠키에는 Secure가 필요하다. 브라우저의 제3자 쿠키 정책 등도 별도로 고려해야 한다.
프론트엔드와 백엔드가 서로 다른 사이트에 배포된 구조라면 쿠키 정책, CORS, 자격 증명 전송 설정을 함께 설계해야 한다.
Path와 Domain
Path는 쿠키를 전송할 요청 경로를 제한한다.
예를 들어 다음과 같이 설정하면 브라우저는 해당 경로와 일치하는 요청에 쿠키를 포함한다.
Path=/auth/refresh
다만 Path는 강력한 보안 경계가 아니다. 다른 경로의 스크립트로부터 쿠키 자체를 보호하는 수단으로 간주해서는 안 된다.
Domain을 생략하면 쿠키는 일반적으로 쿠키를 설정한 호스트에 한정된다. 가능한 경우 불필요하게 넓은 Domain 범위를 지정하지 않는 편이 좋다.
쿠키 이름에 __Host- 접두사를 사용하면 더 엄격한 제약을 적용할 수 있다. 예를 들어 __Host-refreshToken은 Secure를 사용하고, Path=/로 설정하며, Domain 속성을 지정하지 않아야 한다. 이 접두사는 하위 경로만으로 쿠키 전송을 제한하는 기능을 제공하지는 않는다.
Node.js와 TypeScript로 구현하는 JWT 인증
다음은 jsonwebtoken을 사용하는 간단한 Access Token 발급 및 검증 예시다.
Access Token 발급
import jwt from "jsonwebtoken";
const ACCESS_TOKEN_SECRET =
process.env.ACCESS_TOKEN_SECRET;
if (!ACCESS_TOKEN_SECRET) {
throw new Error("ACCESS_TOKEN_SECRET is required");
}
interface AccessTokenPayload {
sub: string;
role: "user" | "admin";
}
export function createAccessToken(
user: AccessTokenPayload
): string {
return jwt.sign(
{
role: user.role,
tokenType: "access",
},
ACCESS_TOKEN_SECRET,
{
subject: user.sub,
issuer: "api.example.com",
audience: "game-client",
expiresIn: "15m",
algorithm: "HS256",
}
);
}
이 코드는 사용자 식별자와 역할을 포함하는 Access Token을 발급한다.
운영 환경에서는 충분히 강력하고 예측 불가능한 비밀키를 사용해야 한다. 비밀키를 소스 코드에 하드코딩하지 말고, 환경 변수나 별도의 비밀 관리 시스템을 통해 관리해야 한다.
Access Token 검증
import jwt from "jsonwebtoken";
const ACCESS_TOKEN_SECRET =
process.env.ACCESS_TOKEN_SECRET!;
interface VerifiedAccessToken {
sub: string;
role: "user" | "admin";
tokenType: "access";
}
export function verifyAccessToken(
token: string
): VerifiedAccessToken {
const decoded = jwt.verify(
token,
ACCESS_TOKEN_SECRET,
{
algorithms: ["HS256"],
issuer: "api.example.com",
audience: "game-client",
}
);
if (
typeof decoded === "string" ||
typeof decoded.sub !== "string" ||
(decoded.role !== "user" &&
decoded.role !== "admin") ||
decoded.tokenType !== "access"
) {
throw new Error("Invalid access token payload");
}
return {
sub: decoded.sub,
role: decoded.role,
tokenType: "access",
};
}
jwt.verify()는 서명과 시간 관련 클레임 등을 검증한다. 위 코드에서는 알고리즘, 발급자, 대상도 명시적으로 제한한다.
다만 JWT 서명 검증이 성공했다고 해서 모든 권한 검사가 끝난 것은 아니다. 실제 API에서는 요청한 리소스에 대한 접근 권한, 사용자 정지 상태, 필요한 경우 세션 폐기 여부 등을 추가로 검증해야 한다.
또한 비밀키가 설정되지 않았을 때 애플리케이션이 시작 단계에서 실패하도록 처리하고, 운영 코드에서는 검증되지 않은 토큰의 Payload를 신뢰해서는 안 된다.
Refresh Token Rotation의 핵심 구현 원칙
Refresh Token Rotation은 단순히 새 토큰을 발급하는 것만으로 완성되지 않는다.
안전한 구현을 위해서는 다음 절차가 필요하다.
- Refresh Token을 제출받는다.
- 토큰의 서명, 만료, 발급 목적 및 세션 상태를 검증한다.
- 서버 저장소에서 토큰의 해시값과 폐기 상태를 확인한다.
- 토큰이 아직 유효하고 사용 가능한 상태인지 원자적으로 확인한다.
- 기존 Refresh Token을 폐기한다.
- 새로운 Refresh Token을 발급하고 저장한다.
- 새로운 Access Token과 Refresh Token을 클라이언트에 전달한다.
- 이미 폐기된 토큰이 재사용되었다면 설정된 탐지 정책을 실행한다.
이 과정에서 기존 토큰 폐기와 새 토큰 저장이 일관되게 처리되어야 한다. 데이터베이스 트랜잭션이나 조건부 업데이트를 사용해 중간 실패와 동시 요청 문제를 방지해야 한다.
새 토큰 발급과 저장에 실패했을 때 기존 토큰만 폐기되는 상황도 고려해야 한다. 트랜잭션으로 데이터베이스 변경을 묶거나, 실패 복구 정책을 설계해야 한다.
실무에서 고려해야 할 추가 보안 사항
Access Token과 Refresh Token의 용도 분리
Access Token은 API 접근용으로, Refresh Token은 갱신용으로만 사용해야 한다.
토큰 유형과 검증 규칙을 분리하면 Refresh Token을 일반 API에 제출하거나 다른 용도의 토큰을 잘못 사용하는 문제를 줄일 수 있다.
Refresh Token 해시 저장
서버 데이터베이스에는 Refresh Token 원문을 그대로 저장하기보다 해시값을 저장하는 방식을 고려한다.
데이터베이스가 유출되더라도 공격자가 저장된 값만으로 원래 토큰을 바로 사용하는 위험을 줄일 수 있다.
비밀키 관리와 교체
JWT 서명 키가 유출되면 공격자가 유효한 토큰을 위조할 수 있다.
따라서 비밀키를 안전하게 보관하고, 접근 권한을 최소화하며, 키 교체 절차를 마련해야 한다. 비대칭 키를 사용하는 경우에는 서명용 개인키와 검증용 공개키를 분리할 수 있다.
HTTPS 적용
토큰은 네트워크를 통해 전달되는 인증 정보다. HTTPS를 적용하지 않으면 전송 과정에서 토큰이 노출될 위험이 있다.
로그에서 토큰 제거
Access Token, Refresh Token, 쿠키 값, Authorization 헤더를 로그에 그대로 기록해서는 안 된다.
인증 문제를 추적해야 한다면 사용자 식별자, 요청 ID, 세션 식별자, 실패 사유 등 민감한 인증 정보가 아닌 데이터를 중심으로 기록하는 편이 좋다.
로그아웃과 계정 보안 이벤트
비밀번호 변경, 계정 정지, 의심스러운 로그인, 관리자 권한 변경 등의 상황에서는 관련 세션이나 토큰을 폐기하는 정책을 고려해야 한다.
단순한 로그아웃과 계정 전체 세션 종료는 구분해서 설계해야 한다.