Access Token, Refresh Token, Refresh Token Rotation

웹 서비스나 게임 서버에서 로그인 기능을 구현할 때 중요한 개념 중 하나가 Access Token과 Refresh Token을 이용한 인증 구조다.

사용자가 로그인하면 서버는 인증 정보를 바탕으로 토큰을 발급한다. 클라이언트는 이 토큰을 사용해 API 요청을 보내며, 토큰이 만료되면 갱신 절차를 거친다.

하지만 Refresh Token을 오랫동안 동일하게 유지하면 토큰 탈취 시 공격자가 지속적으로 새로운 Access Token을 발급받을 수 있다. 이를 방지하기 위한 대표적인 보안 기법이 **Refresh Token Rotation(리프레시 토큰 로테이션)**이다.

이 글에서는 세 가지 개념의 역할과 동작 원리, 서버 구현 구조, 보안상 주의점을 살펴본다.

Access Token이란?

Access Token은 클라이언트가 서버의 보호된 리소스에 접근할 수 있도록 발급받는 인증·인가용 토큰이다.

예를 들어 게임 클라이언트가 플레이어의 프로필을 조회하거나 인벤토리를 확인하려면 서버에 자신의 요청 권한을 증명해야 한다. 이때 Access Token을 사용할 수 있다.

GET /api/player/inventory HTTP/1.1
Host: api.example.com
Authorization: Bearer ACCESS_TOKEN

서버는 전달받은 토큰을 검증한 뒤 유효성, 만료 여부, 권한 등을 확인하고 요청을 처리한다.

Access Token의 주요 특징은 다음과 같다.

  • 짧은 수명: 탈취되더라도 공격자가 사용할 수 있는 시간을 줄인다.
  • API 접근: 보호된 리소스에 대한 요청을 인증·인가하는 데 사용한다.
  • 권한 제한: 특정 사용자, 리소스, 권한 범위에 맞게 제한할 수 있다.
  • 만료: 만료된 토큰은 일반적인 API 인증에 사용할 수 없다.

Access Token은 JWT 형식일 수도 있고, 서버가 저장소에서 조회하는 불투명한 문자열(opaque token)일 수도 있다. JWT를 사용한다고 해서 자동으로 안전해지는 것은 아니다. 서명, 만료 시간, 발급자, 대상 서버 등의 검증이 필요하다.

Refresh Token이란?

Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용하는 장기 인증 자격 증명이다.

Access Token의 수명을 짧게 설정하면 탈취 위험을 줄일 수 있지만, 사용자가 짧은 주기마다 다시 로그인해야 하는 문제가 발생한다.

Refresh Token은 이 문제를 해결한다.

예를 들어 다음과 같은 정책을 사용할 수 있다.

항목Access TokenRefresh Token
주요 목적API 접근Access Token 갱신
예시 수명10분7일
사용 빈도API 요청마다갱신 시점
검증 위치API 서버 또는 리소스 서버인증 서버
탈취 시 위험제한된 시간 동안 API 접근새 Access Token 발급 가능
저장 방식메모리 등보안이 확보된 저장소

표의 수명은 설명을 위한 예시이며, 실제 수명은 서비스의 보안 정책에 따라 결정한다.

핵심은 Refresh Token을 일반 API 요청에 사용하지 않는다는 것이다. Refresh Token은 갱신 엔드포인트에만 전달하고, 서버는 이를 검증한 뒤 새로운 Access Token을 발급한다.

Access Token과 Refresh Token의 전체 동작 구조

클라이언트 로그인

아이디·비밀번호 또는 다른 인증 수단

인증 서버

사용자 인증 후 두 토큰 발급

Access Token

API 요청에 사용

짧은 수명

Refresh Token

토큰 갱신에 사용

상대적으로 긴 수명

Access Token 만료

API 요청 실패 또는 클라이언트의 만료 감지

Refresh Token으로 갱신 요청

서버가 Refresh Token 검증

새 Access Token 발급

Rotation 적용 시 새 Refresh Token도 함께 발급

이 구조를 사용하면 사용자가 매번 로그인하지 않고도 서비스를 계속 이용할 수 있다. 다만 Refresh Token이 만료되거나 폐기되면 다시 인증해야 할 수 있다.

Refresh Token Rotation이란?

Refresh Token Rotation은 Refresh Token을 한 번 사용할 때마다 새로운 Refresh Token으로 교체하고, 기존 토큰을 무효화하는 보안 기법이다.

일반적인 Refresh Token 방식과 Rotation 방식의 차이는 기존 토큰을 계속 사용할 수 있는지에 있다.

일반 Refresh Token 방식

Refresh Token A

Access Token B

Refresh Token A가 만료되거나 폐기될 때까지 반복해서 사용할 수 있다.

Refresh Token Rotation 방식

Refresh Token A

폐기

