JWT(JSON Web Token)는 header.payload.signature라는 base64url 세 부분으로 된 문자열로, 서버가 로그인할 때 사용자에게 주고 이후 요청마다 확인합니다. 서명은 서버가 토큰을 발급했고 아무도 바꾸지 않았음을 증명하지만, 페이로드는 인코딩만 되어 있을 뿐 암호화되지 않아서 누구나 읽을 수 있습니다. Node.js에서는 jose 패키지의 new SignJWT(payload).sign(secret)으로 만들고 jwtVerify(token, secret)으로 확인합니다.
3 parts, 이어서 user-42 admin이 출력됩니다. jwtVerify가 서명과 만료를 확인하고 페이로드를 반환했습니다. jwtVerify에 넘기는 비밀 키를 한 글자 바꿔 다시 실행하면 검증이 signature verification failed로 실패합니다.
JWT 안에 들어 있는 것
헤더와 페이로드는 JSON이고 base64url로 인코딩되어 있습니다. 디코딩에는 키가 필요 없습니다. 그래서 JWT에는 비밀번호나 그 밖의 비밀을 절대 넣으면 안 됩니다.
처음 두 줄은 {"alg":"HS256"}과 {"name":"Ada","role":"editor"}를 출력하고, 토큰을 가진 누구나 읽을 수 있습니다. 수정을 막는 것은 서명(HS256이면 43자)입니다. editor를 admin으로 바꾸면 서명이 더 이상 맞지 않습니다.
| 부분 | 내용 | 비밀인가? |
|---|---|---|
| 헤더 | HS256 같은 알고리즘 | 아니요, 읽을 수 있음 |
| 페이로드 | 클레임: sub(사용자 id), exp(만료), iat(발급 시각), 직접 넣은 필드 | 아니요, 읽을 수 있음 |
| 서명 | 앞 두 부분에 대한 HMAC 또는 RSA 서명 | 만들려면 키가 필요 |
만료와 검증 실패
setExpirationTime('15m')은 exp 클레임을 쓰고, jwtVerify는 그 시각이 지난 토큰을 거부합니다. 모든 거부는 code가 있는 에러라서, 만료된 토큰과 위조된 토큰을 구분할 수 있습니다.
첫 검사는 ERR_JWT_EXPIRED로, 서명을 바꾼 두 번째는 ERR_JWS_SIGNATURE_VERIFICATION_FAILED로 실패합니다. API 입장에서는 둘 다 같은 의미(401로 응답)지만, 코드를 로그로 남기면 디버깅에 도움이 됩니다.
Bearer 토큰으로 Express 라우트 보호하기
클라이언트는 한 번 로그인해 토큰을 보관하고, Authorization 헤더에 Bearer <token>으로 보냅니다. 미들웨어가 그것을 검증하고 라우트를 위해 페이로드를 req.user에 넣습니다.
첫 요청인 토큰 없는 GET /me는 401과 Missing token을 받습니다. 본문 {"username": "ada", "password": "lovelace"}로 POST /login을 보내고, 응답의 token을 복사한 다음, Authorization: Bearer <token> 헤더를 붙여 GET /me를 다시 보내면 응답은 {"id":"user-1","name":"ada"}입니다.
jsonwebtoken 패키지
jsonwebtoken(npm install jsonwebtoken)도 많이 쓰는 선택지입니다. sign과 verify는 Promise를 반환하지 않고 동기적으로 동작합니다(콜백도 선택적으로 쓸 수 있습니다).
const jwt = require('jsonwebtoken');
const token = jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, {
subject: 'user-42',
expiresIn: '1h',
});
const payload = jwt.verify(token, process.env.JWT_SECRET); // throws if invalid
새 코드에는 jose를 쓰세요. 의존성이 없고 엣지 런타임과 브라우저에서도 돌아갑니다. 두 라이브러리의 토큰 모두 표준 JWT라서, 한쪽이 서명한 것을 다른 쪽이 검증할 수 있습니다.
흔한 실수
verify 대신 decode를 쓰는 것. jwt.decode와 decodeJwt는 서명을 확인하지 않고 페이로드를 읽으므로 위조된 토큰도 통과합니다. verify가 반환한 것만 믿으세요.
짧거나 커밋된 비밀 키. 추측 가능한 HS256 비밀 키가 있으면 공격자가 자기 토큰에 직접 서명할 수 있습니다. 무작위 32바이트 이상을 쓰고, process.env에서 읽고, 절대 커밋하지 마세요.
만료되지 않는 토큰. exp가 없으면 유출된 토큰이 영원히 동작합니다. 항상 만료 시간을 설정하세요.
자주 묻는 질문
클라이언트는 JWT를 어디에 저장해야 하나요?
브라우저 앱이라면 페이지 스크립트가 읽을 수 없는 HttpOnly, Secure 쿠키가 가장 안전합니다. localStorage는 간단하지만 주입된 것을 포함해 페이지의 어떤 스크립트든 토큰을 읽을 수 있습니다. 모바일 앱은 플랫폼의 보안 저장소를 씁니다.
JWT로 사용자를 로그아웃시키려면?
서버는 이미 서명한 토큰을 취소할 수 없고, 토큰은 만료될 때까지 유효합니다. 클라이언트에서 지우고, 액세스 토큰의 수명을 짧게 하고, 즉시 로그아웃이 필요하다면 취소한 토큰 id(jti) 목록을 서버에 두고 요청마다 확인하세요.
HS256과 RS256 중 무엇을 써야 하나요?
HS256은 서명과 검증에 공유 비밀 키 하나를 쓰므로 백엔드가 하나일 때 알맞습니다. RS256은 개인 키로 서명하고 공개 키로 검증하므로, 다른 서비스가 토큰을 만들 수는 없으면서 확인만 할 수 있습니다.
JWT 유효 기간은 얼마가 좋나요?
액세스 토큰은 보통 5분에서 60분입니다. 더 긴 세션에는 더 신중하게 저장하는 리프레시 토큰을 함께 쓰고, 클라이언트가 그것을 새 액세스 토큰으로 교환합니다.