티스토리 뷰

반응형

JWT는 stateless라는데 로그아웃은 어떻게 하지? 토큰 폐기, 강제 로그아웃, 블랙리스트 전략까지 한 번에 이해하기 — FastAPI · Spring Boot · Node.js

여기까지 오면 꼭 한 번 머리가 복잡해집니다.
로그인도 했고, access token도 만들었고, refresh token으로 재발급도 붙였잖아요.

그런데 바로 이런 질문이 튀어나와요.

“잠깐… JWT는 stateless라며?”
“그럼 로그아웃은 어떻게 해?”
“이미 발급된 access token은 못 막는 거 아냐?”
“강제 로그아웃은 또 어떻게 하지?”

저도 이 부분에서 처음 좀 헷갈렸어요.
JWT를 배우기 시작할 때는 “서버가 상태를 안 들고 있는 게 장점”처럼 느껴지는데,
막상 운영을 생각하면 오히려 그 지점이 불편하게 다가오더라고요.

  • 사용자가 로그아웃을 눌렀을 때
  • 비밀번호를 바꿨을 때
  • 관리자가 특정 계정을 강제로 끊어야 할 때
  • refresh token이 유출됐을 가능성이 있을 때

이런 상황에서는 “그냥 토큰 만료될 때까지 기다리면 된다”로는 부족합니다.

그래서 이번 글에서는 이 얘기를 제대로 해보겠습니다.

  • JWT에서 로그아웃이 왜 애매한지
  • refresh token 폐기만으로 충분한 경우와 아닌 경우
  • access token 차단은 어떤 방식으로 하는지
  • 블랙리스트, 토큰 버전(version), 강제 로그아웃 전략이 어떻게 다른지
  • FastAPI · Spring Boot · Node.js에서는 어디서부터 구현하면 좋은지

RFC 7009는 OAuth 2.0 token revocation을 정의하면서 access token과 refresh token을 무효화하는 메커니즘을 설명합니다. (RFC Editor)
RFC 9700은 OAuth 2.0 보안 모범 사례로 refresh token rotation을 권장하고, refresh token 재사용이 탐지되면 위험 신호로 봐야 한다고 설명합니다. (RFC Editor)
OWASP Session Management Cheat Sheet는 사용자가 언제든 명확하게 로그아웃할 수 있어야 하고, 서버 측 세션 또는 식별자를 무효화해야 한다고 설명합니다. (OWASP Cheat Sheet Series)


먼저 결론부터 아주 짧게 말하면

JWT 구조에서 로그아웃은 보통 세 가지 층위로 봅니다.

1. 가장 기본

refresh token 폐기

2. 조금 더 강한 대응

refresh token rotation + 재사용 탐지

3. 즉시 차단까지 필요할 때

access token 블랙리스트 또는 토큰 버전 전략

이걸 왜 나누냐면,
모든 서비스가 무조건 access token 블랙리스트까지 필요한 건 아니기 때문입니다.


왜 JWT 로그아웃이 세션보다 까다로울까

세션은 서버가 상태를 들고 있으니까 비교적 단순합니다.

  • 세션 저장소에서 삭제
  • 세션 무효화

Spring Security 문서도 기본적으로 인증 정보가 HTTP 세션에 저장된다고 설명하고, 저장 위치를 세션 외 캐시/DB로 바꿀 수도 있다고 안내합니다. (Home)

그런데 JWT access token은 다릅니다.

이미 발급된 토큰은 클라이언트가 들고 있고,
서버는 보통 그 토큰을 매 요청마다 서명과 만료만 검증합니다.
즉 “서버가 따로 기억하고 있지 않아도 검증 가능한 토큰”인 거죠.

이 말은 곧,
발급된 access token은 만료 전까지 기본적으로 살아 있다는 뜻이기도 합니다.

그래서 JWT 구조에서 로그아웃은 보통 이렇게 생각해야 합니다.

  • 앞으로 새 access token을 못 받게 막기
  • 필요하면 이미 발급된 access token도 차단하기

이 두 문제를 따로 봐야 해요.


로그아웃을 너무 단순하게 보면 생기는 오해

많이들 이렇게 생각합니다.

“로그아웃 버튼 눌렀으니 클라이언트에서 토큰 지우면 끝 아닌가?”

반은 맞고 반은 틀립니다.

클라이언트 자기 기기에서만 보면 맞아요.
로컬 스토리지든 메모리든 지워버리면 그 브라우저/앱에서는 더 못 씁니다.

그런데 만약 토큰이 이미 유출됐다면요?
혹은 다른 디바이스에 남아 있다면요?
혹은 공격자가 이미 access token이나 refresh token을 복사했다면요?

그때는 “클라이언트에서만 삭제”는 충분하지 않습니다.

RFC 7009가 token revocation endpoint를 정의하는 이유도 바로 이거예요.
클라이언트 삭제만으로는 서버 입장에서 토큰이 끝났다고 볼 수 없으니까요. (RFC Editor)


로그아웃을 설계할 때 먼저 나눠야 하는 두 가지

Access Token 로그아웃

이미 발급된 access token을 어떻게 취급할 것인가

Refresh Token 로그아웃

앞으로 access token을 새로 받는 길을 어떻게 끊을 것인가

이걸 섞으면 설계가 꼬입니다.

