junyeokk
Blog
Security·2025. 04. 16

Refresh Token + Access Token 분리

웹 서비스에서 사용자 인증을 구현할 때 가장 먼저 부딪히는 문제가 있다. "사용자가 로그인한 상태를 어떻게 유지할 것인가?"

세션 기반 인증은 서버가 상태를 들고 있어야 하니까 서버 확장이 어렵다. 그래서 JWT(JSON Web Token)를 쓰는데, JWT는 서버가 상태를 저장하지 않아도 토큰 자체에 사용자 정보가 담겨 있어서 stateless하게 인증을 처리할 수 있다.

그런데 JWT에도 치명적인 약점이 있다. 한 번 발급된 토큰은 만료 전까지 무효화할 수 없다. 만약 토큰이 탈취당하면? 만료될 때까지 공격자가 자유롭게 사용할 수 있다. 그렇다고 토큰 유효기간을 짧게 잡으면? 사용자가 5분마다 다시 로그인해야 한다. 이 딜레마를 해결하는 것이 바로 Access Token + Refresh Token 이중 토큰 전략이다.


핵심 아이디어

토큰을 두 종류로 분리한다.

Access TokenRefresh Token
용도API 요청 인증Access Token 재발급
유효기간짧음 (15분~1시간)김 (7일~30일)
저장 위치Authorization 헤더 (메모리/변수)HttpOnly 쿠키
탈취 위험높음 (매 요청마다 전송)낮음 (특정 엔드포인트에만 전송)

Access Token은 짧은 수명으로 탈취 피해를 최소화하고, Refresh Token은 긴 수명으로 사용자 편의를 유지한다. 각 토큰의 역할과 보안 수준이 다르기 때문에 저장 방식도 달라야 한다.


인증 흐름

전체 흐름을 단계별로 보면 이렇다.

1. 로그인

사용자가 로그인하면 서버는 두 개의 토큰을 생성해서 내려준다.

typescript
import * as jwt from 'jsonwebtoken';

function generateTokens(userId: number) {
  const accessToken = jwt.sign(
    { id: userId },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '1h' }
  );

  const refreshToken = jwt.sign(
    { id: userId },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '14d' }
  );

  return { accessToken, refreshToken };
}

Access Token과 Refresh Token은 반드시 다른 비밀키로 서명해야 한다. 같은 키를 사용하면 Access Token의 서명을 검증하는 로직으로 Refresh Token까지 위조할 수 있기 때문이다.

2. Access Token으로 API 요청

클라이언트는 API 요청 시 Authorization 헤더에 Access Token을 실어 보낸다.

typescript
// 클라이언트
const response = await fetch('/api/posts', {
  headers: {
    Authorization: `Bearer ${accessToken}`
  }
});

서버는 이 토큰을 검증해서 사용자를 식별한다. Access Token이 유효하면 요청을 처리하고, 만료됐으면 401을 반환한다.

3. Access Token 만료 → Refresh Token으로 재발급

Access Token이 만료되면 클라이언트는 Refresh Token을 사용해서 새 Access Token을 발급받는다.

typescript
// 클라이언트 — 401 응답 시 토큰 갱신
async function refreshAccessToken() {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include'  // 쿠키 자동 포함
  });

  if (response.ok) {
    const { accessToken } = await response.json();
    return accessToken;
  }

  // Refresh Token도 만료 → 재로그인 필요
  redirectToLogin();
}
typescript
// 서버 — Refresh 엔드포인트
async refreshToken(userId: number, refreshToken: string) {
  // 1. Refresh Token 유효성 검증
  const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);

  // 2. 저장된 Refresh Token과 비교 (선택적이지만 권장)
  const storedToken = await this.getStoredRefreshToken(payload.id);
  if (storedToken !== refreshToken) {
    throw new UnauthorizedException('유효하지 않은 토큰');
  }

  // 3. 새 Access Token 발급
  const newAccessToken = jwt.sign(
    { id: payload.id },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '1h' }
  );

  return { accessToken: newAccessToken };
}

