JWT 인증: JSON 웹 토큰 작동 방식
· 12분 읽기
목차
JSON 웹 토큰이란?
JSON 웹 토큰(JWT)은 두 당사자 간에 클레임을 나타내는 컴팩트하고 URL 안전한 방법입니다. JWT는 현대 웹 애플리케이션, API 및 마이크로서비스 아키텍처에서 인증 및 권한 부여의 사실상 표준이 되었습니다.
사용자가 로그인하면 서버는 사용자 정보가 포함된 JWT를 생성하여 클라이언트에 전송합니다. 그런 다음 클라이언트는 후속 요청에 이 토큰을 포함하여 신원을 증명합니다. 매번 발급 기관에 다시 확인하지 않고도 검증할 수 있는 자격 증명이 포함된 디지털 여권과 같다고 생각하면 됩니다.
서버가 메모리나 데이터베이스에 세션 상태를 유지하는 기존 세션 기반 인증과 달리 JWT는 무상태입니다. 토큰을 검증하는 데 필요한 모든 정보가 토큰 자체에 포함되어 있습니다. 이러한 아키텍처 결정은 여러 가지 이점을 제공합니다:
- 확장성: 로드 밸런싱 환경에서 세션 저장소나 스티키 세션이 필요 없음
- 크로스 도메인 인증: JWT는 다양한 도메인과 서비스에서 원활하게 작동
- 모바일 친화적: 백엔드 API로 인증해야 하는 모바일 앱에 완벽
- 마이크로서비스: 각 서비스가 중앙 세션 저장소 없이 독립적으로 토큰을 검증 가능
- 성능: 인증된 모든 요청에 대한 데이터베이스 조회 제거
코드를 작성하지 않고도 JWT 디코더 도구를 사용하여 JWT를 검사하고 디코딩하여 내용을 확인할 수 있습니다. 이는 인증 문제를 디버깅하거나 토큰에 포함된 데이터를 이해할 때 특히 유용합니다.
프로 팁: JWT는 기본적으로 암호화되지 않고 서명됩니다. 누구나 JWT를 디코딩하고 내용을 읽을 수 있습니다. 비밀번호, 신용카드 번호 또는 주민등록번호와 같은 민감한 정보를 JWT 페이로드에 절대 저장하지 마세요.
JWT 구조 설명
JWT는 점(.)으로 구분된 세 가지 고유한 부분으로 구성됩니다: 헤더, 페이로드 및 서명. 실제 JWT는 다음과 같습니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
각 구성 요소가 어떻게 함께 작동하여 안전하고 검증 가능한 토큰을 생성하는지 이해하기 위해 분석해 보겠습니다.
1. 헤더
헤더는 일반적으로 토큰 처리 방법을 정의하는 두 가지 중요한 필드를 포함합니다:
{
"alg": "HS256",
"typ": "JWT"
}
alg(알고리즘): 토큰 서명에 사용되는 암호화 알고리즘 지정typ(타입): JWT 토큰임을 선언
이 JSON 객체는 Base64Url로 인코딩되어 JWT의 첫 번째 부분을 형성합니다. 인코딩은 HTTP 헤더에서 전송하기 위해 URL 안전하고 컴팩트하게 만듭니다.
2. 페이로드
페이로드에는 클레임 — 사용자 및 추가 메타데이터에 대한 설명이 포함됩니다. 전송하려는 실제 데이터를 저장하는 곳입니다:
{
"sub": "1234567890",
"name": "John Doe",
"email": "[email protected]",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
클레임은 세 가지 범주로 나뉩니다:
등록된 클레임 (표준화되고 권장됨):
sub(주체): 사용자의 고유 식별자iat(발급 시간): 토큰이 생성된 타임스탬프exp(만료): 토큰이 만료되는 타임스탬프iss(발급자): 토큰을 발급한 사람 식별aud(대상): 토큰의 대상 식별nbf(이전 불가): 이 타임스탬프 이전에는 토큰이 유효하지 않음jti(JWT ID): 토큰 자체의 고유 식별자
공개 클레임은 IANA JSON 웹 토큰 레지스트리에 정의되거나 충돌 방지 이름(예: URL)을 사용해야 하는 사용자 정의 클레임입니다.
비공개 클레임은 role, permissions 또는 department와 같이 당사자 간에 합의된 사용자 정의 클레임입니다.
3. 서명
서명은 JWT를 안전하고 변조 방지하게 만드는 요소입니다. 인코딩된 헤더, 인코딩된 페이로드, 비밀 키를 가져와 헤더에 지정된 알고리즘을 적용하여 생성됩니다:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
서명은 두 가지 중요한 목적을 제공합니다:
- 무결성 검증: 토큰이 발급된 이후 수정되지 않았음을 보장
- 인증: 비밀 키에 액세스할 수 있는 사람이 토큰을 생성했음을 증명
누군가 헤더나 페이로드를 수정하려고 하면 서명 검증이 실패하고 토큰이 거부됩니다.
JWT 인증 작동 방식
JWT를 올바르게 구현하려면 전체 인증 흐름을 이해하는 것이 필수적입니다. 단계별 프로세스는 다음과 같습니다:
1단계: 사용자 로그인
사용자가 인증 엔드포인트에 자격 증명(사용자 이름 및 비밀번호)을 제출합니다. 서버는 데이터베이스에 대해 이러한 자격 증명을 검증합니다.
2단계: 토큰 생성
자격 증명이 유효하면 서버는 사용자 정보와 클레임이 포함된 JWT를 생성합니다. 서버는 비밀 키(대칭 알고리즘의 경우) 또는 개인 키(비대칭 알고리즘의 경우)를 사용하여 토큰에 서명합니다.
3단계: 토큰 전달
서버는 일반적으로 응답 본문에 JWT를 클라이언트에 다시 보냅니다. 클라이언트는 향후 요청을 위해 이 토큰을 저장합니다.
4단계: 인증된 요청
후속 요청의 경우 클라이언트는 Bearer 스키마를 사용하여 Authorization 헤더에 JWT를 포함합니다:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
5단계: 토큰 검증
서버는 요청을 받고 헤더에서 JWT를 추출하고 비밀 또는 공개 키를 사용하여 서명을 검증합니다. 유효한 경우 서버는 페이로드에서 사용자 정보를 추출하고 요청을 처리합니다.
6단계: 토큰 만료
토큰이 만료되면 클라이언트는 재인증하거나 리프레시 토큰을 사용하여(다음 섹션에서 다룸) 새 토큰을 얻어야 합니다.
빠른 팁: 클라이언트에서 만료를 확인하더라도 항상 서버 측에서 exp 클레임을 검증하세요. 클라이언트 측 확인은 우회될 수 있지만 서버 측 검증은 권위가 있습니다.
액세스 토큰 vs 리프레시 토큰
강력한 JWT 인증 시스템은 일반적으로 함께 작동하는 두 가지 유형의 토큰을 사용합니다: 액세스 토큰과 리프레시 토큰. 안전한 애플리케이션을 구축하려면 차이점을 이해하는 것이 중요합니다.
액세스 토큰
액세스 토큰은 보호된 리소스에 대한 액세스 권한을 부여하는 수명이 짧은 JWT(일반적으로 15분에서 1시간)입니다. 요청을 승인하는 데 필요한 사용자 신원 및 권한이 포함되어 있습니다.
특성:
- 짧은 만료 시간(5-60분)
- 모든 API 요청에 포함
- 사용자 권한 및 역할 포함
- 만료 전에 취소할 수 없음(무상태)
리프레시 토큰
리프레시 토큰은 새 액세스 토큰을 얻는 데만 사용되는 수명이 긴 토큰(며칠에서 몇 개월)입니다. 안전하게 저장되며 토큰 갱신 엔드포인트로만 전송됩니다.
특성:
- 긴 만료 시간(며칠에서 몇 개월)
- 갱신 엔드포인트로만 전송
- 데이터베이스에서 취소 가능
- 추가 메타데이터(장치, IP, 사용자 에이전트)와 함께 저장되는 경우가 많음
둘 다 사용하는 이유는?
이 이중 토큰 접근 방식은 보안과 사용자 경험의 균형을 맞춥니다:
| 측면 | 액세스 토큰만 | 액세스 + 리프레시 토큰 |
|---|---|---|
| 보안 | 낮음(수명이 긴 토큰이 위험) | 높음(수명이 짧은 액세스 토큰) |
| 사용자 경험 | 좋음(재인증 없음) | 좋음(원활한 토큰 갱신) |
| 취소 | 어려움(무상태) | 가능(DB의 리프레시 토큰) |
| 네트워크 오버헤드 | 낮음 | 약간 높음(갱신 요청) |
| 구현 복잡성 | 간단 | 보통 |
토큰 갱신 흐름
- 클라이언트가 액세스 토큰이 만료되었거나 곧 만료될 것을 감지
- 클라이언트가
/auth/refresh엔드포인트로 리프레시 토큰 전송 - 서버가 데이터베이스에 대해 리프레시 토큰 검증
- 서버가 새 액세스 토큰(및 선택적으로 새 리프레시 토큰) 생성
- 클라이언트가 새 토큰을 받아 저장
- 클라이언트가 새 액세스 토큰으로 원래 요청 재시도
프로 팁: 리프레시 토큰 로테이션을 구현하세요 — 사용할 때마다 새 리프레시 토큰을 발급하고 이전 토큰을 무효화합니다. 이렇게 하면 리프레시 토큰이 손상된 경우 피해를 제한할 수 있습니다.
Node.js 구현 가이드
Node.js, Express 및 jsonwebtoken 라이브러리를 사용하여 완전한 JWT 인증 시스템을 구축해 보겠습니다. 이 구현에는 액세스 토큰과 리프레시 토큰이 모두 포함됩니다.
설치
npm install express jsonwebtoken bcrypt dotenv
환경 구성
비밀 키로 .env 파일을 생성합니다:
ACCESS_TOKEN_SECRET=your-super-secret-access-key-change-this
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
토큰 생성
const jwt = require('jsonwebtoken');
function generateAccessToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
);
}
로그인 엔드포인트
const bcrypt = require('bcrypt');
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// 데이터베이스에서 사용자 찾기
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 비밀번호 확인
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 토큰 생성
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// 데이터베이스에 리프레시 토큰 저장
await RefreshToken.create({
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
인증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
}
// 사용법
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({
message: 'Protected data',
user: req.user
});
});
토큰 갱신 엔드포인트
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
// 데이터베이스에 리프레시 토큰이 존재하는지 확인
const storedToken = await RefreshToken.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// 토큰 서명 확인
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// 사용자 데이터 가져오기
const userData = await User.findById(user.userId);
// 새 액세스 토큰 생성
const accessToken = generateAccessToken(userData);
res.json({ accessToken });