저는 보통 이렇게 정리해요.

  • 일반 로그아웃: refresh token 폐기
  • 보안 사고 대응 / 강제 로그아웃: refresh token 폐기 + access token 차단 전략 검토

이번 글에서 맞출 공통 예제

이번에는 세 스택 모두 아래 흐름으로 맞추겠습니다.

  • 로그인 시 access token + refresh token 발급
  • refresh token은 서버 저장
  • /logout 호출 시 refresh token 폐기
  • /logout-all 같은 강제 로그아웃용으로 token version 증가
  • 보호 API는 access token의 version 또는 blacklist 여부를 검사

즉 오늘 글의 핵심은 이겁니다.

JWT 로그아웃은 “클라이언트에서 지우기”가 아니라, 서버가 어떤 자격 증명을 언제부터 무효로 볼 것인지 정하는 문제다.


1) FastAPI에서 로그아웃과 강제 로그아웃 구조 잡기

반응형

FastAPI는 access token JWT 흐름과 OAuth2PasswordBearer 기반 보호 API 구조를 공식적으로 잘 보여줍니다. 이 위에 로그아웃 전략을 올리는 방식이 자연스럽습니다. (FastAPI)

여기서는 두 가지를 같이 넣겠습니다.

  • /logout: 현재 refresh token 폐기
  • /logout-all: 사용자 token version 증가로 기존 access token 전체 무효화

추천 구조

fastapi-backend/
├── app/
│   ├── api/
│   │   └── auth.py
│   ├── repositories/
│   │   ├── refresh_token_repository.py
│   │   └── user_repository.py
│   ├── schemas/
│   │   └── auth.py
│   ├── security/
│   │   ├── jwt.py
│   │   └── password.py
│   ├── services/
│   │   └── auth_service.py
│   ├── dependencies.py
│   └── main.py
└── requirements.txt

requirements.txt

fastapi
uvicorn[standard]
pydantic
email-validator
pwdlib[argon2]
pyjwt
python-multipart

app/security/jwt.py

from datetime import datetime, timedelta, timezone
import secrets
import jwt

SECRET_KEY = "change-this-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15


def create_access_token(subject: str, token_version: int) -> str:
    now = datetime.now(timezone.utc)
    expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    payload = {
        "sub": subject,
        "ver": token_version,
        "iat": int(now.timestamp()),
        "exp": int(expire.timestamp()),
    }

    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def decode_access_token(token: str) -> dict:
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])


def create_refresh_token() -> str:
    return secrets.token_urlsafe(48)

app/security/password.py

from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()


def hash_password(raw_password: str) -> str:
    return password_hash.hash(raw_password)


def verify_password(raw_password: str, hashed_password: str) -> bool:
    return password_hash.verify(raw_password, hashed_password)

app/schemas/auth.py

from pydantic import BaseModel, EmailStr, Field


class SignupRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=100)
    nickname: str = Field(min_length=2, max_length=20)


class LoginRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=100)


class RefreshRequest(BaseModel):
    refresh_token: str


class LogoutRequest(BaseModel):
    refresh_token: str


class UserRecord(BaseModel):
    id: int
    email: EmailStr
    password_hash: str
    nickname: str
    token_version: int = 1


