security2025-05-10·10·76/348

Node.js에서 JWT 리프레시 토큰 구현하기

JWT 리프레시 토큰을 사용한 안전한 인증 시스템 구현 방법과 보안 고려사항을 다룹니다.

Introduction

JWT는 현대 웹 애플리케이션에서 가장 많이 사용되는 인증 방식입니다. 하지만 access token의 짧은 유효기간과 보안 문제를 해결하기 위해 refresh token 패턴이 사용됩니다. 이번 포스트에서는 안전한 JWT 리프레시 토큰 구현 방법을 살펴보겠습니다.

Environment

# 프로젝트 의존성
npm list jsonwebtoken bcryptjs
# jsonwebtoken@9.0.2
# bcryptjs@2.4.3

# 테스트 환경
node --version
# v20.11.0

Problem

현재 인증 시스템이 access token만 사용하고 있어 문제가 발생했습니다:

// 기존 인증 로직 (문제점 있음)
const jwt = require('jsonwebtoken');

function generateToken(user) {
    return jwt.sign(
        { userId: user.id },
        process.env.JWT_SECRET,
        { expiresIn: '24h' }  // 너무 긴 유효기간
    );
}

// 토큰 갱신 메커니즘 없음
// 토큰 도용 시 전체 세션 유효

발생한 문제들

  1. 토큰 도용 위험: 긴 유효기간으로 인한 보안 취약
  2. 강제 로그아웃 불가: 토큰 폐기가 어려움
  3. 세션 관리 어려움: 여러 기기에서의 세션 관리 문제

Analysis

JWT 리프레시 토큰 패턴

클라이언트 → 로그인 요청 → 서버
서버 → access token + refresh token 발급 → 클라이언트

클라이언트 → API 요청 (access token) → 서버
서버 → access token 검증 → 응답

access token 만료 시:
클라이언트 → refresh token으로 갱신 요청 → 서버
서버 → refresh token 검증 → 새 access token 발급 → 클라이언트

토큰 비교

항목Access TokenRefresh Token
유효기간15-30분7-30일
저장 위치메모리 (axios)HttpOnly 쿠키
갱신 주기자주가끔
보안 요구사항높음매우 높음

Solution

1. 토큰 생성 서비스

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

class TokenService {
    constructor() {
        this.accessTokenSecret = process.env.JWT_ACCESS_SECRET;
        this.refreshTokenSecret = process.env.JWT_REFRESH_SECRET;
        this.accessExpiresIn = '15m';
        this.refreshExpiresIn = '7d';
    }

    generateAccessToken(user) {
        return jwt.sign(
            {
                userId: user.id,
                email: user.email,
                role: user.role,
                type: 'access',
            },
            this.accessTokenSecret,
            { expiresIn: this.accessExpiresIn }
        );
    }

    generateRefreshToken(user) {
        const tokenId = crypto.randomBytes(32).toString('hex');

        return {
            token: jwt.sign(
                {
                    userId: user.id,
                    tokenId: tokenId,
                    type: 'refresh',
                },
                this.refreshTokenSecret,
                { expiresIn: this.refreshExpiresIn }
            ),
            tokenId,
            expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
        };
    }

    verifyAccessToken(token) {
        return jwt.verify(token, this.accessTokenSecret);
    }

    verifyRefreshToken(token) {
        return jwt.verify(token, this.refreshTokenSecret);
    }
}

module.exports = new TokenService();

2. 인증 미들웨어

const tokenService = require('./token-service');

// Access Token 검증 미들웨어
function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
        return res.status(401).json({ error: 'Access token required' });
    }

    try {
        const decoded = tokenService.verifyAccessToken(token);
        req.user = decoded;
        next();
    } catch (error) {
        if (error.name === 'TokenExpiredError') {
            return res.status(401).json({
                error: 'Token expired',
                code: 'TOKEN_EXPIRED',
            });
        }
        return res.status(403).json({ error: 'Invalid token' });
    }
}

// Refresh Token 검증 미들웨어
function authenticateRefreshToken(req, res, next) {
    const { refreshToken } = req.cookies;

    if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token required' });
    }

    try {
        const decoded = tokenService.verifyRefreshToken(refreshToken);
        req.refreshToken = decoded;
        next();
    } catch (error) {
        return res.status(403).json({ error: 'Invalid refresh token' });
    }
}

3. 인증 라우터

const express = require('express');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser');
const tokenService = require('./token-service');
const { authenticateToken, authenticateRefreshToken } = require('./auth-middleware');