중요한 포인트는 Refresh Token을 서버에도 저장해서 비교한다는 점이다. JWT는 원래 stateless지만, Refresh Token만큼은 서버에서 관리하는 게 안전하다. 이렇게 하면 필요할 때 Refresh Token을 서버 측에서 무효화할 수 있다.


Refresh Token 저장: 왜 HttpOnly 쿠키인가

Refresh Token은 반드시 HttpOnly 쿠키에 저장해야 한다. 이유를 하나씩 보자.

localStorage/sessionStorage의 문제

javascript
// ❌ 이렇게 하면 안 됨
localStorage.setItem('refreshToken', token);

localStorage는 JavaScript로 접근할 수 있다. 즉 XSS(Cross-Site Scripting) 공격에 취약하다. 악성 스크립트가 페이지에 삽입되면 localStorage.getItem('refreshToken')으로 토큰을 훔쳐갈 수 있다.

HttpOnly 쿠키가 안전한 이유

typescript
// 서버에서 Refresh Token을 쿠키로 설정
response.cookie('refresh_token', refreshToken, {
  httpOnly: true,   // JavaScript에서 접근 불가
  secure: true,     // HTTPS에서만 전송
  sameSite: 'strict', // CSRF 방지
  path: '/api/auth',  // 인증 엔드포인트에서만 전송
  maxAge: 14 * 24 * 60 * 60 * 1000  // 14일
});
옵션역할
httpOnlydocument.cookie로 접근 불가 → XSS 방어
secureHTTPS 연결에서만 쿠키 전송 → 도청 방지
sameSite: 'strict'다른 사이트에서 요청 시 쿠키 미전송 → CSRF 방어
path: '/api/auth'인증 관련 요청에만 쿠키 전송 → 노출 범위 최소화

httpOnly: true가 핵심이다. 이 옵션이 설정된 쿠키는 JavaScript에서 아예 읽을 수 없다. 브라우저가 HTTP 요청 시 자동으로 포함할 뿐이다. XSS 공격으로 스크립트를 삽입하더라도 쿠키 값 자체를 탈취할 수 없다.

Access Token은 왜 쿠키에 안 넣나?

Access Token을 쿠키에 넣으면 CSRF 공격에 노출된다. 쿠키는 브라우저가 자동으로 전송하기 때문에, 공격자가 만든 악성 사이트에서 API 요청을 보내면 Access Token이 자동으로 포함된다.

반면 Authorization 헤더에 넣으면 JavaScript 코드에서 명시적으로 헤더에 추가해야 한다. 브라우저가 자동으로 추가하지 않으므로 CSRF 공격이 불가능하다.

typescript
// Access Token은 메모리에 보관하고 명시적으로 헤더에 추가
let accessToken = null;