class TokenPairResponse(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"


class UserResponse(BaseModel):
    id: int
    email: EmailStr
    nickname: str

app/repositories/user_repository.py

from typing import Optional
from app.schemas.auth import UserRecord


class UserRepository:
    def __init__(self) -> None:
        self._users: list[UserRecord] = []
        self._next_id = 1

    def find_by_email(self, email: str) -> Optional[UserRecord]:
        return next((u for u in self._users if str(u.email) == email), None)

    def save(self, email: str, password_hash: str, nickname: str) -> UserRecord:
        user = UserRecord(
            id=self._next_id,
            email=email,
            password_hash=password_hash,
            nickname=nickname,
            token_version=1,
        )
        self._users.append(user)
        self._next_id += 1
        return user

    def increase_token_version(self, email: str) -> None:
        user = self.find_by_email(email)
        if user is None:
            return
        user.token_version += 1

app/repositories/refresh_token_repository.py

from datetime import datetime, timedelta, timezone


class RefreshTokenRepository:
    def __init__(self) -> None:
        self._tokens: dict[str, dict] = {}

    def save(self, token: str, email: str, days: int = 14) -> None:
        self._tokens[token] = {
            "email": email,
            "expires_at": datetime.now(timezone.utc) + timedelta(days=days),
        }

    def find(self, token: str) -> dict | None:
        data = self._tokens.get(token)
        if not data:
            return None
        if data["expires_at"] < datetime.now(timezone.utc):
            self.delete(token)
            return None
        return data

    def delete(self, token: str) -> None:
        self._tokens.pop(token, None)

    def delete_all_by_email(self, email: str) -> None:
        targets = [token for token, data in self._tokens.items() if data["email"] == email]
        for token in targets:
            self.delete(token)

app/services/auth_service.py

from app.repositories.refresh_token_repository import RefreshTokenRepository
from app.repositories.user_repository import UserRepository
from app.schemas.auth import (
    LoginRequest,
    LogoutRequest,
    RefreshRequest,
    SignupRequest,
    TokenPairResponse,
    UserResponse,
)
from app.security.jwt import create_access_token, create_refresh_token
from app.security.password import hash_password, verify_password


class AuthService:
    def __init__(
        self,
        user_repository: UserRepository,
        refresh_token_repository: RefreshTokenRepository,
    ) -> None:
        self.user_repository = user_repository
        self.refresh_token_repository = refresh_token_repository

    def signup(self, request: SignupRequest) -> None:
        if self.user_repository.find_by_email(str(request.email)) is not None:
            raise ValueError("이미 가입된 이메일입니다.")

        self.user_repository.save(
            email=str(request.email),
            password_hash=hash_password(request.password),
            nickname=request.nickname,
        )

    def login(self, request: LoginRequest) -> TokenPairResponse:
        user = self.user_repository.find_by_email(str(request.email))
        if user is None or not verify_password(request.password, user.password_hash):
            raise ValueError("이메일 또는 비밀번호가 올바르지 않습니다.")

        access_token = create_access_token(str(user.email), user.token_version)
        refresh_token = create_refresh_token()

        self.refresh_token_repository.save(refresh_token, str(user.email))

        return TokenPairResponse(
            access_token=access_token,
            refresh_token=refresh_token,
        )

    def refresh(self, request: RefreshRequest) -> TokenPairResponse:
        stored = self.refresh_token_repository.find(request.refresh_token)
        if stored is None:
            raise ValueError("유효하지 않은 refresh token입니다.")

        email = stored["email"]
        user = self.user_repository.find_by_email(email)
        if user is None:
            raise ValueError("사용자를 찾을 수 없습니다.")

        self.refresh_token_repository.delete(request.refresh_token)

        new_access_token = create_access_token(email, user.token_version)
        new_refresh_token = create_refresh_token()
        self.refresh_token_repository.save(new_refresh_token, email)

        return TokenPairResponse(
            access_token=new_access_token,
            refresh_token=new_refresh_token,
        )

    def logout(self, request: LogoutRequest) -> None:
        self.refresh_token_repository.delete(request.refresh_token)

    def logout_all(self, email: str) -> None:
        self.refresh_token_repository.delete_all_by_email(email)
        self.user_repository.increase_token_version(email)

    def get_user_by_email(self, email: str) -> UserResponse:
        user = self.user_repository.find_by_email(email)
        if user is None:
            raise ValueError("사용자를 찾을 수 없습니다.")

        return UserResponse(
            id=user.id,
            email=user.email,
            nickname=user.nickname,
        )

app/dependencies.py

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

from app.repositories.refresh_token_repository import RefreshTokenRepository
from app.repositories.user_repository import UserRepository
from app.schemas.auth import UserResponse
from app.security.jwt import decode_access_token
from app.services.auth_service import AuthService

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")

user_repository = UserRepository()
refresh_token_repository = RefreshTokenRepository()


def get_auth_service() -> AuthService:
    return AuthService(user_repository, refresh_token_repository)


def get_current_user(
    token: str = Depends(oauth2_scheme),
    auth_service: AuthService = Depends(get_auth_service),
) -> UserResponse:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="유효하지 않은 인증 토큰입니다.",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = decode_access_token(token)
        email = payload.get("sub")
        token_version = payload.get("ver")
        if not email or token_version is None:
            raise credentials_exception
    except Exception:
        raise credentials_exception

    user = user_repository.find_by_email(email)
    if user is None or user.token_version != token_version:
        raise credentials_exception

    return auth_service.get_user_by_email(email)

app/api/auth.py

from fastapi import APIRouter, Depends, HTTPException, status

from app.dependencies import get_auth_service, get_current_user
from app.schemas.auth import (
    LoginRequest,
    LogoutRequest,
    RefreshRequest,
    SignupRequest,
    TokenPairResponse,
    UserResponse,
)
from app.services.auth_service import AuthService

router = APIRouter(prefix="/api/auth", tags=["auth"])


@router.post("/signup", status_code=status.HTTP_201_CREATED)
def signup(request: SignupRequest, auth_service: AuthService = Depends(get_auth_service)):
    try:
        auth_service.signup(request)
        return {"message": "회원가입 성공"}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@router.post("/login", response_model=TokenPairResponse)
def login(request: LoginRequest, auth_service: AuthService = Depends(get_auth_service)):
    try:
        return auth_service.login(request)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@router.post("/refresh", response_model=TokenPairResponse)
def refresh(request: RefreshRequest, auth_service: AuthService = Depends(get_auth_service)):
    try:
        return auth_service.refresh(request)
    except ValueError as e:
        raise HTTPException(status_code=401, detail=str(e))


@router.post("/logout")
def logout(request: LogoutRequest, auth_service: AuthService = Depends(get_auth_service)):
    auth_service.logout(request)
    return {"message": "로그아웃 성공"}


@router.post("/logout-all")
def logout_all(
    current_user: UserResponse = Depends(get_current_user),
    auth_service: AuthService = Depends(get_auth_service),
):
    auth_service.logout_all(current_user.email)
    return {"message": "모든 기기에서 로그아웃 성공"}

FastAPI에서 핵심 감각

여기서 중요한 건 두 단계예요.

  • /logout: refresh token 하나만 지움
  • /logout-all: refresh token 전부 지우고 token_version 증가

이렇게 하면 refresh token 재발급 길을 끊을 수 있고,
token_version을 access token payload에 넣어두면 기존 access token도 한 번에 무효화할 수 있습니다.

FastAPI 공식 문서는 OAuth2PasswordBearer와 현재 사용자 의존성 패턴을 잘 제공하므로, 그 위에 이런 버전 검사를 추가하는 식으로 확장하기 좋습니다. (FastAPI)


2) Spring Boot에서 토큰 버전 기반 강제 로그아웃 붙이기

