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.0Problem
현재 인증 시스템이 access token만 사용하고 있어 문제가 발생했습니다:
// 기존 인증 로직 (문제점 있음)
const jwt = require('jsonwebtoken');
function generateToken(user) {
return jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '24h' } // 너무 긴 유효기간
);
}
// 토큰 갱신 메커니즘 없음
// 토큰 도용 시 전체 세션 유효발생한 문제들
- 토큰 도용 위험: 긴 유효기간으로 인한 보안 취약
- 강제 로그아웃 불가: 토큰 폐기가 어려움
- 세션 관리 어려움: 여러 기기에서의 세션 관리 문제
Analysis
JWT 리프레시 토큰 패턴
클라이언트 → 로그인 요청 → 서버
서버 → access token + refresh token 발급 → 클라이언트
클라이언트 → API 요청 (access token) → 서버
서버 → access token 검증 → 응답
access token 만료 시:
클라이언트 → refresh token으로 갱신 요청 → 서버
서버 → refresh token 검증 → 새 access token 발급 → 클라이언트토큰 비교
| 항목 | Access Token | Refresh 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
- 토큰 분리 저장: Access token은 메모리에, refresh token은 HttpOnly 쿠키에 저장하세요
- 리프레시 토큰 폐기: 로그아웃 시 리프레시 토큰을 명시적으로 폐기하세요
- 토큰 순환: 리프레시 토큰 갱신 시 기존 토큰을 폐기하고 새 토큰을 발급하세요
- 보안 설정: HttpOnly, Secure, SameSite 옵션을 올바르게 설정하세요
- 만료 관리: 리프레시 토큰의 만료 시간을 데이터베이스에서 관리하세요
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.