JWT 인증: 웹 개발자를 위한 완벽 가이드 (2026)

2026년 3월 게시 • 읽기 10분

JWT 인증이란?

JSON Web Token(JWT, "jot"으로 발음)은 디지털 서명된 토큰으로 당사자 간에 인증 및 인가 정보를 전송하기 위한 간결하고 자기 완결적인 표준입니다. 사용자가 로그인하면 서버가 해당 사용자에 대한 클레임을 포함하는 JWT를 발행합니다. 클라이언트는 토큰을 저장하고 이후 모든 요청에 함께 전송합니다. 서버는 토큰의 서명을 검증하여 진위를 확인합니다 — 데이터베이스 조회가 필요하지 않습니다.

JWT는 RFC 7519에 정의되어 있으며, REST API, 싱글 페이지 애플리케이션, 마이크로서비스 아키텍처에서 지배적인 인증 메커니즘입니다. 설계상 무상태(stateless)로 동작합니다. 즉, 서버가 세션 데이터를 저장할 필요가 없으므로 수평 확장이 간단합니다.

JWT 작동 원리

전체 JWT 인증 흐름은 다음과 같이 동작합니다:

  1. 로그인: 사용자가 서버의 로그인 엔드포인트에 자격 증명(아이디 + 비밀번호)을 제출합니다.
  2. 토큰 발행: 서버가 데이터베이스에서 자격 증명을 검증합니다. 성공하면 사용자 ID, 역할, 만료 타임스탬프를 포함하는 JWT에 서명하고 클라이언트에 토큰을 반환합니다.
  3. 토큰 저장: 클라이언트가 토큰을 저장합니다(메모리, localStorage, 또는 httpOnly 쿠키에 — 이에 대해서는 후술합니다).
  4. 인증된 요청: 이후 모든 API 요청에 클라이언트가 Authorization 헤더에 JWT를 포함합니다: Authorization: Bearer <token>.
  5. 검증: 서버가 헤더에서 토큰을 추출하고, 비밀 키를 사용하여 서명을 검증하고, 만료를 확인하고, 클레임을 읽습니다 — 모두 데이터베이스 쿼리 없이 수행됩니다.
  6. 응답: 토큰이 유효하면 서버가 요청을 처리합니다. 유효하지 않으면(만료, 변조, 누락) 401 Unauthorized를 반환합니다.

JWT 구조 설명

JWT는 점(.)으로 구분된 세 개의 Base64URL 인코딩된 부분으로 구성됩니다:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTYiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDkzMDAwMDAsImV4cCI6MTcwOTMwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

세 부분은 다음과 같습니다:

1. 헤더

{
  "alg": "HS256",   // 서명 알고리즘 (HS256, RS256, ES256)
  "typ": "JWT"      // 토큰 유형
}

2. 페이로드 (클레임)

{
  "userId": "123456",
  "email": "user@example.com",
  "role": "admin",
  "iat": 1709300000,   // 발행 시각 (Unix 타임스탬프)
  "exp": 1709303600    // 만료 (발행 후 1시간)
}

표준 클레임 이름: iss(발행자), sub(주체), aud(대상), exp(만료), iat(발행 시각), jti(철회용 JWT ID). 페이로드는 Base64URL로 인코딩되며, 암호화되지 않습니다 — 누구나 디코딩할 수 있습니다. 비밀번호, 신용카드 번호 또는 기타 민감한 데이터를 페이로드에 저장하지 마십시오.

3. 서명

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secretKey
)

서명은 토큰이 서버에서 발행되었으며 변조되지 않았음을 증명합니다. 헤더나 페이로드의 문자가 하나라도 변경되면 서명 검증이 실패합니다.

Node.js에서 JWT 구현

jsonwebtoken 패키지를 설치합니다:

npm install jsonwebtoken bcryptjs express

로그인 및 보호된 라우트의 전체 예제를 생성합니다:

// auth.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const express = require('express');
const router = express.Router();

const JWT_SECRET = process.env.JWT_SECRET; // 길고 랜덤한 문자열이어야 합니다
const JWT_EXPIRES_IN = '1h';               // 액세스 토큰은 단기간으로 유지합니다