Spring Security는 세션 기반이면 서버에서 세션 무효화로 상대적으로 단순하게 처리할 수 있지만, JWT resource server 쪽은 access token 자체가 stateless하므로 별도 전략이 필요합니다. Spring Security는 JWT와 opaque token 둘 다 보호 엔드포인트에 사용할 수 있다고 설명합니다. opaque token은 introspection을 통해 서버 상태 확인이 가능하다는 점에서 revocation 제어가 더 쉽습니다. (Home)

여기서는 시리즈 흐름상 JWT를 유지하면서,
refresh token 폐기 + token version 전략으로 갑니다.

추천 구조

springboot-backend/
├── src/main/java/com/example/backend/
│   ├── BackendApplication.java
│   ├── config/
│   │   ├── JwtAuthFilter.java
│   │   └── SecurityConfig.java
│   ├── controller/
│   │   └── AuthController.java
│   ├── dto/
│   │   ├── LoginRequest.java
│   │   ├── LogoutRequest.java
│   │   ├── RefreshRequest.java
│   │   ├── SignupRequest.java
│   │   ├── TokenPairResponse.java
│   │   └── UserRecord.java
│   ├── repository/
│   │   ├── RefreshTokenRepository.java
│   │   └── UserRepository.java
│   ├── security/
│   │   └── JwtTokenProvider.java
│   └── service/
│       └── AuthService.java
└── build.gradle

dto/UserRecord.java

package com.example.backend.dto;

public record UserRecord(
        Long id,
        String email,
        String passwordHash,
        String nickname,
        int tokenVersion
) {
}

dto/LogoutRequest.java

package com.example.backend.dto;

import jakarta.validation.constraints.NotBlank;

public record LogoutRequest(
        @NotBlank String refreshToken
) {
}

dto/RefreshRequest.java

package com.example.backend.dto;

import jakarta.validation.constraints.NotBlank;

public record RefreshRequest(
        @NotBlank String refreshToken
) {
}

dto/TokenPairResponse.java

package com.example.backend.dto;

public record TokenPairResponse(
        String accessToken,
        String refreshToken,
        String tokenType
) {
}

repository/UserRepository.java

package com.example.backend.repository;

import com.example.backend.dto.UserRecord;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Repository
public class UserRepository {

    private final List<UserRecord> users = new ArrayList<>();
    private long nextId = 1L;

    public Optional<UserRecord> findByEmail(String email) {
        return users.stream()
                .filter(user -> user.email().equals(email))
                .findFirst();
    }

    public UserRecord save(String email, String passwordHash, String nickname) {
        UserRecord user = new UserRecord(nextId++, email, passwordHash, nickname, 1);
        users.add(user);
        return user;
    }

    public void increaseTokenVersion(String email) {
        for (int i = 0; i < users.size(); i++) {
            UserRecord user = users.get(i);
            if (user.email().equals(email)) {
                users.set(i, new UserRecord(
                        user.id(),
                        user.email(),
                        user.passwordHash(),
                        user.nickname(),
                        user.tokenVersion() + 1
                ));
                return;
            }
        }
    }
}

repository/RefreshTokenRepository.java

package com.example.backend.repository;

import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;

@Repository
public class RefreshTokenRepository {

    private final Map<String, Map<String, Object>> store = new HashMap<>();

    public void save(String token, String email) {
        store.put(token, Map.of(
                "email", email,
                "expiresAt", Instant.now().plus(14, ChronoUnit.DAYS)
        ));
    }

    public Map<String, Object> find(String token) {
        Map<String, Object> data = store.get(token);
        if (data == null) {
            return null;
        }

        Instant expiresAt = (Instant) data.get("expiresAt");
        if (expiresAt.isBefore(Instant.now())) {
            delete(token);
            return null;
        }

        return data;
    }

    public void delete(String token) {
        store.remove(token);
    }

    public void deleteAllByEmail(String email) {
        store.entrySet().removeIf(entry -> email.equals(entry.getValue().get("email")));
    }
}

security/JwtTokenProvider.java

package com.example.backend.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtTokenProvider {

    private static final String SECRET = "change-this-secret-key-change-this-secret-key";
    private static final long ACCESS_TOKEN_EXPIRE_MS = 1000L * 60 * 15;

    private final SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));

    public String createAccessToken(String subject, int tokenVersion) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_MS);

        return Jwts.builder()
                .subject(subject)
                .claim("ver", tokenVersion)
                .issuedAt(now)
                .expiration(expiry)
                .signWith(key)
                .compact();
    }

    public Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

service/AuthService.java

package com.example.backend.service;

import com.example.backend.dto.*;
import com.example.backend.repository.RefreshTokenRepository;
import com.example.backend.repository.UserRepository;
import com.example.backend.security.JwtTokenProvider;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.UUID;

