[TIL] JWT 토큰

지현·2026년 7월 12일

React + Express로 JWT 인증 시스템을 직접 구현해보면서 배운 내용을 정리한다.

0. 사용한 패키지

패키지역할
jsonwebtokenJWT 토큰 생성(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 라이브러리 없음

1. JWT란?

JWT(JSON Web Token)는 두 당사자 간 정보를 안전하게 전송하기 위한 개방형 표준(RFC 7519)이다.

구조: Header.Payload.Signature

구성요소내용특징
Header토큰 타입 + 알고리즘 ({"alg":"HS256","typ":"JWT"})Base64 인코딩
Payload실제 데이터 (userId, username, exp 등)Base64 인코딩, 암호화 아님
SignatureHeader + Payload + Secret Key로 만든 해시값Secret 없이는 위조 불가

⚠️ 가장 중요한 포인트: Base64 인코딩 ≠ 암호화. 누구나 디코딩해서 payload를 볼 수 있다. 따라서 JWT payload에는 비밀번호 같은 민감 정보를 절대 넣으면 안 된다.

JWT의 핵심 장점: Stateless(무상태)
서버가 세션을 별도 저장하지 않고 토큰 자체에 정보가 담겨있어서, 서버를 여러 대로 확장해도 세션 동기화 문제가 없다.

localStorage 방식

  • 전송: Authorization: Bearer <token> 헤더에 직접 담아서 전송
  • 장점: 구현이 간단, 클라이언트에서 자유롭게 접근 가능
  • 단점: XSS 공격에 취약 — 악성 스크립트가 삽입되면 localStorage.getItem()으로 토큰을 그대로 탈취 가능
  • 전송: credentials: "include" 옵션으로 브라우저가 자동 전송
  • 장점: httpOnly: true 설정 시 JavaScript로 절대 접근 불가 → XSS로부터 토큰 자체는 안전
  • 단점: CSRF(사이트 간 요청 위조) 공격에는 별도 방어가 필요 (sameSite 옵션으로 완화)

쿠키 옵션 정리

httpOnly: true   // JS 접근 차단 (XSS 방어의 핵심)
secure: false    // 개발환경(false) / 프로덕션(true, HTTPS 필수)
sameSite: "lax"  // CSRF 방어 (strict/lax/none)
maxAge: 3600000  // 만료시간(ms), 토큰 만료시간과 맞춤
path: "/"        // 쿠키 사용 가능 경로

결론: XSS는 HttpOnly 쿠키로 막고, CSRF는 sameSite + 서버 측 검증으로 막는 게 실무 정석 조합.

4. 로그인 ~ 인증 검증 흐름

(1) 서버: 로그인 시 토큰 발급 (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 응답을 분기하는 구조가 핵심이다. 로그인 로직 자체(비밀번호 검증, 토큰 서명)는 동일하고, 토큰을 어떻게 클라이언트에 넘겨줄지만 달라진다.

(2) 서버: 토큰 검증 미들웨어 (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)으로 실패 원인을 구분해서 메시지를 다르게 내려주는 부분도 눈여겨볼 만하다.

(3) 클라이언트: 요청마다 인증 정보 자동 첨부 (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() vs jwt.verify()

  • 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 jsonwebtokenPython 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는 이름만 봐서는 구분이 안 되기 때문에 실수하기 쉽다.)

5. 토큰 갱신(Refresh)

만료 전에 유효한 토큰으로 새 토큰을 재발급받는 흐름도 구현했다. 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 미들웨어 체이닝이 왜 편한지 체감할 수 있었다.

6. 마무리

  • Base64 ≠ 암호화
    "토큰이 암호화됐으니 안전하겠지"라고 착각하기 쉬운데, payload는 jwt.io 같은 곳에서 누구나 디코딩 가능하다.
  • XSS는 저장 위치(HttpOnly), CSRF는 전송 방식(sameSite/CSRF 토큰)으로 막는다
  • CORS credentials 설정은 클라이언트 + 서버 양쪽 다 필요하다

0개의 댓글