Refresh Token B

Access Token B

다음 갱신에는 B를 사용하고, 새로운 Refresh Token C를 발급받는다.

Refresh Token A를 사용해 갱신에 성공하면 서버는 새로운 토큰 쌍을 발급한다.

  • 기존 Refresh Token A: 사용 불가
  • 새로운 Access Token B: API 요청에 사용
  • 새로운 Refresh Token B: 다음 갱신에 사용

이후 Refresh Token B를 사용하면 다시 새로운 토큰으로 교체한다.

왜 기존 토큰을 폐기하는가?

토큰이 탈취되었을 때를 생각해 보자.

일반적인 방식에서는 공격자가 탈취한 Refresh Token을 반복해서 사용해 새로운 Access Token을 발급받을 수 있다.

Rotation 방식에서는 정상 사용자가 먼저 토큰을 갱신해 기존 토큰을 폐기했다면, 공격자가 탈취한 이전 토큰을 사용하려는 순간 서버가 재사용을 감지할 수 있다.

서버는 기존 토큰과 새 토큰의 관계를 추적해 해당 토큰 계열(token family)을 식별하고, 필요하면 현재 유효한 Refresh Token까지 폐기한다. 다만 서버는 정상 사용자와 공격자 중 누가 이전 토큰을 사용했는지 항상 판별할 수 없으므로, 정상 사용자도 다시 로그인해야 할 수 있다.

Refresh Token Rotation의 동작 과정

  1. 로그인 및 최초 토큰 발급 서버가 Access Token A와 Refresh Token A를 발급한다. 서버는 Refresh Token의 해시, 만료 시간, 사용자 ID, 토큰 계열 등의 정보를 저장한다.
  2. Access Token 만료 클라이언트는 Refresh Token A를 인증 서버의 갱신 엔드포인트로 전달한다.
  3. Refresh Token 검증 서버는 토큰의 존재 여부, 해시 일치, 만료 여부, 폐기 여부, 사용자 세션 상태 등을 검증한다.
  4. 기존 토큰 폐기 및 새 토큰 발급 서버는 Refresh Token A를 사용 완료 상태로 변경하고 Access Token B와 Refresh Token B를 발급한다.
  5. 이전 토큰 재사용 감지 이미 폐기된 Refresh Token A가 다시 제출되면 재사용 가능성을 감지한다. 정책에 따라 해당 토큰 계열의 활성 Refresh Token을 모두 폐기하고 재인증을 요구한다.

이 구조에서 중요한 것은 단순히 토큰 문자열을 교체하는 것이 아니다. 토큰의 발급 이력과 폐기 상태를 서버가 관리해야 한다.

서버는 Refresh Token을 어떻게 관리하는가?

JWT Access Token은 서명과 클레임을 검증해 별도의 DB 조회 없이 처리하도록 설계할 수 있다. 반면 Rotation을 적용한 Refresh Token은 재사용 감지와 폐기를 위해 서버 측 상태 관리가 필요하다.

예를 들어 MySQL에 다음과 같은 테이블을 둘 수 있다.

CREATE TABLE refresh_tokens (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,

    user_id BIGINT NOT NULL,

    -- 원본 토큰이 아닌 해시값
    token_hash CHAR(64) NOT NULL UNIQUE,

    -- 같은 로그인 세션에서 발급된 토큰의 계열
    token_family_id CHAR(36) NOT NULL,

    -- 이전 토큰과 다음 토큰의 관계
    replaced_by_token_id BIGINT NULL,

    expires_at DATETIME NOT NULL,
    revoked_at DATETIME NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_user_id (user_id),
    INDEX idx_token_family_id (token_family_id),

    FOREIGN KEY (replaced_by_token_id)
        REFERENCES refresh_tokens(id)
);

각 필드의 역할은 다음과 같다.

필드역할
user_id토큰 소유자 식별
token_hash토큰 원문 대신 해시를 저장해 유출 피해 감소
token_family_id같은 로그인 세션의 토큰 계열 식별
replaced_by_token_id기존 토큰을 대체한 토큰 추적
expires_at토큰 만료 시각
revoked_at토큰 폐기 시각

replaced_by_token_id는 새 토큰과 이전 토큰의 관계를 추적하기 위한 선택적 설계다. 토큰 계열 ID와 폐기 상태만으로 관리하는 구현도 가능하다.

Refresh Token 원문을 DB에 그대로 저장하지 않는 것이 바람직하다. 서버는 충분히 안전한 무작위 토큰을 생성하고, 원문을 해시해 저장한 뒤 클라이언트에 원문을 한 번 전달한다. 이후 클라이언트가 보낸 원문을 동일한 방식으로 해시해 DB의 값과 비교한다.

단, 해시 저장만으로 토큰 탈취 문제가 해결되는 것은 아니다. 전송 구간 암호화, 클라이언트 저장소 보호, 만료 및 폐기 정책이 함께 필요하다.