@Service
public class AuthService {

    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(
            UserRepository userRepository,
            RefreshTokenRepository refreshTokenRepository,
            PasswordEncoder passwordEncoder,
            JwtTokenProvider jwtTokenProvider
    ) {
        this.userRepository = userRepository;
        this.refreshTokenRepository = refreshTokenRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public void signup(SignupRequest request) {
        userRepository.findByEmail(request.email()).ifPresent(user -> {
            throw new IllegalArgumentException("이미 가입된 이메일입니다.");
        });

        userRepository.save(
                request.email(),
                passwordEncoder.encode(request.password()),
                request.nickname()
        );
    }

    public TokenPairResponse login(LoginRequest request) {
        UserRecord user = userRepository.findByEmail(request.email())
                .orElseThrow(() -> new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."));

        if (!passwordEncoder.matches(request.password(), user.passwordHash())) {
            throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.");
        }

        String accessToken = jwtTokenProvider.createAccessToken(user.email(), user.tokenVersion());
        String refreshToken = UUID.randomUUID().toString() + UUID.randomUUID();

        refreshTokenRepository.save(refreshToken, user.email());

        return new TokenPairResponse(accessToken, refreshToken, "Bearer");
    }

    public TokenPairResponse refresh(RefreshRequest request) {
        Map<String, Object> stored = refreshTokenRepository.find(request.refreshToken());
        if (stored == null) {
            throw new IllegalArgumentException("유효하지 않은 refresh token입니다.");
        }

        String email = (String) stored.get("email");
        UserRecord user = userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        refreshTokenRepository.delete(request.refreshToken());

        String newAccessToken = jwtTokenProvider.createAccessToken(user.email(), user.tokenVersion());
        String newRefreshToken = UUID.randomUUID().toString() + UUID.randomUUID();

        refreshTokenRepository.save(newRefreshToken, user.email());

        return new TokenPairResponse(newAccessToken, newRefreshToken, "Bearer");
    }

    public void logout(LogoutRequest request) {
        refreshTokenRepository.delete(request.refreshToken());
    }

    public void logoutAll(String email) {
        refreshTokenRepository.deleteAllByEmail(email);
        userRepository.increaseTokenVersion(email);
    }

    public UserRecord getUserByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
    }
}

config/JwtAuthFilter.java

package com.example.backend.config;

import com.example.backend.dto.UserRecord;
import com.example.backend.security.JwtTokenProvider;
import com.example.backend.service.AuthService;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final AuthService authService;

    public JwtAuthFilter(JwtTokenProvider jwtTokenProvider, AuthService authService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.authService = authService;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            try {
                String token = authHeader.substring(7);
                Claims claims = jwtTokenProvider.parseClaims(token);

                String email = claims.getSubject();
                Integer tokenVersion = claims.get("ver", Integer.class);

                UserRecord user = authService.getUserByEmail(email);
                if (user.tokenVersion() != tokenVersion) {
                    filterChain.doFilter(request, response);
                    return;
                }

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                email,
                                null,
                                Collections.emptyList()
                        );

                authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );

                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (Exception ignored) {
            }
        }

        filterChain.doFilter(request, response);
    }
}

controller/AuthController.java

package com.example.backend.controller;

import com.example.backend.dto.*;
import com.example.backend.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/signup")
    @ResponseStatus(HttpStatus.CREATED)
    public Object signup(@Valid @RequestBody SignupRequest request) {
        authService.signup(request);
        return java.util.Map.of("message", "회원가입 성공");
    }

    @PostMapping("/login")
    public TokenPairResponse login(@Valid @RequestBody LoginRequest request) {
        return authService.login(request);
    }

    @PostMapping("/refresh")
    public TokenPairResponse refresh(@Valid @RequestBody RefreshRequest request) {
        return authService.refresh(request);
    }

    @PostMapping("/logout")
    public Object logout(@Valid @RequestBody LogoutRequest request) {
        authService.logout(request);
        return java.util.Map.of("message", "로그아웃 성공");
    }

    @PostMapping("/logout-all")
    public Object logoutAll(Authentication authentication) {
        authService.logoutAll((String) authentication.getPrincipal());
        return java.util.Map.of("message", "모든 기기에서 로그아웃 성공");
    }
}

Spring Boot에서 핵심 감각

Spring 쪽은 OncePerRequestFilter에서 access token을 검증할 때
사용자의 현재 tokenVersion과 토큰 안의 ver를 비교하게 만들면 강제 로그아웃이 꽤 깔끔하게 됩니다.

Spring Security가 JWT와 opaque token 둘 다 지원하는 이유를 생각하면,
즉시 폐기 제어가 중요할수록 서버 상태 확인이 가능한 구조가 더 편해질 수 있다는 감각도 같이 가져가면 좋아요. (Home)


3) Node.js에서 블랙리스트 대신 token version으로 가볍게 가기

Node.js는 프레임워크가 이 부분을 정해주지 않아서 선택지가 더 많습니다.

  • refresh token 폐기만 할지
  • access token blacklist를 둘지
  • 사용자 tokenVersion을 둘지
  • user-level logout-all을 둘지

초반엔 저는 token version 전략이 꽤 현실적이라고 봐요.
access token blacklist는 토큰 하나하나를 저장해야 해서 운영 부담이 빨리 생기거든요.

추천 구조

node-backend/
├── src/
│   ├── repositories/
│   │   ├── refresh-token.repository.js
│   │   └── user.repository.js
│   ├── routes/
│   │   └── auth.route.js
│   ├── security/
│   │   ├── jwt.js
│   │   └── password.js
│   ├── services/
│   │   └── auth.service.js
│   └── server.js
└── package.json

