📑 목차
- JWT, 대체 뭐길래 이렇게 핫할까요? 🤔
- JWT의 핵심 구조 파헤치기: Header, Payload, Signature 🕵️♀️
- 1. Header (헤더)
- 2. Payload (페이로드)
- 3. Signature (서명)
- JWT 기반 인증 시스템, 이렇게 설계해봐요! 🏗️
- 1. 사용자 인증 및 토큰 발급
- 2. 토큰 전달 및 리소스 접근
- 3. Refresh Token의 활용 (보안 강화)
- JWT 인증, 놓칠 수 없는 보안 고려사항 🚨
- 1. 토큰 탈취 위험성 (XSS, CSRF)
- 2. 토큰 만료 시간 설정의 중요성
- 3. 시크릿 키 관리의 중요성
- 4. 토큰 폐기 (로그아웃 및 강제 만료)
- 실전! JWT 취약점 방어 전략 🛡️
- 1. XSS/CSRF 방어: 토큰 저장 방식과 쿠키 활용
- 2. 만료 시간 단축 및 Refresh Token 활용
- 3. 시크릿 키 안전한 저장 및 주기적 교체
- 4. 토큰 폐기 (블랙리스트) 구현
- 5. 알고리즘 변경 공격 방어
- Access Token vs Refresh Token: 어떤 차이가 있을까요? 📊
- 마무리하며: JWT, 현명하게 사용하면 든든하죠! 🚀
Image by pixelcreatures on Pixabay
JWT, 대체 뭐길래 이렇게 핫할까요? 🤔
개발자라면 한 번쯤은 들어봤을 JWT(JSON Web Token), 요즘 정말 많은 서비스에서 인증 방식으로 채택하고 있죠? 아이디와 비밀번호를 서버에 직접 보내지 않고도, 안전하게 사용자를 식별하고 권한을 부여하는 똑똑한 방식 덕분인데요. 기존의 세션 기반 인증 방식이 가지고 있던 여러 한계를 극복하면서, 특히 분산 환경이나 마이크로서비스 아키텍처에서 더욱 빛을 발하고 있답니다.
세션 방식은 서버에 사용자 정보를 저장해야 하는 부담이 있었죠. 서버의 부하를 늘리거나, 여러 서버 간에 세션 정보를 공유해야 하는 복잡한 문제가 생기기도 했고요. 하지만 JWT는 달라요! 사용자 정보가 담긴 토큰을 클라이언트가 가지고 있다가 필요할 때마다 서버에 보내는 스테이트리스(Stateless) 방식이거든요. 서버는 그저 토큰의 유효성만 검증하면 되니, 훨씬 가볍고 확장성이 뛰어나다는 장점이 있죠.
그렇다면 이 JWT라는 친구, 과연 어떤 구조를 가지고 있고 어떻게 동작하는지, 그리고 무엇보다 어떻게 안전하게 설계해야 하는지 저와 함께 자세히 알아볼까요?
JWT의 핵심 구조 파헤치기: Header, Payload, Signature 🕵️♀️
JWT는 이름처럼 JSON 객체를 기반으로 정보를 표현하는데요, 크게 세 부분으로 나눌 수 있어요. 점(.)으로 구분된 세 덩어리가 바로 그것인데요. 바로 Header(헤더), Payload(페이로드), Signature(서명)입니다.
xxxxx.yyyyy.zzzzz
각각의 역할이 궁금하시죠? 하나씩 자세히 살펴볼게요.
1. Header (헤더)
헤더는 토큰의 타입(typ)과 서명에 사용된 알고리즘(alg)을 정의하는 부분이에요. 보통 다음과 같은 형태로 구성됩니다.
{
"alg": "HS256", // 서명 알고리즘 (예: HMAC SHA256)
"typ": "JWT" // 토큰 타입
}
이 JSON 객체는 Base64Url로 인코딩되어 JWT의 첫 번째 부분이 됩니다.
2. Payload (페이로드)
페이로드는 토큰에 담을 정보, 즉 클레임(Claim)을 포함하는 부분이에요. 클레임은 사용자 정보나 권한 등 인증에 필요한 다양한 데이터를 담을 수 있는데요. 크게 세 가지 종류로 나눌 수 있어요.
- 등록된 클레임(Registered Claims): JWT 표준에 정의된 클레임으로, 필수는 아니지만 사용하는 것을 권장해요. 예를 들어,
iss(발급자),exp(만료 시간),sub(제목),aud(수신자) 등이 있습니다. 특히exp는 토큰의 생명 주기를 결정하는 아주 중요한 클레임이죠. - 공개 클레임(Public Claims): 충돌 방지를 위해 URI 형태로 정의하는 클레임이에요.
- 비공개 클레임(Private Claims): 클라이언트와 서버 간에 협의하여 사용하는 클레임으로, 사용자 ID나 권한 등 서비스에 특화된 정보를 담을 때 활용됩니다.
예를 들어, 다음과 같은 페이로드를 가질 수 있어요.
{
"sub": "1234567890",
"name": "홍길동",
"admin": true,
"exp": 1678886400 // 만료 시간 (Unix Time)
}
이 JSON 객체도 헤더와 마찬가지로 Base64Url로 인코딩되어 JWT의 두 번째 부분이 됩니다.
3. Signature (서명)
서명은 JWT의 무결성과 인증을 보장하는 핵심 부분이에요. 헤더와 페이로드를 Base64Url로 인코딩한 값에, 서버만이 알고 있는 비밀 키(Secret Key)를 이용해 암호화한 값이죠. 서명은 다음과 같은 방식으로 생성됩니다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
서명이 있기 때문에 클라이언트가 임의로 헤더나 페이로드의 내용을 변경하더라도, 서버는 이 서명 값을 통해 변조 여부를 쉽게 확인할 수 있어요. 만약 서명이 유효하지 않다면, 해당 토큰은 위조된 것으로 간주하고 인증을 거부하게 되는 거죠. 이 부분이 바로 JWT의 강력한 보안 메커니즘 중 하나랍니다.
최종적으로 JWT는 이렇게 세 부분이 합쳐져 하나의 긴 문자열 형태로 만들어져요.
JWT 기반 인증 시스템, 이렇게 설계해봐요! 🏗️
JWT의 구조를 이해했으니, 이제 이걸 가지고 어떻게 인증 시스템을 설계할지 알아볼 차례죠. 일반적인 흐름은 다음과 같습니다.
1. 사용자 인증 및 토큰 발급
- 사용자가 로그인 요청 시, 아이디와 비밀번호를 서버로 전송합니다. (이때 HTTPS를 사용하는 것은 기본 중의 기본이겠죠!)
- 서버는 전달받은 아이디와 비밀번호를 데이터베이스에 저장된 정보와 비교하여 사용자를 인증합니다.
- 인증에 성공하면, 서버는 해당 사용자 정보를 바탕으로 JWT를 생성합니다. 이 JWT에는 보통 사용자 ID, 권한 등의 페이로드와 만료 시간을 포함시키죠.
- 생성된 JWT를 클라이언트에게 응답으로 전달합니다.
토큰을 발급하는 서버 측 예시 코드를 간단히 살펴볼까요? (Node.js의 jsonwebtoken 라이브러리 활용)
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET_KEY; // 환경 변수로 관리하는 것이 중요!
// 사용자 로그인 성공 후 토큰 발급
function generateToken(user) {
const payload = {
userId: user.id,
role: user.role
};
const options = {
expiresIn: '1h', // Access Token은 짧게 유지
issuer: 'your-service-name'
};
return jwt.sign(payload, SECRET_KEY, options);
}
// 예시: 로그인 API
app.post('/login', (req, res) => {
const { username, password } = req.body;
// DB에서 사용자 인증 로직
if (authenticateUser(username, password)) {
const user = getUserFromDB(username);
const accessToken = generateToken(user);
// Refresh Token도 함께 발급하고 DB에 저장
const refreshToken = generateRefreshToken(user);
res.json({ accessToken, refreshToken });
} else {
res.status(401).send('Invalid credentials');
}
});
2. 토큰 전달 및 리소스 접근
- 클라이언트는 서버로부터 받은 JWT(Access Token)를 안전한 곳에 저장합니다. (보통 웹에서는 로컬 스토리지나 세션 스토리지, 혹은 HTTP Only 쿠키에 저장하죠.)
- 이후 클라이언트가 서버의 보호된 리소스(예: 사용자 정보 조회, 게시글 작성 등)에 접근할 필요가 있을 때마다, 저장해둔 JWT를 HTTP 요청의 Authorization 헤더에 담아 보냅니다. 보통
Bearer스키마를 사용해요. - 서버는 요청과 함께 전달받은 JWT의 서명을 검증하여 토큰의 유효성(변조 여부, 만료 시간 등)을 확인합니다.
- 토큰이 유효하다면, 페이로드에 담긴 사용자 정보를 기반으로 해당 리소스에 대한 접근 권한을 확인하고 요청을 처리합니다.
서버 측에서 토큰을 검증하는 미들웨어 예시입니다.
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET_KEY;
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer Token 추출
if (token == null) return res.sendStatus(401); // 토큰 없음
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.sendStatus(403); // 토큰 유효하지 않음 (만료, 변조 등)
req.user = user; // 요청 객체에 사용자 정보 추가
next(); // 다음 미들웨어 또는 라우터로 이동
});
}
// 예시: 보호된 API
app.get('/protected', authenticateToken, (req, res) => {
res.send(`Welcome, ${req.user.userId}! You have role: ${req.user.role}`);
});
3. Refresh Token의 활용 (보안 강화)
Access Token은 보통 만료 시간을 짧게 가져가는 것이 좋아요. 토큰이 탈취되더라도 공격자가 사용할 수 있는 시간을 최소화하기 위해서죠. 하지만 Access Token의 만료 시간이 너무 짧으면, 사용자는 계속해서 로그인해야 하는 불편함을 겪게 됩니다.
이때 등장하는 것이 바로 Refresh Token(리프레시 토큰)이에요. Refresh Token은 Access Token보다 긴 만료 시간을 가지며, 새로운 Access Token을 발급받기 위한 용도로 사용됩니다. 일반적인 흐름은 이렇습니다.
- 사용자 로그인 시, 서버는 Access Token과 Refresh Token을 동시에 발급합니다.
- 클라이언트는 Access Token은 비교적 접근하기 쉬운 곳(예: 로컬 스토리지)에 저장하고, Refresh Token은 HTTP Only 쿠키와 같이 좀 더 안전한 곳에 저장합니다.
- Access Token이 만료되면, 클라이언트는 Refresh Token을 이용하여 서버에 새로운 Access Token 발급을 요청합니다.
- 서버는 Refresh Token의 유효성을 검증하고, 유효하다면 새로운 Access Token을 발급하여 클라이언트에게 전달합니다. 필요하다면 Refresh Token도 재발급할 수 있습니다.
이러한 Refresh Token 전략은 Access Token이 탈취되더라도, 짧은 만료 시간 덕분에 피해를 최소화하고 Refresh Token은 더 안전하게 관리하여 재사용성을 높일 수 있는 강력한 보안 패턴이랍니다.
Image by WebTechExperts on Pixabay
JWT 인증, 놓칠 수 없는 보안 고려사항 🚨
JWT는 분명 강력한 인증 메커니즘이지만, 만능은 아니에요. 잘못 설계하면 오히려 취약점이 될 수도 있죠. 다음 보안 고려사항들을 반드시 숙지해야 합니다.
1. 토큰 탈취 위험성 (XSS, CSRF)
클라이언트 측에 저장되는 JWT는 XSS(Cross-Site Scripting) 공격에 매우 취약해요. 악성 스크립트가 실행되어 로컬 스토리지에 저장된 Access Token을 탈취할 수 있거든요. 탈취된 토큰은 공격자가 마치 사용자 본인인 것처럼 서버에 요청을 보낼 수 있게 만듭니다.
또한, CSRF(Cross-Site Request Forgery) 공격에도 주의해야 합니다. 특히 토큰을 쿠키에 저장할 경우, CSRF 공격으로 인해 사용자의 의도와 다르게 요청이 전송될 수 있어요.
2. 토큰 만료 시간 설정의 중요성
앞서 언급했듯이, Access Token의 만료 시간은 짧게 가져가는 것이 좋아요. 예를 들어 1시간 정도로 설정하면, 토큰이 탈취되더라도 공격자가 1시간 이상 사용할 수 없게 되죠. 반대로 Refresh Token은 더 긴 만료 시간을 가질 수 있지만, 이 역시 무한정 길게 설정하는 것은 위험합니다. 적절한 주기로 재발급하거나 만료시켜야 해요.
3. 시크릿 키 관리의 중요성
JWT의 서명을 생성하고 검증하는 데 사용되는 시크릿 키(Secret Key)는 절대 노출되어서는 안 됩니다. 이 키가 유출되면 공격자가 마음대로 유효한 JWT를 생성하거나 변조된 토큰의 서명을 위조할 수 있게 되어 시스템 전체의 보안이 무너지게 됩니다. 시크릿 키는 환경 변수, 키 관리 시스템(KMS) 등을 통해 안전하게 관리해야 해요.
4. 토큰 폐기 (로그아웃 및 강제 만료)
JWT는 스테이트리스 방식이기 때문에, 한 번 발급된 토큰은 만료되기 전까지는 유효합니다. 즉, 사용자가 로그아웃을 하더라도 서버는 해당 토큰이 더 이상 유효하지 않다는 것을 기본적으로 알 수 없어요. 따라서 로그아웃 시 토큰을 서버 측에서 블랙리스트(Blacklist) 처리하거나, Redis와 같은 인메모리 데이터베이스에 저장하여 만료될 때까지 유효성 검사 시마다 확인하는 추가적인 로직이 필요합니다.
또한, 보안 사고 발생 시 특정 사용자 또는 모든 사용자의 토큰을 강제로 만료시켜야 할 때도 블랙리스트 전략이 유용하게 사용될 수 있습니다.
실전! JWT 취약점 방어 전략 🛡️
위에서 언급된 보안 고려사항들을 바탕으로, 실질적인 방어 전략들을 알아볼까요?
1. XSS/CSRF 방어: 토큰 저장 방식과 쿠키 활용
- Access Token: 로컬 스토리지 대신 HTTP Only 쿠키에 저장하는 것을 고려해볼 수 있습니다. HTTP Only 쿠키는 자바스크립트에서 접근할 수 없으므로 XSS 공격으로부터 비교적 안전합니다. 하지만 CSRF 공격에 취약해질 수 있으므로, CSRF 토큰과 같은 추가적인 방어책이 필요합니다.
- Refresh Token: 항상 HTTP Only, Secure, SameSite=Strict(또는 Lax) 플래그가 설정된 쿠키에 저장해야 합니다.
HTTP Only: 자바스크립트 접근 불가능Secure: HTTPS 연결에서만 전송SameSite: CSRF 공격 방어 (Strict는 다른 도메인에서의 요청 시 쿠키 전송 차단)
2. 만료 시간 단축 및 Refresh Token 활용
Access Token의 만료 시간을 15분 ~ 1시간 정도로 짧게 설정하고, Refresh Token은 수일 ~ 수주 정도로 길게 설정하는 것이 일반적인 권장사항입니다. Access Token은 메모리에 저장하고, Refresh Token은 HTTP Only 쿠키에 저장하여 보안을 강화하세요. Access Token이 만료되면 Refresh Token으로 재발급받는 흐름을 구현하여 사용자 경험을 해치지 않으면서 보안을 유지할 수 있습니다.
3. 시크릿 키 안전한 저장 및 주기적 교체
시크릿 키는 환경 변수로 관리하거나, Vault와 같은 전문적인 키 관리 솔루션을 이용하세요. 코드 내에 하드코딩하는 것은 절대 금물입니다. 또한, 주기적으로 키를 교체(Key Rotation)하는 정책을 수립하여 혹시 모를 키 유출 사고에 대비하는 것이 좋습니다.
4. 토큰 폐기 (블랙리스트) 구현
로그아웃 시에는 클라이언트에게 토큰을 삭제하도록 지시하고, 서버에서는 해당 Access Token을 블랙리스트에 추가하여 더 이상 사용하지 못하도록 만듭니다. Redis와 같은 인메모리 데이터베이스에 토큰 ID와 만료 시간을 함께 저장하여, 유효성 검사 시 블랙리스트 여부를 확인하는 방식으로 구현할 수 있습니다.
// Redis에 블랙리스트 토큰 추가
async function blacklistToken(token, expiresIn) {
await redisClient.set(token, 'blacklisted', 'EX', expiresIn); // expiresIn은 토큰의 남은 유효 시간
}
// 토큰 검증 시 블랙리스트 여부 확인
async function verifyToken(token) {
const isBlacklisted = await redisClient.get(token);
if (isBlacklisted) throw new Error('Token is blacklisted');
// ... JWT.verify 로직 ...
}
5. 알고리즘 변경 공격 방어
JWT의 헤더에 정의된 알고리즘(alg)을 조작하여 서버가 서명 검증을 우회하도록 시도하는 공격이 있습니다. 예를 들어, alg: "none"으로 변경하여 서명 검증을 무력화하는 식이죠. 이를 방어하기 위해 서버는 토큰을 검증할 때 헤더의 알고리즘을 신뢰하지 않고, 항상 사전에 정의된 특정 알고리즘(예: HS256)만 사용하도록 강제해야 합니다.
jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] }, (err, user) => {
// ...
});
algorithms 옵션을 명시적으로 지정하여 예상치 못한 알고리즘으로 토큰이 서명되는 것을 방지할 수 있습니다.
Image by Tumisu on Pixabay
Access Token vs Refresh Token: 어떤 차이가 있을까요? 📊
Access Token과 Refresh Token은 JWT 기반 인증 시스템의 양대 축이라고 할 수 있어요. 둘의 차이점을 명확히 이해하는 것이 중요합니다.
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 주요 역할 | 보호된 리소스에 직접 접근하는 데 사용 (실제 인증 및 권한 부여) | 새로운 Access Token을 발급받는 용도로 사용 |
| 만료 시간 | 짧게 (예: 15분 ~ 1시간). 탈취 시 피해 최소화. | 길게 (예: 수일 ~ 수주). 사용자의 재로그인 빈도 감소. |
| 저장 위치 (클라이언트) | 로컬 스토리지, 세션 스토리지, (HTTP Only) 쿠키 등 | HTTP Only, Secure, SameSite 쿠키 등 안전한 위치 |
| 탈취 위험성 | 높음 (XSS 공격에 취약) | 낮음 (HTTP Only 쿠키 등으로 보호) |
| 폐기 전략 | 만료되거나, 로그아웃 시 블랙리스트 처리 | 서버에서 관리하며, 만료되거나 특정 상황(비밀번호 변경 등)에 강제 폐기 |
이 둘을 적절히 활용하는 것이 JWT 기반 인증 시스템의 핵심 보안 전략이라고 할 수 있습니다.
마무리하며: JWT, 현명하게 사용하면 든든하죠! 🚀
오늘은 JWT 기반 인증 시스템의 설계와 보안 고려사항에 대해 깊이 있게 다뤄봤는데요. JWT의 구조부터 토큰 발급 및 검증 과정, 그리고 Refresh Token을 활용한 보안 강화 전략까지 폭넓게 살펴보았습니다. 특히 XSS, CSRF와 같은 주요 취약점에 대한 방어 전략과 시크릿 키 관리의 중요성, 토큰 폐기 방법 등은 시스템의 안정성을 좌우하는 핵심적인 요소들이니 꼭 기억해두세요!
JWT는 분산 환경과 마이크로서비스 아키텍처에서 탁월한 확장성과 유연성을 제공하지만, 그만큼 개발자가 신경 써야 할 보안 측면도 많다는 점을 잊지 말아야 합니다. 오늘 배운 내용들을 바탕으로 더욱 안전하고 견고한 인증 시스템을 구축하시길 바랍니다. 현명하게 사용하면 JWT는 여러분의 서비스를 든든하게 지켜줄 강력한 도구가 될 거예요!
혹시 JWT 기반 인증 시스템을 설계하면서 겪었던 어려움이나 특별한 노하우가 있으신가요? 댓글로 자유롭게 의견을 나눠주시면 감사하겠습니다! 다음번에는 더 유익한 주제로 찾아올게요. 😊