안전한 JWT 인증 및 회전 새로 고침 토큰 구현하기 (TypeScript, Express, JS)
2025-03-14 18:16:36안전한 인증 구현하기
웹 애플리케이션에서 인증은 핵심적인 부분이며 더욱 더 안전하게 다루는 것이 중요합니다. 많은 개발자들은 localStorage에 액세스 토큰과 새로 고침 토큰을 저장하지만, 이는 XSS 공격에 노출될 위험이 있습니다. 이번 글에서는 이러한 문제를 해결하기 위해 JWT를 활용한 인증 시스템을 구축하는 방법을 설명합니다. 이 과정을 통해 TypeScript와 Express를 사용하여 백엔드를 구축하고 Vanilla JS로 프론트엔드를 연결하는 방법을 알아보겠습니다.
JWT 인증 흐름 개요
인증 흐름을 이해하기 위해 아래의 흐름을 따져보겠습니다:
- 사용자 로그인: 사용자가 인증 정보를 제공하면 서버가 이를 검증합니다.
- 토큰 발급: 인증에 성공하면, 서버는 두 개의 토큰을 발급합니다.
- 짧은 수명의 액세스 토큰: 보통 15분
- 긴 수명의 새로 고침 토큰: 보통 7일
- 토큰 저장:
- 액세스 토큰은
localStorage나 메모리에 저장 - 새로 고침 토큰은 HTTP 전용 쿠키에 저장
- 액세스 토큰은
- API 인증: 클라이언트는 API 요청의 Authorization 헤더에 액세스 토큰을 포함
- 토큰 만료: 액세스 토큰이 만료되면, 클라이언트는 새로 고침 토큰을 사용하여 새로운 액세스 토큰을 요청
- 토큰 회전: 각 새로 고침 시도가 이전 토큰을 무효화하며 보안을 강화
백엔드 구축하기
1. 기본 설정 및 필수 모듈 가져오기
필요한 패키지를 가져오고 앱을 설정하여 JSON, 쿠키 및 CORS를 사용할 수 있도록 구성합니다. 환경 변수를 dotenv로 초기화합니다.
import express, { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import cookieParser from "cookie-parser";
import cors from "cors";
import dotenv from "dotenv";
import { randomUUID as uuidv4 } from "crypto";
dotenv.config();
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
credentials: true,
origin: ["http://localhost:5500", "http://127.0.0.1:5500"],
})
);
위 설정에서 credentials: true와 프런트엔드 도메인을 명시함으로써 해당 도메인으로의 요청시에 쿠키가 안정적으로 전송되도록 합니다.
2. 인메모리 데이터베이스
간단히 하기 위해 사용자와 토큰 데이터를 메모리에 저장합니다. 실제 환경에서는 PostgreSQL, MongoDB 등과 같은 실제 데이터베이스를 사용하세요.
const db = {
users: [{ id: 1, username: "admin", password: "password" }],
refreshTokens: {} as Record<string, string>,
} as const;
refreshTokens는 각 사용자의 유효한 새로 고침 토큰을 보유합니다. 이 예시에서는 모두 readonly로 처리하였습니다.
3. 비밀 및 유틸리티 함수
JWT 비밀을 환경 변수에서 읽어오고, 토큰을 생성하는 두 가지 유틸리티 함수를 정의합니다.
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const REFRESH_SECRET = process.env.REFRESH_SECRET!;
let refreshTokens: Record<string, string> = {};
const generateAccessToken = (user: any) =>
jwt.sign({ id: user.id }, ACCESS_SECRET, { expiresIn: "5s" });
const generateRefreshToken = (user: any) => {
const tokenId = uuidv4();
const token = jwt.sign({ id: user.id, tokenId }, REFRESH_SECRET, {
expiresIn: "7d",
});
refreshTokens[user.id] = token;
return token;
};
4. 로그인 라우트
사용자가 로그인하면 액세스 토큰과 새로 고침 토큰을 생성합니다. 액세스 토큰은 JSON 응답으로 반환되고, 새로 고침 토큰은 HTTP 전용 쿠키에 저장됩니다.
app.post("/auth/login", (req, res) => {
const { username, password } = req.body;
const user = db.users.find(
(u) => u.username === username && u.password === password
);
if (!user) {
res.status(401).json({ message: "Invalid credentials" });
return;
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
db.refreshTokens[user.id] = refreshToken;
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "none",
});
res.json({ accessToken });
});
이 라우트에서는 잘못된 자격 증명이 있으면 401 상태 코드가 반환되며, httpOnly 쿠키는 JavaScript에서 읽을 수 없기 때문에 XSS로부터 보호할 수 있습니다.
5. 새로 고침 라우트 (토큰 회전)
짧은 수명의 액세스 토큰이 만료되면, 프론트엔드는 /auth/refresh에 요청을 보내어 새로운 액세스 토큰을 요청합니다.
app.post("/auth/refresh", (req, res) => {
const oldToken = req.cookies.refreshToken;
if (!oldToken) {
res.status(401).json({ message: "Unauthorized" });
return;
}
jwt.verify(oldToken, REFRESH_SECRET, (err: any, user: any) => {
if (err || db.refreshTokens[user.id] !== oldToken) {
res.clearCookie("refreshToken");
res.status(401).json({ message: "Invalid refresh token" });
return;
}
delete db.refreshTokens[user.id];
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
db.refreshTokens[user.id] = newRefreshToken;
res.cookie("refreshToken", newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: "none",
});
res.json({ accessToken: newAccessToken });
});
});
이 라우트는 기존의 새로 고침 토큰을 읽고, 유효성을 검증한 후 새로운 토큰을 생성하여 회전을 구현합니다.
마무리
이 글에서는 안전한 JWT 인증과 회전하는 새로 고침 토큰을 TypeScript와 Express를 이용해 구현하는 방법을 설명하였습니다. 이 과정을 통해 더욱 안전한 인증 시스템을 구축하고, 취약점을 최소화할 수 있습니다. 실제 프로젝트에서는 데이터베이스 등을 추가하여 더 복잡한 인증 시스템을 구현할 수 있으며, 본 글에서 소개한 메커니즘을 바탕으로 확장해보세요.