package.json

{
  "name": "backend-series-node-logout-revocation",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node src/server.js"
  },
  "dependencies": {
    "bcrypt": "^5.1.1",
    "express": "^5.1.0",
    "express-jwt": "^8.5.1",
    "jsonwebtoken": "^9.0.2"
  }
}

src/security/jwt.js

import crypto from "node:crypto";
import jwt from "jsonwebtoken";

const ACCESS_SECRET_KEY = "change-this-access-secret-key";
const ACCESS_EXPIRES_IN = "15m";

export function createAccessToken(subject, tokenVersion) {
  return jwt.sign({ sub: subject, ver: tokenVersion }, ACCESS_SECRET_KEY, {
    expiresIn: ACCESS_EXPIRES_IN,
  });
}

export function createRefreshToken() {
  return crypto.randomUUID() + crypto.randomUUID();
}

export const jwtSecret = ACCESS_SECRET_KEY;

src/security/password.js

import bcrypt from "bcrypt";

const SALT_ROUNDS = 10;

export async function hashPassword(rawPassword) {
  return bcrypt.hash(rawPassword, SALT_ROUNDS);
}

export async function verifyPassword(rawPassword, hashedPassword) {
  return bcrypt.compare(rawPassword, hashedPassword);
}

src/repositories/user.repository.js

export class UserRepository {
  constructor() {
    this.users = [];
    this.nextId = 1;
  }

  findByEmail(email) {
    return this.users.find((user) => user.email === email) ?? null;
  }

  save(email, passwordHash, nickname) {
    const user = {
      id: this.nextId++,
      email,
      passwordHash,
      nickname,
      tokenVersion: 1,
    };

    this.users.push(user);
    return user;
  }

  increaseTokenVersion(email) {
    const user = this.findByEmail(email);
    if (!user) return;
    user.tokenVersion += 1;
  }
}

src/repositories/refresh-token.repository.js

export class RefreshTokenRepository {
  constructor() {
    this.tokens = new Map();
  }

  save(token, email) {
    const expiresAt = Date.now() + 1000 * 60 * 60 * 24 * 14;
    this.tokens.set(token, { email, expiresAt });
  }

  find(token) {
    const data = this.tokens.get(token);
    if (!data) return null;

    if (data.expiresAt < Date.now()) {
      this.delete(token);
      return null;
    }

    return data;
  }

  delete(token) {
    this.tokens.delete(token);
  }

  deleteAllByEmail(email) {
    for (const [token, data] of this.tokens.entries()) {
      if (data.email === email) {
        this.tokens.delete(token);
      }
    }
  }
}

src/services/auth.service.js

import { createAccessToken, createRefreshToken } from "../security/jwt.js";
import { hashPassword, verifyPassword } from "../security/password.js";

export class AuthService {
  constructor(userRepository, refreshTokenRepository) {
    this.userRepository = userRepository;
    this.refreshTokenRepository = refreshTokenRepository;
  }

  async signup({ email, password, nickname }) {
    const existingUser = this.userRepository.findByEmail(email);
    if (existingUser) {
      throw new Error("이미 가입된 이메일입니다.");
    }

    const passwordHash = await hashPassword(password);
    this.userRepository.save(email, passwordHash, nickname);
  }

  async login({ email, password }) {
    const user = this.userRepository.findByEmail(email);
    if (!user) {
      throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
    }

    const matched = await verifyPassword(password, user.passwordHash);
    if (!matched) {
      throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
    }

    const accessToken = createAccessToken(user.email, user.tokenVersion);
    const refreshToken = createRefreshToken();

    this.refreshTokenRepository.save(refreshToken, user.email);

    return {
      accessToken,
      refreshToken,
      tokenType: "Bearer",
    };
  }

  async refresh({ refreshToken }) {
    const stored = this.refreshTokenRepository.find(refreshToken);
    if (!stored) {
      throw new Error("유효하지 않은 refresh token입니다.");
    }

    const email = stored.email;
    const user = this.userRepository.findByEmail(email);
    if (!user) {
      throw new Error("사용자를 찾을 수 없습니다.");
    }

    this.refreshTokenRepository.delete(refreshToken);

    const newAccessToken = createAccessToken(user.email, user.tokenVersion);
    const newRefreshToken = createRefreshToken();
    this.refreshTokenRepository.save(newRefreshToken, user.email);

    return {
      accessToken: newAccessToken,
      refreshToken: newRefreshToken,
      tokenType: "Bearer",
    };
  }

  logout({ refreshToken }) {
    this.refreshTokenRepository.delete(refreshToken);
  }

  logoutAll({ email }) {
    this.refreshTokenRepository.deleteAllByEmail(email);
    this.userRepository.increaseTokenVersion(email);
  }

  getUserByEmail(email) {
    const user = this.userRepository.findByEmail(email);
    if (!user) {
      throw new Error("사용자를 찾을 수 없습니다.");
    }

    return {
      id: user.id,
      email: user.email,
      nickname: user.nickname,
      tokenVersion: user.tokenVersion,
    };
  }
}

src/routes/auth.route.js

import { Router } from "express";
import { expressjwt } from "express-jwt";
import { jwtSecret } from "../security/jwt.js";

