JWT 토큰 완벽 가이드: 구조, 보안, 그리고 모범 사례
· 12분 읽기
JSON Web Token(JWT)은 현대 웹 애플리케이션에서 상태 비저장 인증의 사실상 표준이 되었습니다. REST API, 마이크로서비스 아키텍처, 또는 단일 페이지 애플리케이션을 구축하든, JWT의 작동 방식을 이해하는 것은 안전하고 확장 가능한 인증을 구현하는 데 필수적입니다.
이 종합 가이드는 JWT의 내부 구조부터 프로덕션 수준의 보안 관행까지 알아야 할 모든 것을 다룹니다. 이 글을 마치면 JWT를 사용하는 방법뿐만 아니라 언제 사용해야 하는지, 그리고 보안 취약점으로 이어지는 일반적인 함정을 피하는 방법을 이해하게 될 것입니다.
목차
JWT란 무엇인가?
JSON Web Token(JWT)은 두 당사자 간에 전송될 클레임을 나타내는 간결하고 URL 안전한 수단입니다. JWT의 클레임은 JSON Web Signature(JWS)를 사용하여 디지털 서명된 JSON 객체로 인코딩됩니다.
JWT를 사용자 정보를 담는 변조 방지 컨테이너로 생각하세요. 사용자가 로그인하면 서버는 사용자 ID, 역할 및 기타 관련 데이터를 포함하는 JWT를 생성합니다. 이 토큰은 클라이언트로 전송되며, 클라이언트는 이후의 모든 요청에 이를 포함합니다. 서버는 데이터베이스를 조회하지 않고도 토큰의 진위를 확인할 수 있어, JWT는 상태 비저장 확장 가능한 아키텍처에 이상적입니다.
JWT는 RFC 7519에 정의되어 있으며 프로그래밍 언어와 프레임워크 전반에 걸쳐 광범위하게 지원됩니다. 특히 다음과 같은 경우에 인기가 있습니다:
- Single Sign-On(SSO) 시스템에서 사용자가 한 번 인증하고 여러 서비스에 액세스하는 경우
- 마이크로서비스 아키텍처에서 서비스가 공유 세션 저장소 없이 사용자 신원을 확인해야 하는 경우
- 모바일 애플리케이션에서 서버 측 세션을 유지하는 것이 비실용적인 경우
- API 인증에서 상태 비저장 검증이 확장성을 향상시키는 경우
빠른 팁: JWT 디코더를 사용하여 모든 JWT 토큰을 즉시 검사하고 디버그하세요. 완전히 클라이언트 측이므로 토큰이 브라우저를 벗어나지 않습니다.
JWT 구조: 헤더, 페이로드, 그리고 서명
JWT는 점(.)으로 구분된 세 개의 Base64URL 인코딩된 부분으로 구성됩니다. 실제 JWT는 다음과 같습니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
이를 분해하면:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← 헤더
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ ← 페이로드
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← 서명
| 부분 | 포함 내용 | 예제 (디코딩됨) |
|---|---|---|
| 헤더 | 알고리즘 + 토큰 타입 | {"alg": "HS256", "typ": "JWT"} |
| 페이로드 | 클레임 (사용자 데이터) | {"sub": "1234567890", "name": "John Doe", "iat": 1516239022} |
| 서명 | 검증 해시 | HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) |
헤더
헤더는 일반적으로 두 부분으로 구성됩니다: 토큰 타입(typ)과 서명 알고리즘(alg). 일반적인 알고리즘은 다음과 같습니다:
- HS256 (HMAC with SHA-256): 공유 비밀을 사용하는 대칭 알고리즘
- RS256 (RSA with SHA-256): 공개/개인 키 쌍을 사용하는 비대칭 알고리즘
- ES256 (ECDSA with SHA-256): 더 작은 키 크기를 가진 비대칭 알고리즘
페이로드
페이로드는 클레임(일반적으로 사용자에 대한 설명)과 추가 메타데이터를 포함합니다. 클레임은 암호화되지 않고 인코딩만 되므로 비밀번호와 같은 민감한 정보를 JWT 페이로드에 넣지 마세요.
서명
서명은 토큰이 변조되지 않았음을 보장합니다. 인코딩된 헤더, 인코딩된 페이로드, 비밀 키를 가져와 헤더에 지정된 알고리즘을 적용하여 생성됩니다. 누군가 헤더나 페이로드를 수정하면 서명 검증이 실패합니다.
프로 팁: JWT 생성기 도구를 사용하여 사용자 정의 클레임으로 테스트 JWT를 생성하세요. 개발 및 테스트 시나리오에 완벽합니다.
표준 클레임 (RFC 7519)
RFC 7519는 서로 다른 JWT 구현 간의 상호 운용성을 제공하는 여러 표준 클레임을 정의합니다. 이러한 클레임은 선택 사항이지만 올바르게 사용하면 보안과 호환성이 향상됩니다.
| 클레임 | 이름 | 목적 | 예제 |
|---|---|---|---|
iss |
발급자 | 토큰을 생성한 주체 식별 | "https://auth.example.com" |
sub |
주체 | 토큰이 누구에 관한 것인지 식별 (보통 사용자 ID) | "user_12345" |
aud |
대상 | 토큰의 의도된 수신자 식별 | "https://api.example.com" |
exp |
만료 | 토큰이 만료되는 시간 (Unix 타임스탬프) | 1735689600 |
nbf |
이전 불가 | 이 시간 이전에는 토큰이 유효하지 않음 | 1735603200 |
iat |
발급 시간 | 토큰이 생성된 시간 | 1735603200 |
jti |
JWT ID | 토큰의 고유 식별자 (폐기에 유용) | "a1b2c3d4-e5f6" |
사용자 정의 클레임
표준 클레임 외에도 애플리케이션에 특정한 사용자 정의 클레임을 추가할 수 있습니다. 일반적인 예는 다음과 같습니다:
role또는roles: 사용자 권한 (예:"admin","user")email: 사용자의 이메일 주소name: 사용자의 표시 이름scope: API 액세스를 위한 OAuth 2.0 범위tenant_id: 다중 테넌트 애플리케이션 식별자
페이로드를 작게 유지하세요. 모든 바이트가 네트워크 오버헤드를 증가시킵니다. 권한 부여 결정에 필요한 클레임만 포함하세요. 추가 사용자 데이터가 필요한 경우 sub 클레임을 조회 키로 사용하여 데이터베이스에서 가져오세요.
JWT 인증 작동 방식
전체 인증 흐름을 이해하면 JWT를 올바르고 안전하게 구현하는 데 도움이 됩니다. 일반적인 순서는 다음과 같습니다:
- 사용자 로그인: 사용자가 인증 엔드포인트에 자격 증명(사용자 이름 + 비밀번호)을 전송
- 자격 증명 검증: 서버가 사용자 데이터베이스에 대해 자격 증명을 검증
- 토큰 생성: 서버가 사용자 클레임을 포함하는 JWT를 생성하고 비밀 키로 서명
- 토큰 전달: 서버가 JWT를 클라이언트에 반환 (일반적으로 응답 본문 또는 httpOnly 쿠키로)
- 토큰 저장: 클라이언트가 JWT를 안전하게 저장 (저장 옵션 섹션에서 자세히 설명)
- 인증된 요청: 클라이언트가 후속 요청에 대해
Authorization헤더에 JWT를 포함 - 토큰 검증: 서버가 서명을 확인하고 만료를 확인 - 데이터베이스 조회 불필요
- 액세스 허용: 유효한 경우 서버가 토큰의 클레임을 사용하여 요청을 처리
시각적 표현은 다음과 같습니다:
┌────────┐ ┌────────┐
│ 클라이언트 │ │ 서버 │
└───┬────┘ └───┬────┘
│ │
│ POST /login {username, password} │
│──────────────────────────────────────────>│
│ │
│ [자격 증명 검증]
│ [JWT 생성]
│ │
│ 200 OK {token: "eyJhbG..."} │
│<──────────────────────────────────────────│
│ │
[토큰 저장] │
│ │
│ GET /api/profile │
│ Authorization: Bearer eyJhbG... │
│──────────────────────────────────────────>│
│ │
│ [서명 검증]
│ [만료 확인]
│ [클레임 추출]
│ │
│ 200 OK {user data} │
│<──────────────────────────────────────────│
│ │
프로 팁: JWT의 장점은 7단계(토큰 검증)가 데이터베이스 쿼리를 필요로 하지 않는다는 것입니다. 서버는 토큰에서 직접 진위를 확인하고 사용자 정보를 추출할 수 있어 API의 확장성이 매우 높아집니다.
구현 예제
인기 있는 프로그래밍 언어와 프레임워크에서의 실용적인 구현을 살펴보겠습니다.
jsonwebtoken을 사용한 Node.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// 로그인 엔드포인트
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 데이터베이스에서 사용자 찾기
const user = await User.findOne({ username });
if (!user) {
return res.status(401).json({ error: '잘못된 자격 증명' });
}
// 비밀번호 검증
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: '잘못된 자격 증명' });
}
// JWT 생성
const token = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET,
{
expiresIn: '15m',
issuer: 'https://api.example.com',
audience: 'https://example.com'
}
);
res.json({ token });
});
// 보호된 라우트 미들웨어
const 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: '토큰이 제공되지 않음' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: '유효하지 않거나 만료된 토큰' });
}
};
// 미들웨어 사용
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.sub, email: req.user.email });
});
PyJWT를 사용한 Python
import jwt
import datetime
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = 'your-secret-key'
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
# 자격 증명 검증 (단순화됨)
user = verify_credentials(username, password)
if not user:
return jsonify({'error': '잘못된 자격 증명'}), 401
# JWT 생성
payload = {
'sub': user['id'],
'email': user['email'],
'role': user['role'],
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
'iat': datetime.datetime.utcnow()
}
token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
return jsonify({'token': token})
def token_required(f):
def decorator(*args, **kwargs):
token = request.headers.get('Authorization')
if not token:
return jsonify({'error': '토큰이 제공되지 않음'}), 401
try:
# 'Bearer ' 접두사 제거
token = token.split(' ')[1]
decoded = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.user = decoded
except jwt.ExpiredSignatureError:
return jsonify({'error': '토큰 만료'}), 403
except jwt.InvalidTokenError:
return jsonify({'error': '유효하지 않은 토큰'}), 403
return f(*args, **kwargs)
return decorator
@app.route('/api/profile')
@token_required
def profile():
return jsonify({'userId': request.user['sub']})
golang-jwt를 사용한 Go
package main
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func generateToken(userID, email, role string) (string, error) {
claims := Claims{
UserID: userID,
Email: emai