async function apiRequest(url, options = {}) {
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`
    }
  });
}

정리하면:

  • Access Token → 메모리 변수 → Authorization 헤더로 전송 → CSRF 불가, XSS에는 노출 (하지만 수명이 짧음)
  • Refresh Token → HttpOnly 쿠키 → 자동 전송 → XSS 불가, CSRF는 sameSite로 방어

Passport.js에서 이중 토큰 구현

Node.js에서는 Passport.js의 Strategy 패턴으로 깔끔하게 구현할 수 있다. Access Token용 Strategy와 Refresh Token용 Strategy를 각각 만든다.

Access Token Strategy

typescript
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor() {
    super({
      // Authorization: Bearer <token> 에서 추출
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_ACCESS_SECRET,
    });
  }

  async validate(payload: { id: number }) {
    return payload;  // req.user에 할당됨
  }
}

ExtractJwt.fromAuthHeaderAsBearerToken()은 요청 헤더의 Authorization: Bearer xxx 에서 토큰을 추출하는 내장 함수다. 추출된 토큰을 secretOrKey로 검증하고, 유효하면 validate()가 호출된다.

Refresh Token Strategy

typescript
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { Request } from 'express';

@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor() {
    super({
      // 쿠키에서 직접 추출하는 커스텀 extractor
      jwtFromRequest: (req: Request) => {
        return req.cookies?.['refresh_token'];
      },
      secretOrKey: process.env.JWT_REFRESH_SECRET,
      passReqToCallback: true,  // validate()에서 req 접근 가능
    });
  }

  async validate(req: Request, payload: { id: number }) {
    const refreshToken = req.cookies['refresh_token'];
    return { ...payload, refreshToken };
  }
}

Refresh Token은 쿠키에서 추출해야 하므로 jwtFromRequest에 커스텀 extractor 함수를 넘긴다. passReqToCallback: true를 설정하면 validate()의 첫 번째 인자로 Request 객체를 받을 수 있어서, 쿠키의 원본 토큰 값에도 접근할 수 있다.

두 Strategy를 'jwt''jwt-refresh'라는 이름으로 등록했기 때문에, Guard에서 이 이름으로 구분해서 사용한다.

Guard 적용

typescript
import { AuthGuard } from '@nestjs/passport';

// Access Token 검증 Guard
export class JwtGuard extends AuthGuard('jwt') {}

// Refresh Token 검증 Guard  
export class RefreshJwtGuard extends AuthGuard('jwt-refresh') {}
typescript
@Controller('auth')
export class AuthController {
  // Access Token 필요한 일반 API
  @UseGuards(JwtGuard)
  @Get('profile')
  getProfile(@Req() req) {
    return req.user;
  }

  // Refresh Token으로 Access Token 재발급
  @UseGuards(RefreshJwtGuard)
  @Post('refresh')
  refresh(@Req() req) {
    const { id } = req.user;
    const newAccessToken = this.authService.generateAccessToken(id);
    return { accessToken: newAccessToken };
  }
}

@UseGuards(JwtGuard)를 붙이면 Access Token을 검증하고, @UseGuards(RefreshJwtGuard)를 붙이면 Refresh Token을 검증한다. 각 Guard가 알아서 해당 Strategy를 호출하기 때문에, 컨트롤러에서는 토큰 파싱/검증 로직을 신경 쓸 필요가 없다.


Token Blacklist: JWT의 한계 보완

JWT는 stateless라서 발급된 토큰을 서버에서 강제로 만료시킬 수 없다. 사용자가 로그아웃했는데 Access Token이 아직 유효하다면? 이 문제를 해결하려면 블랙리스트가 필요하다.

typescript
// 로그아웃 시 현재 Access Token을 블랙리스트에 추가
async logout(accessToken: string) {
  // 토큰의 남은 유효기간만큼 블랙리스트에 저장
  const decoded = jwt.decode(accessToken) as { exp: number };
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);

  if (ttl > 0) {
    await redis.set(`blacklist:${accessToken}`, 'true', 'EX', ttl);
  }
}
typescript
// Access Token Strategy에서 블랙리스트 체크
async validate(req: Request, payload: { id: number }) {
  const token = req.headers.authorization?.replace('Bearer ', '');

  const isBlacklisted = await redis.get(`blacklist:${token}`);
  if (isBlacklisted) {
    throw new UnauthorizedException('만료된 토큰');
  }

  return payload;
}

블랙리스트를 Redis에 저장하는 이유는 두 가지다.

  1. TTL 자동 만료: Access Token의 남은 유효기간만큼만 저장하면 되므로, Redis의 EX 옵션으로 자동 삭제가 가능하다. 만료된 토큰을 수동으로 정리할 필요가 없다.
  2. 빠른 조회: 매 API 요청마다 블랙리스트를 확인해야 하므로 조회 속도가 중요하다. Redis는 메모리 기반이라 O(1)으로 조회할 수 있다.

"JWT가 stateless인데 블랙리스트를 쓰면 stateless가 아니지 않나?" 맞다. 하지만 모든 토큰의 세션을 서버에서 관리하는 것과, 로그아웃된 소수의 토큰만 블랙리스트로 관리하는 것은 비용이 완전히 다르다. 대부분의 요청은 여전히 stateless하게 처리되고, 블랙리스트 확인은 O(1)이므로 실용적인 트레이드오프다.


Refresh Token Rotation

보안을 한 단계 더 강화하려면 Refresh Token Rotation을 적용할 수 있다. Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하고, 이전 것은 무효화하는 방식이다.

typescript
async refreshTokens(userId: number, oldRefreshToken: string) {
  // 1. 저장된 Refresh Token과 비교
  const stored = await redis.get(`refresh:${userId}`);
  if (stored !== oldRefreshToken) {
    // 이미 사용된 Refresh Token으로 요청 → 토큰 탈취 의심
    // 해당 사용자의 모든 토큰 무효화
    await redis.del(`refresh:${userId}`);
    throw new UnauthorizedException('토큰 재사용 감지');
  }

  // 2. 새 토큰 쌍 발급
  const newAccessToken = this.generateAccessToken(userId);
  const newRefreshToken = this.generateRefreshToken(userId);

  // 3. 새 Refresh Token 저장 (이전 것은 자동으로 덮어씀)
  await redis.set(`refresh:${userId}`, newRefreshToken, 'EX', 14 * 24 * 60 * 60);

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Rotation의 핵심 보안 이점은 Refresh Token 재사용 감지다. 공격자가 Refresh Token을 탈취해서 사용하면, 정상 사용자가 같은 토큰으로 요청했을 때 "이미 사용된 토큰"으로 감지된다. 이때 해당 사용자의 모든 토큰을 무효화해서 피해를 차단할 수 있다.


클라이언트: Axios Interceptor로 자동 갱신

실제 서비스에서는 Access Token 만료 시 자동으로 갱신하는 로직이 필요하다. Axios의 response interceptor를 사용하면 깔끔하게 처리할 수 있다.

typescript
import axios from 'axios';

const api = axios.create({
  baseURL: '/api',
  withCredentials: true  // 쿠키 포함
});

let accessToken: string | null = null;

// Request interceptor: Access Token 자동 추가
api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// Response interceptor: 401 시 토큰 갱신 후 재시도
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // 401이고 아직 재시도하지 않은 요청만 처리
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await axios.post('/api/auth/refresh', null, {
          withCredentials: true
        });
        accessToken = data.accessToken;

        // 원래 요청을 새 토큰으로 재시도
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        // Refresh Token도 만료 → 로그인 페이지로
        accessToken = null;
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

_retry 플래그는 무한 루프를 방지한다. 토큰 갱신 요청 자체가 401을 반환하면 재시도하지 않고 로그인 페이지로 리다이렉트한다.

주의할 점이 있다. 여러 API 요청이 동시에 401을 받으면 갱신 요청이 여러 번 발생할 수 있다. 이를 방지하려면 갱신 중인 Promise를 공유하는 패턴을 사용한다.

typescript
let refreshPromise: Promise<string> | null = null;

async function getNewAccessToken() {
  if (!refreshPromise) {
    refreshPromise = axios.post('/api/auth/refresh', null, {
      withCredentials: true
    }).then(({ data }) => {
      accessToken = data.accessToken;
      return accessToken;
    }).finally(() => {
      refreshPromise = null;
    });
  }
  return refreshPromise;
}

동시에 여러 요청이 토큰 갱신을 시도해도 실제 네트워크 요청은 한 번만 발생하고, 나머지는 같은 Promise를 기다린다.


보안 체크리스트

이중 토큰 전략을 구현할 때 놓치기 쉬운 보안 사항들이다.

항목설명
Access/Refresh 비밀키 분리같은 키를 사용하면 한쪽이 유출됐을 때 양쪽 다 위험
Refresh Token 서버 저장클라이언트만 보관하면 서버에서 무효화 불가
HttpOnly + Secure + SameSite쿠키 보안 3종 세트는 반드시 함께 설정
블랙리스트 TTL 설정Access Token 만료 시간만큼만 저장, 영구 저장 금지
Refresh 엔드포인트 Rate Limit무차별 갱신 요청 방지
로그아웃 시 양쪽 토큰 무효화Refresh Token 삭제 + Access Token 블랙리스트
Token Rotation 고려Refresh Token 재사용 공격 방어

관련 문서