← 블로그로 돌아가기

JSON Web Tokens (JWT) 완벽 가이드: 인증의 모든 것

JWT의 구조, 작동 원리, 보안 모범 사례를 배워보세요. 실용적인 예제와 함께 인증 시스템 구축 방법을 완벽 정리.

Big JSON Team15분 소요security
B

Big JSON Team

Technical Writer

Expert in JSON data manipulation, API development, and web technologies. Passionate about creating tools that make developers' lives easier.

15 분 읽기

# JSON Web Tokens (JWT) 완벽 가이드

JWT는 현대 웹 애플리케이션에서 가장 널리 사용되는 인증 방식입니다. 구조부터 보안까지 모든 것을 배워보세요.

JWT란?

JWT (JSON Web Token)는 당사자 간에 정보를 안전하게 전송하기 위한 컴팩트하고 자체 포함된 방식입니다.

특징

  • 자체 포함: 필요한 모든 정보를 포함
  • 디지털 서명: 변조 방지
  • URL 안전: Base64 URL 인코딩
  • 무상태: 서버에 세션 저장 불필요

사용 사례

  • 인증: 사용자 로그인 후 요청마다 토큰 전송
  • 정보 교환: 안전한 정보 전달
  • Single Sign-On: 여러 서비스 간 인증
  • API 권한: 마이크로서비스 간 인증
  • JWT 구조

    JWT는 세 부분으로 구성:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1pa2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    구조:
    Header.Payload.Signature

    1. Header (헤더)

    토큰 타입과 알고리즘:

    {
    

    "alg": "HS256",

    "typ": "JWT"

    }

    인코딩:
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

    2. Payload (페이로드)

    클레임 (정보):

    {
    

    "sub": "1234567890",

    "name": "홍길동",

    "iat": 1516239022,

    "exp": 1516242622

    }

    인코딩:
    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1pa2UiLCJpYXQiOjE1MTYyMzkwMjJ9

    3. Signature (서명)

    변조 방지 서명:

    HMACSHA256(
    

    base64UrlEncode(header) + "." +

    base64UrlEncode(payload),

    secret

    )

    클레임 (Claims)

    Registered Claims (등록된 클레임)

    표준 클레임:

    • iss (Issuer): 발급자
    • sub (Subject): 주제 (사용자 ID)
    • aud (Audience): 대상
    • exp (Expiration): 만료 시간
    • nbf (Not Before): 유효 시작 시간
    • iat (Issued At): 발급 시간
    • jti (JWT ID): 고유 식별자

    Public Claims (공개 클레임)

    충돌 방지를 위해 URI 형식:

    {
    

    "https://example.com/is_admin": true

    }

    Private Claims (비공개 클레임)

    당사자 간 합의된 클레임:

    {
    

    "name": "홍길동",

    "role": "admin",

    "department": "개발팀"

    }

    Node.js 구현

    jsonwebtoken 라이브러리

    npm install jsonwebtoken

    토큰 생성

    const jwt = require('jsonwebtoken');
    
    

    const secret = 'your-secret-key';

    // 토큰 생성

    const token = jwt.sign(

    {

    sub: '12345',

    name: '홍길동',

    role: 'admin'

    },

    secret,

    {

    expiresIn: '1h',

    issuer: 'myapp'

    }

    );

    console.log('토큰:', token);

    토큰 검증

    const jwt = require('jsonwebtoken');
    
    

    const secret = 'your-secret-key';

    try {

    const decoded = jwt.verify(token, secret);

    console.log('유효한 토큰:', decoded);

    /

    {

    sub: '12345',

    name: '홍길동',

    role: 'admin',

    iat: 1642348800,

    exp: 1642352400,

    iss: 'myapp'

    }

    /

    } catch (error) {

    if (error.name === 'TokenExpiredError') {

    console.log('토큰 만료');

    } else if (error.name === 'JsonWebTokenError') {

    console.log('유효하지 않은 토큰');

    } else {

    console.log('검증 오류:', error.message);

    }

    }

    디코딩 (검증 없이)

    const jwt = require('jsonwebtoken');
    
    

    // 서명 검증 없이 디코딩

    const decoded = jwt.decode(token, { complete: true });

    console.log('헤더:', decoded.header);

    console.log('페이로드:', decoded.payload);

    Express 미들웨어

    인증 미들웨어

    const jwt = require('jsonwebtoken');
    
    

    function authenticateToken(req, res, next) {

    const authHeader = req.headers['authorization'];

    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {

    return res.status(401).json({ error: '토큰이 없습니다' });

    }

    jwt.verify(token, process.env.JWT_SECRET, (err, user) => {

    if (err) {

    return res.status(403).json({ error: '유효하지 않은 토큰' });

    }

    req.user = user;

    next();

    });

    }

    // 사용

    app.get('/api/protected', authenticateToken, (req, res) => {

    res.json({

    message: '보호된 라우트',

    user: req.user

    });

    });

    역할 기반 접근 제어

    function requireRole(...roles) {
    

    return (req, res, next) => {

    if (!req.user) {

    return res.status(401).json({ error: '인증 필요' });

    }

    if (!roles.includes(req.user.role)) {

    return res.status(403).json({ error: '권한 없음' });

    }

    next();

    };

    }

    // 사용

    app.delete(

    '/api/users/:id',

    authenticateToken,

    requireRole('admin', 'moderator'),

    (req, res) => {

    // 관리자와 모더레이터만 접근 가능

    }

    );

    로그인 구현

    완전한 로그인 시스템

    const express = require('express');
    

    const jwt = require('jsonwebtoken');

    const bcrypt = require('bcrypt');

    const app = express();

    app.use(express.json());

    const JWT_SECRET = process.env.JWT_SECRET;

    const REFRESH_SECRET = process.env.REFRESH_SECRET;

    // 사용자 DB (예시)

    const users = [

    {

    id: 1,

    username: 'hong',

    password: '$2b$10$...', // 해시된 비밀번호

    role: 'admin'

    }

    ];

    // 로그인

    app.post('/api/auth/login', async (req, res) => {

    const { username, password } = req.body;

    // 사용자 찾기

    const user = users.find(u => u.username === username);

    if (!user) {

    return res.status(401).json({ error: '사용자를 찾을 수 없습니다' });

    }

    // 비밀번호 검증

    const valid = await bcrypt.compare(password, user.password);

    if (!valid) {

    return res.status(401).json({ error: '비밀번호가 틀립니다' });

    }

    // Access Token 생성

    const accessToken = jwt.sign(

    {

    sub: user.id,

    username: user.username,

    role: user.role

    },

    JWT_SECRET,

    { expiresIn: '15m' }

    );

    // Refresh Token 생성

    const refreshToken = jwt.sign(

    { sub: user.id },

    REFRESH_SECRET,

    { expiresIn: '7d' }

    );

    res.json({

    accessToken,

    refreshToken,

    user: {

    id: user.id,

    username: user.username,

    role: user.role

    }

    });

    });

    // 토큰 갱신

    app.post('/api/auth/refresh', (req, res) => {

    const { refreshToken } = req.body;

    if (!refreshToken) {

    return res.status(401).json({ error: 'Refresh Token 필요' });

    }

    try {

    const payload = jwt.verify(refreshToken, REFRESH_SECRET);

    // 새 Access Token 생성

    const accessToken = jwt.sign(

    {

    sub: payload.sub,

    username: payload.username,

    role: payload.role

    },

    JWT_SECRET,

    { expiresIn: '15m' }

    );

    res.json({ accessToken });

    } catch (error) {

    res.status(403).json({ error: '유효하지 않은 Refresh Token' });

    }

    });

    // 로그아웃 (클라이언트에서 토큰 삭제)

    app.post('/api/auth/logout', (req, res) => {

    // Refresh Token을 블랙리스트에 추가 (선택)

    res.json({ message: '로그아웃 성공' });

    });

    클라이언트 사용

    Fetch API

    // 로그인
    

    async function login(username, password) {

    const response = await fetch('/api/auth/login', {

    method: 'POST',

    headers: {

    'Content-Type': 'application/json'

    },

    body: JSON.stringify({ username, password })

    });

    const data = await response.json();

    // 토큰 저장

    localStorage.setItem('accessToken', data.accessToken);

    localStorage.setItem('refreshToken', data.refreshToken);

    return data;

    }

    // API 요청

    async function fetchProtectedData() {

    const token = localStorage.getItem('accessToken');

    const response = await fetch('/api/protected', {

    headers: {

    'Authorization': Bearer ${token}

    }

    });

    if (response.status === 401) {

    // 토큰 만료, 갱신 시도

    await refreshAccessToken();

    return fetchProtectedData(); // 재시도

    }

    return response.json();

    }

    // 토큰 갱신

    async function refreshAccessToken() {

    const refreshToken = localStorage.getItem('refreshToken');

    const response = await fetch('/api/auth/refresh', {

    method: 'POST',

    headers: {

    'Content-Type': 'application/json'

    },

    body: JSON.stringify({ refreshToken })

    });

    const data = await response.json();

    localStorage.setItem('accessToken', data.accessToken);

    }

    Axios Interceptor

    import axios from 'axios';
    
    

    const api = axios.create({

    baseURL: '/api'

    });

    // 요청 인터셉터

    api.interceptors.request.use(

    (config) => {

    const token = localStorage.getItem('accessToken');

    if (token) {

    config.headers.Authorization = Bearer ${token};

    }

    return config;

    },

    (error) => Promise.reject(error)

    );

    // 응답 인터셉터

    api.interceptors.response.use(

    (response) => response,

    async (error) => {

    const originalRequest = error.config;

    if (error.response.status === 401 && !originalRequest._retry) {

    originalRequest._retry = true;

    try {

    const refreshToken = localStorage.getItem('refreshToken');

    const response = await axios.post('/api/auth/refresh', {

    refreshToken

    });

    localStorage.setItem('accessToken', response.data.accessToken);

    originalRequest.headers.Authorization =

    Bearer ${response.data.accessToken};

    return api(originalRequest);

    } catch (err) {

    // Refresh 실패, 로그아웃

    localStorage.clear();

    window.location.href = '/login';

    return Promise.reject(err);

    }

    }

    return Promise.reject(error);

    }

    );

    export default api;

    Python 구현

    PyJWT

    pip install PyJWT
    import jwt
    

    import datetime

    SECRET = 'your-secret-key'

    # 토큰 생성

    payload = {

    'sub': '12345',

    'name': '홍길동',

    'role': 'admin',

    'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1),

    'iat': datetime.datetime.utcnow()

    }

    token = jwt.encode(payload, SECRET, algorithm='HS256')

    print(f'토큰: {token}')

    # 토큰 검증

    try:

    decoded = jwt.decode(token, SECRET, algorithms=['HS256'])

    print(f'유효한 토큰: {decoded}')

    except jwt.ExpiredSignatureError:

    print('토큰 만료')

    except jwt.InvalidTokenError:

    print('유효하지 않은 토큰')

    Flask

    from flask import Flask, request, jsonify
    

    import jwt

    import datetime

    from functools import wraps

    app = Flask(__name__)

    SECRET = 'your-secret-key'

    def token_required(f):

    @wraps(f)

    def decorated(args, kwargs):

    token = request.headers.get('Authorization')

    if not token:

    return jsonify({'error': '토큰이 없습니다'}), 401

    try:

    token = token.split(' ')[1] # Bearer 제거

    data = jwt.decode(token, SECRET, algorithms=['HS256'])

    request.user = data

    except jwt.ExpiredSignatureError:

    return jsonify({'error': '토큰 만료'}), 401

    except jwt.InvalidTokenError:

    return jsonify({'error': '유효하지 않은 토큰'}), 401

    return f(args, kwargs)

    return decorated

    @app.route('/api/login', methods=['POST'])

    def login():

    data = request.get_json()

    # 사용자 검증 (예시)

    if data['username'] == 'hong' and data['password'] == 'password':

    token = jwt.encode({

    'sub': '12345',

    'username': data['username'],

    'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1)

    }, SECRET, algorithm='HS256')

    return jsonify({'token': token})

    return jsonify({'error': '인증 실패'}), 401

    @app.route('/api/protected', methods=['GET'])

    @token_required

    def protected():

    return jsonify({

    'message': '보호된 라우트',

    'user': request.user

    })

    보안 모범 사례

    1. 비밀키 관리

    // ❌ 나쁜 예
    

    const secret = 'mysecret';

    // ✅ 좋은 예

    const secret = process.env.JWT_SECRET;

    // 강력한 비밀키 생성

    const crypto = require('crypto');

    const secret = crypto.randomBytes(64).toString('hex');

    2. 짧은 만료 시간

    // Access Token: 15분
    

    const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });

    // Refresh Token: 7일

    const refreshToken = jwt.sign(payload, refreshSecret, { expiresIn: '7d' });

    3. HTTPS 사용

    항상 HTTPS를 통해 토큰 전송

    4. 민감한 정보 제외

    // ❌ 나쁜 예
    

    const token = jwt.sign({

    password: 'secret123',

    creditCard: '1234-5678'

    }, secret);

    // ✅ 좋은 예

    const token = jwt.sign({

    sub: userId,

    role: 'user'

    }, secret);

    5. 토큰 블랙리스트

    const blacklist = new Set();
    
    

    function logout(req, res) {

    const token = req.headers.authorization.split(' ')[1];

    blacklist.add(token);

    res.json({ message: '로그아웃 성공' });

    }

    function authenticateToken(req, res, next) {

    const token = req.headers.authorization.split(' ')[1];

    if (blacklist.has(token)) {

    return res.status(401).json({ error: '무효화된 토큰' });

    }

    // 검증 계속...

    }

    6. 알고리즘 지정

    // ✅ 알고리즘 명시
    

    jwt.verify(token, secret, { algorithms: ['HS256'] });

    // ❌ 알고리즘 미지정 (취약)

    jwt.verify(token, secret);

    RS256 (비대칭 암호화)

    키 생성

    # 개인키
    

    openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

    # 공개키

    openssl rsa -pubout -in private_key.pem -out public_key.pem

    사용

    const fs = require('fs');
    

    const jwt = require('jsonwebtoken');

    const privateKey = fs.readFileSync('private_key.pem');

    const publicKey = fs.readFileSync('public_key.pem');

    // 토큰 생성 (개인키)

    const token = jwt.sign({ sub: '12345' }, privateKey, {

    algorithm: 'RS256',

    expiresIn: '1h'

    });

    // 토큰 검증 (공개키)

    const decoded = jwt.verify(token, publicKey, {

    algorithms: ['RS256']

    });

    일반적인 실수

    1. 토큰을 LocalStorage에 저장

    문제: XSS 공격에 취약

    해결:

    // ✅ HttpOnly 쿠키 사용
    

    res.cookie('token', token, {

    httpOnly: true,

    secure: true,

    sameSite: 'strict',

    maxAge: 3600000

    });

    2. 토큰 만료 확인 안함

    // ✅ 항상 검증
    

    try {

    const decoded = jwt.verify(token, secret);

    } catch (error) {

    // 만료 처리

    }

    3. 약한 비밀키

    // ❌ 약한 키
    

    const secret = 'secret';

    // ✅ 강한 키

    const secret = crypto.randomBytes(64).toString('hex');

    결론

    JWT는 강력하고 유연한 인증 메커니즘입니다. 올바르게 구현하면 안전하고 확장 가능한 인증 시스템을 구축할 수 있습니다.

    핵심 요약:**

    • ✅ 짧은 만료 시간 설정
    • ✅ Refresh Token 사용
    • ✅ HTTPS 필수
    • ✅ 민감한 정보 제외
    • ✅ 강력한 비밀키 사용

    지금 바로 JSON Simplify에서 JWT를 디코딩해보세요!

    Share:

    관련 글

    Read in English