React + Express로 JWT 인증 시스템을 직접 구현해보면서 배운 내용을 정리한다.
| 패키지 | 역할 |
|---|---|
jsonwebtoken | JWT 토큰 생성(jwt.sign)·검증(jwt.verify)·디코딩(jwt.decode) |
express | 백엔드 서버 프레임워크 |
cors | 프론트(5173)·백엔드(3001) 간 CORS 허용 설정 |
cookie-parser | 요청에 담긴 쿠키를 req.cookies로 파싱 |
react | 프론트엔드 UI (로그인 폼, 인증 상태 관리) |
# 백엔드
npm install express jsonwebtoken cors cookie-parser
# 프론트엔드는 fetch API만 사용, 별도 HTTP 라이브러리 없음
JWT(JSON Web Token)는 두 당사자 간 정보를 안전하게 전송하기 위한 개방형 표준(RFC 7519)이다.
구조: Header.Payload.Signature
| 구성요소 | 내용 | 특징 |
|---|---|---|
| Header | 토큰 타입 + 알고리즘 ({"alg":"HS256","typ":"JWT"}) | Base64 인코딩 |
| Payload | 실제 데이터 (userId, username, exp 등) | Base64 인코딩, 암호화 아님 |
| Signature | Header + Payload + Secret Key로 만든 해시값 | Secret 없이는 위조 불가 |
⚠️ 가장 중요한 포인트: Base64 인코딩 ≠ 암호화. 누구나 디코딩해서 payload를 볼 수 있다. 따라서 JWT payload에는 비밀번호 같은 민감 정보를 절대 넣으면 안 된다.
JWT의 핵심 장점: Stateless(무상태)
서버가 세션을 별도 저장하지 않고 토큰 자체에 정보가 담겨있어서, 서버를 여러 대로 확장해도 세션 동기화 문제가 없다.
Authorization: Bearer <token> 헤더에 직접 담아서 전송localStorage.getItem()으로 토큰을 그대로 탈취 가능credentials: "include" 옵션으로 브라우저가 자동 전송httpOnly: true 설정 시 JavaScript로 절대 접근 불가 → XSS로부터 토큰 자체는 안전sameSite 옵션으로 완화)httpOnly: true // JS 접근 차단 (XSS 방어의 핵심)
secure: false // 개발환경(false) / 프로덕션(true, HTTPS 필수)
sameSite: "lax" // CSRF 방어 (strict/lax/none)
maxAge: 3600000 // 만료시간(ms), 토큰 만료시간과 맞춤
path: "/" // 쿠키 사용 가능 경로
결론: XSS는 HttpOnly 쿠키로 막고, CSRF는 sameSite + 서버 측 검증으로 막는 게 실무 정석 조합.
server.js)jsonwebtoken 패키지에서 jwt.sign, jwt.verify, jwt.decode 세 함수를 가져와 사용한다.
import jwt from "jsonwebtoken";
app.post("/api/login", (req, res) => {
const { username, password, storageType = "localStorage" } = req.body;
const user = users.find(
(u) => u.username === username && u.password === password,
);
if (!user) {
return res.status(401).json({
success: false,
message: "사용자 이름 또는 비밀번호가 잘못되었습니다.",
});
}
const payload = {
userId: user.id,
username: user.username,
role: user.role,
};
const options = {
expiresIn: "1h",
issuer: "jwt-education-server",
subject: user.username,
};
const token = jwt.sign(payload, JWT_SECRET, options);
if (storageType === "cookie") {
res.cookie("jwt_token", token, {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
});
res.json({ success: true, storageType: "cookie", user });
} else {
res.json({ success: true, storageType: "localStorage", token, user });
}
});
storageType 값 하나로 쿠키 발급과 JSON 응답을 분기하는 구조가 핵심이다. 로그인 로직 자체(비밀번호 검증, 토큰 서명)는 동일하고, 토큰을 어떻게 클라이언트에 넘겨줄지만 달라진다.
verifyToken)const verifyToken = (req, res, next) => {
let token = null;
// 1. 쿠키에서 토큰 확인
if (req.cookies && req.cookies.jwt_token) {
token = req.cookies.jwt_token;
}
// 2. Authorization 헤더에서 토큰 확인
else {
const authHeader = req.headers["authorization"];
if (authHeader) {
token = authHeader.split(" ")[1]; // "Bearer <token>" 중 토큰만 추출
}
}
if (!token) {
return res.status(401).json({ success: false, message: "인증 토큰이 제공되지 않았습니다" });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ success: false, message: "토큰이 만료되었습니다. 다시 로그인해주세요." });
}
if (err.name === "JsonWebTokenError") {
return res.status(401).json({ success: false, message: "유효하지 않은 토큰입니다" });
}
return res.status(401).json({ success: false, message: "토큰 검증 중 오류가 발생했습니다." });
}
};
쿠키를 먼저 확인하고, 없으면 헤더를 확인하는 순서로 짜여있어서 두 저장 방식을 하나의 미들웨어로 동시에 지원한다. jwt.verify()가 던지는 에러 이름(TokenExpiredError, JsonWebTokenError)으로 실패 원인을 구분해서 메시지를 다르게 내려주는 부분도 눈여겨볼 만하다.
api.js)const getAuthHeaders = () => {
const headers = { "Content-Type": "application/json" };
// localStorage 방식일 때만 헤더에 토큰을 직접 담는다
if (currentStorageType === "localStorage") {
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
// 쿠키 방식은 credentials: "include" 설정만으로 자동 전송됨
return headers;
};
const getFetchOptions = (method = "GET", body = null) => {
const options = {
method,
headers: getAuthHeaders(),
credentials: "include", // 쿠키 포함 요청 (CORS 시 필수)
};
if (body) {
options.body = typeof body === "string" ? body : JSON.stringify(body);
}
return options;
};
getProfile, getAdminData, refreshToken 등 인증이 필요한 API는 전부 이 getFetchOptions()를 통해 호출하도록 통일해서, 저장 방식이 바뀌어도 각 API 함수 코드는 건드릴 필요가 없게 설계되어 있다.
export const getProfile = async () => {
const response = await fetch(`${API_BASE_URL}/profile`, getFetchOptions("GET"));
const data = await response.json();
if (data.success) {
return { success: true, user: data.user };
} else {
if (response.status === 401) {
removeToken(); // 만료/무효 토큰은 즉시 폐기
}
return { success: false, message: data.message || "프로필 조회 실패" };
}
};
401 응답이 오면 클라이언트가 알아서 removeToken()을 호출해 로컬에 남은 만료 토큰을 정리하는 흐름도 실무적으로 유용했다.
jwt.decode(): 서명 검증 없이 디코딩만 함 (위조된 토큰도 그냥 읽힘)jwt.verify(): 서명 검증 + 디코딩 → 실제 인증 로직에는 반드시 이것만 사용// 교육용 디코딩 엔드포인트 - 실제 서비스에서는 절대 노출 금지
app.post("/api/decode-token", (req, res) => {
const decoded = jwt.decode(token, { complete: true });
// complete: true → Header, Payload, Signature 모두 반환
res.json({ success: true, decoded });
});
decode()의 동작이 다르다예전에 Python으로 JWT를 구현했을 때 PyJWT를 썼는데, 거기서는 decode()가 기본적으로 서명 검증까지 포함해서 동작한다. Node의 jsonwebtoken과 정반대라 헷갈리기 딱 좋은 부분이라 따로 정리한다.
Node.js jsonwebtoken | Python PyJWT | |
|---|---|---|
decode() | 검증 안 함 (그냥 파싱만) | 기본값으로 서명 검증함 (verify_signature=True) |
verify() | 검증 + 디코딩 | 별도 verify() 메서드 없음, decode()가 그 역할까지 담당 |
| 검증 없이 보고 싶을 때 | decode() 그대로 사용 | decode(token, options={"verify_signature": False})처럼 명시적으로 꺼야 함 |
import jwt
# PyJWT: decode()가 기본적으로 서명 검증까지 수행
decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) # 여기서 이미 검증됨
# 검증 없이 payload만 보고 싶다면 명시적으로 꺼야 함
unsafe_decoded = jwt.decode(token, options={"verify_signature": False})
정리: 같은 "decode"라는 이름이라도 라이브러리마다 검증 포함 여부가 다르니, 새 언어/라이브러리로 JWT를 다룰 때는 공식 문서에서 decode 함수가 서명 검증을 포함하는지 반드시 확인하는 습관을 들이는 게 중요하다. (jsonwebtoken은 이름 그대로 decode=안전하지 않음, verify=안전함으로 명확히 분리되어 있지만, PyJWT는 이름만 봐서는 구분이 안 되기 때문에 실수하기 쉽다.)
만료 전에 유효한 토큰으로 새 토큰을 재발급받는 흐름도 구현했다. verifyToken 미들웨어를 그대로 재사용해서 "현재 토큰이 유효한 사람만 갱신 가능"하도록 설계한 점이 인상적이었다.
app.post("/api/refresh", verifyToken, (req, res) => {
const { storageType = "localStorage" } = req.body;
const payload = {
userId: req.user.userId,
username: req.user.username,
role: req.user.role,
};
const options = {
expiresIn: "1h",
issuer: "jwt-education-server",
subject: req.user.username,
};
const newToken = jwt.sign(payload, JWT_SECRET, options);
if (storageType === "cookie") {
res.cookie("jwt_token", newToken, {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
});
res.json({ success: true, storageType: "cookie" });
} else {
res.json({ success: true, token: newToken, storageType: "localStorage" });
}
});
라우트 앞에 verifyToken을 붙이기만 하면 인증 체크가 자동으로 적용된다는 점에서, Express 미들웨어 체이닝이 왜 편한지 체감할 수 있었다.
credentials 설정은 클라이언트 + 서버 양쪽 다 필요하다