export function createAuthRouter(authService) {
  const router = Router();

  const requireAuth = expressjwt({
    secret: jwtSecret,
    algorithms: ["HS256"],
  });

  router.post("/api/auth/signup", async (req, res) => {
    const { email, password, nickname } = req.body;

    if (!email || !password || !nickname) {
      return res.status(400).json({ message: "필수값이 누락되었습니다." });
    }

    try {
      await authService.signup({ email, password, nickname });
      return res.status(201).json({ message: "회원가입 성공" });
    } catch (error) {
      return res.status(400).json({ message: error.message });
    }
  });

  router.post("/api/auth/login", async (req, res) => {
    const { email, password } = req.body;

    if (!email || !password) {
      return res.status(400).json({ message: "필수값이 누락되었습니다." });
    }

    try {
      const result = await authService.login({ email, password });
      return res.json(result);
    } catch (error) {
      return res.status(400).json({ message: error.message });
    }
  });

  router.post("/api/auth/refresh", async (req, res) => {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(400).json({ message: "refresh token이 필요합니다." });
    }

    try {
      const result = await authService.refresh({ refreshToken });
      return res.json(result);
    } catch (error) {
      return res.status(401).json({ message: error.message });
    }
  });

  router.post("/api/auth/logout", (req, res) => {
    const { refreshToken } = req.body;

    authService.logout({ refreshToken });
    return res.json({ message: "로그아웃 성공" });
  });

  router.post("/api/auth/logout-all", requireAuth, (req, res) => {
    try {
      const email = req.auth.sub;
      const user = authService.getUserByEmail(email);

      if (user.tokenVersion !== req.auth.ver) {
        return res.status(401).json({ message: "유효하지 않은 인증 토큰입니다." });
      }

      authService.logoutAll({ email });
      return res.json({ message: "모든 기기에서 로그아웃 성공" });
    } catch (error) {
      return res.status(401).json({ message: error.message });
    }
  });

  router.get("/api/auth/me", requireAuth, (req, res) => {
    try {
      const email = req.auth.sub;
      const user = authService.getUserByEmail(email);

      if (user.tokenVersion !== req.auth.ver) {
        return res.status(401).json({ message: "유효하지 않은 인증 토큰입니다." });
      }

      return res.json(user);
    } catch (error) {
      return res.status(401).json({ message: error.message });
    }
  });

  return router;
}

src/server.js

import express from "express";
import { RefreshTokenRepository } from "./repositories/refresh-token.repository.js";
import { UserRepository } from "./repositories/user.repository.js";
import { createAuthRouter } from "./routes/auth.route.js";
import { AuthService } from "./services/auth.service.js";

const app = express();
const PORT = 3000;

const userRepository = new UserRepository();
const refreshTokenRepository = new RefreshTokenRepository();
const authService = new AuthService(userRepository, refreshTokenRepository);

app.use(express.json());
app.use(createAuthRouter(authService));

app.listen(PORT, () => {
  console.log(`server running on http://localhost:${PORT}`);
});

Node.js에서 핵심 감각

Node.js에서는 express-jwt가 토큰 서명과 만료 검증을 해주고,
그 위에 tokenVersion 비교를 한 번 더 올리면 강제 로그아웃까지 꽤 현실적으로 처리할 수 있습니다.

express-jwt가 디코딩된 payload를 req.auth에 넣어준다고 문서가 설명하는데,
이게 이런 추가 검사를 붙이기에 딱 좋습니다. (Home)


블랙리스트와 token version은 어떻게 다를까

이 부분은 꽤 중요합니다.

블랙리스트

특정 access token 자체를 서버에 기록하고 차단하는 방식

장점:

  • 특정 토큰 하나만 정확히 막기 좋음
  • 즉시 폐기 개념이 직관적

단점:

  • access token마다 저장 부담
  • 짧은 access token 구조인데도 상태 저장이 커질 수 있음

Token Version

사용자 또는 디바이스 단위로 “현재 유효한 토큰 세대”를 관리

장점:

  • 구현이 단순함
  • 모든 기기 로그아웃, 비밀번호 변경 후 전체 만료 같은 시나리오에 좋음

단점:

  • 특정 access token 하나만 개별 폐기하는 건 덜 정교함

즉,

  • 특정 토큰 하나를 끊고 싶다 → 블랙리스트
  • 사용자 전체 세션을 끊고 싶다 → token version

실무에서는 token version부터 시작하고, 정말 필요할 때 블랙리스트를 추가하는 경우가 꽤 많습니다. RFC 7009의 revocation 개념은 “토큰을 서버가 무효로 볼 수 있어야 한다”는 점을 강조하고 있고, 그 구현 방식은 서비스 아키텍처에 따라 달라질 수 있습니다. (RFC Editor)


refresh token 폐기만으로 충분한 경우

이건 생각보다 많습니다.

예를 들면,

  • access token 수명이 10분~15분 정도로 짧고
  • 사용자가 일반적으로 웹/앱 한두 기기 정도만 쓰고
  • 보안 민감도가 아주 높은 금융/관리자 시스템은 아니고
  • “즉시 access token 차단”이 절대 필수는 아닌 서비스

이런 경우에는

  • /logout 시 refresh token 폐기
  • access token은 곧 만료되게 두기

이 정도로도 꽤 현실적입니다.