Node.js + TypeScript로 구현하는 Rotation

다음은 Express, MySQL, TypeScript를 사용하는 서버의 핵심 갱신 로직이다. 토큰 생성과 해시 함수는 별도의 유틸리티로 분리했다고 가정한다.

Refresh Token 갱신 API

import { Router } from "express";
import type { Request, Response } from "express";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
import { pool } from "./database";

const router = Router();

function hashToken(token: string): string {
  return crypto
    .createHash("sha256")
    .update(token)
    .digest("hex");
}

function createRefreshToken(): string {
  // 256비트의 암호학적으로 안전한 난수
  return crypto.randomBytes(32).toString("base64url");
}

function createAccessToken(userId: number): string {
  return jwt.sign(
    { sub: String(userId) },
    process.env.JWT_ACCESS_SECRET!,
    { expiresIn: "10m" }
  );
}

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

    if (
      typeof refreshToken !== "string" ||
      refreshToken.length === 0
    ) {
      return res.status(400).json({
        message: "Refresh token is required",
      });
    }

    const connection = await pool.getConnection();

    try {
      await connection.beginTransaction();

      const tokenHash = hashToken(refreshToken);

      // 토큰 행을 잠가 동시에 발생하는 갱신을 제어한다.
      const [rows] = await connection.execute<any[]>(
        `SELECT *
         FROM refresh_tokens
         WHERE token_hash = ?
         FOR UPDATE`,
        [tokenHash]
      );

      const storedToken = rows[0];

      if (!storedToken) {
        await connection.rollback();

        return res.status(401).json({
          message: "Invalid refresh token",
        });
      }

      // 이미 폐기된 토큰이 다시 사용되었다.
      if (storedToken.revoked_at) {
        // 같은 토큰 계열의 활성 토큰을 모두 폐기한다.
        await connection.execute(
          `UPDATE refresh_tokens
           SET revoked_at = UTC_TIMESTAMP()
           WHERE token_family_id = ?
             AND revoked_at IS NULL`,
          [storedToken.token_family_id]
        );

        await connection.commit();

        return res.status(401).json({
          message: "Refresh token reuse detected",
        });
      }

      if (
        new Date(storedToken.expires_at).getTime()
        <= Date.now()
      ) {
        await connection.rollback();

        return res.status(401).json({
          message: "Refresh token expired",
        });
      }

      const newRefreshToken = createRefreshToken();
      const newRefreshTokenHash = hashToken(newRefreshToken);

      const accessToken = createAccessToken(
        storedToken.user_id
      );

      // 이전 토큰 폐기
      await connection.execute(
        `UPDATE refresh_tokens
         SET revoked_at = UTC_TIMESTAMP()
         WHERE id = ?
           AND revoked_at IS NULL`,
        [storedToken.id]
      );

      // 새 Refresh Token 저장
      const [insertResult] = await connection.execute<any>(
        `INSERT INTO refresh_tokens (
           user_id,
           token_hash,
           token_family_id,
           expires_at
         )
         VALUES (
           ?, ?, ?,
           DATE_ADD(UTC_TIMESTAMP(), INTERVAL 7 DAY)
         )`,
        [
          storedToken.user_id,
          newRefreshTokenHash,
          storedToken.token_family_id,
        ]
      );

      // 이전 토큰이 어떤 토큰으로 교체되었는지 기록
      await connection.execute(
        `UPDATE refresh_tokens
         SET replaced_by_token_id = ?
         WHERE id = ?`,
        [insertResult.insertId, storedToken.id]
      );

      await connection.commit();

      return res.json({
        accessToken,
        refreshToken: newRefreshToken,
        tokenType: "Bearer",
        expiresIn: 600,
      });
    } catch (error) {
      await connection.rollback();

      return res.status(500).json({
        message: "Token refresh failed",
      });
    } finally {
      connection.release();
    }
  }
);

export default router;

교육용 핵심 예제다. 실제 서비스에서는 MySQL 드라이버 타입, 오류 처리, 토큰 패밀리 정책, 쿠키 처리, 로그아웃과 세션 폐기 로직 등을 보완해야 한다.

코드에서 중요한 부분

토큰 해시 저장

const tokenHash = hashToken(refreshToken);

클라이언트가 전달한 토큰 원문을 DB에 저장하는 대신 해시를 조회 키로 사용한다. Refresh Token은 충분한 엔트로피를 가진 난수여야 하며, 비밀번호처럼 낮은 엔트로피를 가진 값과는 특성이 다르다.

트랜잭션과 행 잠금

SELECT *
FROM refresh_tokens
WHERE token_hash = ?
FOR UPDATE;