const router = express.Router();

// 쿠키 설정 옵션
const cookieOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
};

// 로그인
router.post('/login', async (req, res) => {
    const { email, password } = req.body;

    try {
        // 사용자 확인
        const user = await User.findOne({ email });
        if (!user) {
            return res.status(401).json({ error: 'Invalid credentials' });
        }

        // 비밀번호 확인
        const validPassword = await bcrypt.compare(password, user.password);
        if (!validPassword) {
            return res.status(401).json({ error: 'Invalid credentials' });
        }

        // 토큰 생성
        const accessToken = tokenService.generateAccessToken(user);
        const refreshToken = tokenService.generateRefreshToken(user);

        // 리프레시 토큰 저장
        await RefreshToken.create({
            tokenId: refreshToken.tokenId,
            userId: user.id,
            expiresAt: refreshToken.expiresAt,
            userAgent: req.headers['user-agent'],
        });

        // HttpOnly 쿠키로 리프레시 토큰 전달
        res.cookie('refreshToken', refreshToken.token, cookieOptions);

        res.json({
            accessToken,
            user: {
                id: user.id,
                email: user.email,
                role: user.role,
            },
        });
    } catch (error) {
        res.status(500).json({ error: 'Login failed' });
    }
});

// 토큰 갱신
router.post('/refresh', authenticateRefreshToken, async (req, res) => {
    try {
        const { refreshToken } = req.refreshToken;

        // 리프레시 토큰 유효성 확인
        const storedToken = await RefreshToken.findOne({
            tokenId: refreshToken.tokenId,
            revoked: false,
        });

        if (!storedToken) {
            return res.status(403).json({ error: 'Token revoked' });
        }

        // 사용자 정보 가져오기
        const user = await User.findById(refreshToken.userId);

        // 새 토큰 생성
        const newAccessToken = tokenService.generateAccessToken(user);
        const newRefreshToken = tokenService.generateRefreshToken(user);

        // 기존 토큰 폐기
        await RefreshToken.updateOne(
            { tokenId: refreshToken.tokenId },
            { revoked: true }
        );

        // 새 리프레시 토큰 저장
        await RefreshToken.create({
            tokenId: newRefreshToken.tokenId,
            userId: user.id,
            expiresAt: newRefreshToken.expiresAt,
            userAgent: req.headers['user-agent'],
        });

        // 새 리프레시 토큰 쿠키 설정
        res.cookie('refreshToken', newRefreshToken.token, cookieOptions);

        res.json({
            accessToken: newAccessToken,
        });
    } catch (error) {
        res.status(500).json({ error: 'Token refresh failed' });
    }
});

// 로그아웃
router.post('/logout', authenticateToken, async (req, res) => {
    try {
        // 리프레시 토큰 폐기
        await RefreshToken.updateMany(
            { userId: req.user.userId },
            { revoked: true }
        );

        // 쿠키 제거
        res.clearCookie('refreshToken');

        res.json({ message: 'Logged out successfully' });
    } catch (error) {
        res.status(500).json({ error: 'Logout failed' });
    }
});

// 보호된 라우트
router.get('/profile', authenticateToken, async (req, res) => {
    const user = await User.findById(req.user.userId).select('-password');
    res.json(user);
});

module.exports = router;

4. 리프레시 토큰 관리 모델

const mongoose = require('mongoose');

const refreshTokenSchema = new mongoose.Schema({
    tokenId: {
        type: String,
        required: true,
        unique: true,
        index: true,
    },
    userId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true,
    },
    expiresAt: {
        type: Date,
        required: true,
        index: { expireAfterSeconds: 0 },
    },
    revoked: {
        type: Boolean,
        default: false,
    },
    userAgent: String,
    ipAddress: String,
}, {
    timestamps: true,
});

// 만료된 토큰 자동 삭제
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

module.exports = mongoose.model('RefreshToken', refreshTokenSchema);

Lessons Learned

  1. 토큰 분리 저장: Access token은 메모리에, refresh token은 HttpOnly 쿠키에 저장하세요
  2. 리프레시 토큰 폐기: 로그아웃 시 리프레시 토큰을 명시적으로 폐기하세요
  3. 토큰 순환: 리프레시 토큰 갱신 시 기존 토큰을 폐기하고 새 토큰을 발급하세요
  4. 보안 설정: HttpOnly, Secure, SameSite 옵션을 올바르게 설정하세요
  5. 만료 관리: 리프레시 토큰의 만료 시간을 데이터베이스에서 관리하세요

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.