// 로그인 라우트 — 인증 성공 시 JWT 발행
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // 1. 데이터베이스에서 사용자 조회
  const user = await User.findOne({ email });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 2. 비밀번호를 저장된 해시와 비교
  const validPassword = await bcrypt.compare(password, user.passwordHash);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 3. JWT 서명 — 민감하지 않은 클레임만 포함
  const token = jwt.sign(
    { userId: user._id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' }
  );

  res.json({ token, expiresIn: 3600 });
});

// 보호된 라우트에서 JWT를 검증하는 미들웨어
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

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

  try {
    // 알고리즘 혼동 공격을 방지하기 위해 명시적으로 알고리즘을 지정
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

// 보호된 라우트 예제
router.get('/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.userId, email: req.user.email });
});

module.exports = { router, authenticateToken };

React에서 JWT 저장

브라우저에서 JWT를 어디에 저장하느냐는 애플리케이션의 보안에 크게 영향을 미칩니다. 두 가지 주요 옵션은 localStorage와 httpOnly 쿠키입니다:

속성 localStorage httpOnly 쿠키
XSS 취약성 높음 — 모든 스크립트가 읽을 수 있음 낮음 — JavaScript에서 접근 불가
CSRF 취약성 없음 있음 — SameSite + CSRF 토큰으로 완화
자동 전송 아니오 — 헤더에 수동으로 추가해야 함 예 — 브라우저가 모든 요청에 자동 전송
JS에서 접근 가능 아니오 (httpOnly 플래그)
권장 대상 저위험 앱, 공개 데이터 사용자 데이터가 있는 프로덕션 앱

프로덕션 애플리케이션에서는 httpOnly 쿠키를 사용하십시오. 서버가 로그인 시 쿠키를 설정하고 브라우저가 JavaScript가 토큰 값을 다루지 않고도 자동으로 전송합니다.

localStorage를 사용하는 경우(예: 개발 중 간편함을 위해), 가능한 한 메모리에 토큰을 저장하고 XSS 위험을 완화하기 위해 강력한 Content Security Policy를 구현하십시오:

// React — localStorage에서 JWT 전송
async function fetchProfile() {
  const token = localStorage.getItem('authToken');

  const response = await fetch('/api/profile', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (response.status === 401) {
    // 토큰 만료 — 로그인으로 리디렉트하거나 갱신
    localStorage.removeItem('authToken');
    window.location.href = '/login';
  }

  return response.json();
}

리프레시 토큰 전략

단기 액세스 토큰(15분~1시간)은 토큰 탈취 위험을 최소화하지만, 사용자에게 매시간 로그인을 강제하는 것은 좋지 않은 UX입니다. 해결책은 리프레시 토큰 쌍입니다:

  • 액세스 토큰: 단기(15분 – 1시간), 모든 API 요청에 Authorization 헤더로 전송. 메모리 또는 localStorage에 저장.
  • 리프레시 토큰: 장기(7 – 30일), 새 액세스 토큰을 얻기 위해서만 사용. 항상 httpOnly, Secure, SameSite=Strict 쿠키에 저장. 사용할 때마다 로테이션해야 합니다.
// 서버: 로그인 시 두 토큰 모두 발행
router.post('/login', async (req, res) => {
  // ... 자격 증명 검증 ...

  const accessToken = jwt.sign(
    { userId: user._id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user._id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '30d' }
  );

  // 철회 지원을 위해 리프레시 토큰 해시를 데이터베이스에 저장
  await RefreshToken.create({ userId: user._id, token: hashToken(refreshToken) });

  // 리프레시 토큰을 httpOnly 쿠키로 전송
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30일 (밀리초)
  });

  res.json({ accessToken });
});

// 서버: 갱신 엔드포인트
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    // 선택사항: DB에 토큰 해시가 존재하는지 확인 (철회 가능)

    const newAccessToken = jwt.sign(
      { userId: decoded.userId },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: '15m' }
    );

    res.json({ accessToken: newAccessToken });
  } catch {
    res.status(403).json({ error: 'Invalid refresh token' });
  }
});