여러 요청이 동일한 Refresh Token으로 동시에 갱신을 시도할 수 있다. 행 잠금과 트랜잭션을 사용하면 기존 토큰 폐기와 새 토큰 발급을 원자적으로 처리하는 데 도움이 된다.

단, 실제 동시 요청 처리에서는 두 번째 요청이 잠금 해제 후 폐기된 토큰을 읽고 재사용 감지 정책을 실행할 수 있다. 모바일 네트워크 재시도나 여러 API 요청의 동시 갱신도 고려해야 한다. 클라이언트에서 갱신 요청을 단일화하고, 서버에서도 원자적인 상태 전이를 보장해야 한다.

토큰 계열 전체 폐기

UPDATE refresh_tokens
SET revoked_at = UTC_TIMESTAMP()
WHERE token_family_id = ?
  AND revoked_at IS NULL;

이미 폐기된 토큰이 다시 사용되었다면 해당 로그인 세션의 활성 Refresh Token까지 무효화한다. 공격자가 탈취한 토큰으로 계속 인증을 유지하는 것을 차단하기 위한 조치다.

다만 이 예제에는 몇 가지 추가 보완이 필요하다.

  • 만료된 토큰의 재사용과 정상적인 폐기 토큰의 재사용을 구분하는 정책
  • 새 토큰을 발급한 뒤 응답이 유실된 경우의 재시도 처리
  • Access Token 발급과 DB 트랜잭션 사이의 실패 처리
  • 로그아웃, 비밀번호 변경, 계정 정지 시 토큰 계열 폐기
  • Refresh Token의 절대 최대 수명과 비활성 만료 정책

특히 새 토큰을 발급한 뒤 클라이언트가 응답을 받지 못하면 기존 토큰은 이미 폐기되었을 수 있다. 이 경우 무조건 같은 토큰으로 재시도하면 재사용 감지로 처리될 수 있다. 무제한 재사용 허용 대신 제한된 재시도 정책이나 재인증을 고려해야 한다.

클라이언트에서는 어떻게 사용하는가?

클라이언트는 Access Token을 API 요청에 사용하고, 만료 시 Refresh Token으로 갱신한다.

async function refreshAccessToken(
  refreshToken: string
) {
  const response = await fetch("/auth/refresh", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ refreshToken }),
  });

  if (!response.ok) {
    // 재인증이 필요한 상태
    throw new Error("Re-authentication required");
  }

  const tokens = await response.json();

  // 기존 토큰을 새 토큰으로 교체
  return {
    accessToken: tokens.accessToken,
    refreshToken: tokens.refreshToken,
  };
}

실제 클라이언트는 반환된 새 Refresh Token을 안전하게 저장하고, 기존 Refresh Token을 더 이상 사용하지 않아야 한다.

웹 브라우저에서는 Refresh Token을 HttpOnly, Secure, 적절한 SameSite 속성을 적용한 쿠키로 관리하는 방식을 고려할 수 있다. 쿠키 기반 인증에서는 CSRF 방어도 함께 설계해야 한다. 모바일 앱에서는 운영체제의 보안 저장소를 고려한다.

실무에서 반드시 고려할 보안 정책

항목권장 설계
Access Token 수명짧게 설정하고 권한 범위 최소화
Refresh Token 수명절대 최대 수명 및 비활성 만료 설정
Rotation갱신할 때마다 새 Refresh Token 발급
재사용 감지이전 토큰 재사용 시 위험 평가 후 세션 폐기
저장토큰 원문 노출 최소화, 서버에는 해시 저장
통신HTTPS 필수
로그아웃해당 토큰 계열 폐기
동시 갱신클라이언트 요청 단일화 및 서버 측 원자성 보장

OAuth 2.0 보안 모범 사례인 RFC 9700은 공개 클라이언트의 Refresh Token에 대해 토큰 Rotation 또는 DPoP와 같은 송신자 제한 방식을 통한 재사용 방지 메커니즘을 요구한다. Rotation을 사용한다면 토큰 계열과 재사용 감지를 올바르게 구현해야 한다.

정리

  • Access Token: API에 접근할 때 사용하는 단기 인증 자격 증명
  • Refresh Token: 새로운 Access Token을 발급받기 위한 장기 자격 증명
  • Refresh Token Rotation: 갱신할 때마다 Refresh Token을 교체하고 기존 토큰을 폐기하는 방식
  • Reuse Detection: 폐기된 Refresh Token이 다시 사용되었는지 감지하는 보안 메커니즘
  • Token Family: 같은 로그인 세션에서 이어진 토큰들을 묶어 관리하는 개념

핵심은 토큰의 수명을 짧게 설정하는 것만으로는 충분하지 않다는 점이다. Refresh Token 탈취에 대응하려면 토큰의 수명, 저장 방식, 폐기 상태, 재사용 감지, 동시성 제어를 하나의 인증 시스템으로 설계해야 한다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다