즉 “새 토큰 재발급 길만 끊고, 이미 발급된 access token은 짧은 만료로 흡수한다” 전략이죠.


access token까지 즉시 막아야 하는 경우

이런 경우에는 더 강한 전략이 필요합니다.

  • 관리자 계정
  • 결제/정산/개인정보 민감 영역
  • 계정 탈취 의심
  • 비밀번호 변경 직후 전체 차단 필요
  • 다수 디바이스 강제 로그아웃 요구

이때는 token version이나 blacklist가 들어가야 해요.

그리고 만약 토큰 폐기/상태 확인을 더 강하게 서버가 통제해야 한다면, Spring Security가 지원하는 opaque token + introspection 같은 모델도 검토 가치가 있습니다. Spring Security는 resource server가 JWT뿐 아니라 opaque token도 지원한다고 설명합니다. (Home)


주니어 때 자주 하는 실수

첫 번째는 로그아웃을 클라이언트 삭제로만 끝내는 것입니다.
그건 자기 기기 UI 기준 로그아웃이지, 서버 기준 자격 증명 폐기는 아닐 수 있습니다. OWASP는 서버 측 무효화가 중요하다고 설명합니다. (OWASP Cheat Sheet Series)

두 번째는 refresh token은 폐기하면서 access token 통제는 전혀 고려하지 않는 것입니다.
모든 서비스가 즉시 차단이 필요한 건 아니지만, 최소한 “우리 서비스는 access token 만료까지 허용할 건지”를 의식적으로 결정해야 합니다.

세 번째는 블랙리스트를 너무 빨리 넣는 것입니다.
구현 난이도와 저장 비용이 늘어나니까, 작은 서비스 초반에는 token version이 더 단순하고 좋은 경우가 많습니다.

네 번째는 refresh token rotation 없이 장기 refresh token을 계속 재사용하는 것입니다.
RFC 9700이 rotation을 Best Current Practice로 설명하는 이유가 분명합니다. (RFC Editor)


제가 추천하는 아주 현실적인 시작점

작은 서비스 초반이라면 저는 보통 이렇게 추천합니다.

  • access token: 15분 전후
  • refresh token: 7일~14일
  • /logout: refresh token 폐기
  • /logout-all: refresh token 전부 폐기 + token version 증가
  • 보호 API: access token의 ver와 현재 user tokenVersion 비교

이 정도면
“그냥 JWT만 쓰는 상태”보다는 훨씬 운영 친화적이고,
그렇다고 너무 무겁지도 않습니다.


이번 글 핵심 정리

이번 글의 핵심은 이겁니다.

JWT 로그아웃은 “토큰을 지운다”가 아니라, 서버가 어떤 자격 증명을 언제부터 더 이상 신뢰하지 않을지 정하는 문제다.

정리하면 이렇게 볼 수 있어요.

  • 기본 로그아웃은 refresh token 폐기
  • refresh token rotation은 강하게 추천
  • access token 즉시 차단이 필요하면 token version 또는 blacklist 고려
  • 모든 서비스가 무조건 blacklist까지 필요한 건 아니다
  • 강제 로그아웃 시나리오는 따로 설계해야 한다

스택별 감각은 이렇습니다.

  • FastAPI: 의존성 기반 current user 구조 위에 token version 검사를 얹기 좋다. (FastAPI)
  • Spring Boot: JWT resource server를 쓰더라도 즉시 폐기 전략은 별도 설계가 필요하고, opaque token도 대안이 될 수 있다. (Home)
  • Node.js: express-jwt 위에 token version 검사를 추가하는 방식이 현실적이다. (Home)

다음 글 예고

다음 글에서는 이 흐름을 이어서
CORS, 쿠키, CSRF — 프론트와 백엔드 연결에서 꼭 터지는 보안/통신 이슈를 다뤄보겠습니다.

즉,

  • 왜 브라우저에서만 갑자기 막히는지
  • CORS는 정확히 뭘 허용하는 건지
  • JWT를 헤더로 보낼 때와 쿠키로 보낼 때 차이가 뭔지
  • 쿠키 기반 refresh token이면 왜 CSRF를 같이 봐야 하는지
  • FastAPI · Spring Boot · Node.js에서 어디까지 설정해야 하는지

이걸 실전적으로 풀어보겠습니다.

출처

  • RFC 7009 — OAuth 2.0 Token Revocation. 토큰 무효화 메커니즘. (RFC Editor)
  • RFC 9700 — Best Current Practice for OAuth 2.0 Security. refresh token rotation 권고. (RFC Editor)
  • OWASP Session Management Cheat Sheet — 서버 측 로그아웃/세션 무효화 중요성. (OWASP Cheat Sheet Series)
  • Spring Security 공식 문서 — Session Management. 기본 security context 저장과 저장소 커스터마이징. (Home)
  • Spring Security 공식 문서 — OAuth2 Resource Server. JWT와 Opaque Token 둘 다 지원. (Home)
  • Spring Security 공식 문서 — Authentication Architecture / SecurityContextHolder. (Home)
  • FastAPI 공식 문서 — Security / JWT / current user 패턴. (FastAPI)

백엔드개발, FastAPI, SpringBoot, Nodejs, Express, JWT로그아웃, TokenRevocation, RefreshTokenRotation, 강제로그아웃, 백엔드시리즈

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함
반응형