JWT 보안 모범 사례

  • 액세스 토큰을 단기간으로 유지: 15분에서 1시간. 탈취된 토큰은 만료 후 무용지물이 됩니다.
  • 강력한 시크릿 사용: JWT_SECRET은 최소 256비트의 랜덤 데이터여야 합니다. openssl rand -hex 32로 생성하십시오. 절대 하드코딩하지 마십시오.
  • 항상 알고리즘 명시: jwt.verify(){ algorithms: ['HS256'] }을 전달하십시오. 이것은 공격자가 헤더에 "alg": "none"을 설정하는 알고리즘 혼동(none) 공격을 방지합니다.
  • 프로덕션에서는 HTTPS만 사용: 전송 중인 JWT는 전송 계층만큼만 안전합니다. 프로덕션에서는 항상 TLS를 사용하십시오.
  • 리프레시 토큰은 httpOnly 쿠키에 저장: localStorage나 sessionStorage가 아닌 곳에 저장하십시오.
  • 토큰 철회 구현: 로그아웃 및 보안 이벤트를 위해 단기 차단 목록(Redis가 적합)을 유지하거나, 데이터베이스 추적과 함께 로테이팅 리프레시 토큰을 사용하십시오.
  • 모든 클레임 검증: 모든 요청에서 exp(만료), iss(발행자), aud(대상)를 확인하십시오. jsonwebtoken 라이브러리는 올바른 옵션을 전달하면 자동으로 이를 수행합니다.

일반적인 JWT 실수

고위험 앱에서 JWT를 localStorage에 저장

XSS 취약점이 하나라도 있으면 — 로드한 서드파티 스크립트라 하더라도 — localStorage를 읽고 모든 토큰을 탈취할 수 있습니다. 결제, 개인 데이터 또는 관리자 작업을 처리하는 애플리케이션에서는 httpOnly 쿠키만 사용하십시오.

토큰에 만료 없음

exp 클레임이 없는 토큰은 영원히 유효합니다. 유출된 토큰은 영구적인 백도어가 됩니다. 서명 시 항상 expiresIn을 설정하십시오.

알고리즘 none 공격

초기 JWT 라이브러리는 헤더에서 "alg": "none"을 허용하여, 서명되지 않은 토큰을 유효한 것으로 받아들였습니다. 허용되는 알고리즘을 제한하기 위해 항상 jwt.verify()algorithms 옵션을 전달하십시오:

// 항상 이렇게 하십시오 — "none" 알고리즘을 절대 허용하지 마십시오
jwt.verify(token, secret, { algorithms: ['HS256'] });

페이로드에 민감한 데이터

JWT 페이로드는 Base64URL로 인코딩되며 암호화되지 않습니다. 토큰을 가진 모든 당사자가 시크릿을 모르더라도 페이로드를 디코딩하여 읽을 수 있습니다. 비밀번호, 주민등록번호, 신용카드 번호 또는 내부 시스템 세부 정보를 JWT 클레임에 포함하지 마십시오.

액세스 토큰과 리프레시 토큰에 동일한 시크릿 사용

두 토큰 유형에 동일한 시크릿을 사용하면, 유출된 액세스 토큰이 잠재적으로 리프레시 토큰으로 사용될 수 있습니다. 각 토큰 유형에 별도의 환경 변수에 저장된 개별 시크릿을 사용하십시오.

JWT 토큰 디코딩

JWT의 클레임, 만료 또는 구조를 확인해야 하십니까? devbit.dev JWT 디코더에 붙여넣어 헤더와 페이로드를 즉시 디코딩하고, 만료 시간을 확인하고, 토큰 형식을 검증하십시오 — 모든 처리가 브라우저에서 이루어지며 데이터가 서버로 전송되지 않습니다.

모든 JWT를 즉시 디코딩 및 검사

JWT 토큰을 붙여넣어 헤더, 페이로드, 클레임을 디코딩하십시오. 만료, 알고리즘, 구조를 확인합니다. 100% 클라이언트 측 — 토큰이 브라우저를 벗어나지 않습니다.

JWT 디코더 열기 →

관련 개발자 도구