jwt-decode
로그인 후 서버에서 JWT(JSON Web Token)를 받으면, 클라이언트에서 이 토큰 안에 담긴 정보를 꺼내 써야 할 때가 있다. 예를 들어 토큰에 들어있는 사용자 ID, 역할(role), 만료 시간 같은 정보를 UI에서 사용하거나, 토큰이 만료되었는지 확인해서 갱신 요청을 보내는 경우다.
JWT는 .으로 구분된 세 부분(Header.Payload.Signature)으로 이루어져 있고, Payload 부분이 Base64URL로 인코딩되어 있기 때문에 직접 디코딩할 수도 있다.
const payload = JSON.parse(atob(token.split('.')[1]));
이렇게 하면 되긴 하는데, 몇 가지 문제가 있다.
- Base64URL ≠ Base64: JWT는 Base64URL 인코딩을 사용하는데,
atob()는 표준 Base64만 처리한다.-를+로,_를/로 치환해야 하고, 패딩(=)도 직접 처리해야 한다. - 유니코드 문제: Payload에 한글이나 이모지 같은 멀티바이트 문자가 포함되면
atob()로 디코딩한 결과가 깨진다. - 에러 처리: 잘못된 토큰이 들어왔을 때 적절한 에러를 던지지 않고 의미 불명의 에러가 발생한다.
jwt-decode는 이런 엣지 케이스를 모두 처리해주는 가벼운 라이브러리다. 번들 크기도 약 600바이트 정도라 부담이 없다.
설치와 기본 사용법
npm install jwt-decode
import { jwtDecode } from 'jwt-decode';
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const decoded = jwtDecode(token);
console.log(decoded);
// { sub: "1234", name: "홍길동", iat: 1516239022, exp: 1716239022 }
jwtDecode()에 JWT 문자열을 넘기면 Payload를 파싱한 객체를 반환한다. 서명 검증은 하지 않는다. 이건 의도된 동작이다 — 클라이언트에서 서명을 검증하려면 비밀키가 필요한데, 비밀키를 클라이언트에 두는 건 보안상 말이 안 된다. 서명 검증은 서버의 몫이다.
TypeScript 타입 지정
jwt-decode의 진짜 편한 점은 제네릭으로 Payload 타입을 지정할 수 있다는 것이다.
interface MyTokenPayload {
sub: string;
email: string;
role: 'admin' | 'user';
iat: number;
exp: number;
}
const decoded = jwtDecode<MyTokenPayload>(token);
// 이제 타입 추론이 된다
console.log(decoded.role); // 'admin' | 'user'
console.log(decoded.email); // string
타입을 지정하지 않으면 JwtPayload 기본 타입이 사용되는데, 이 타입에는 iss, sub, aud, exp, nbf, iat, jti 같은 표준 JWT 클레임만 정의되어 있다. 커스텀 클레임(역할, 이메일 등)을 사용한다면 반드시 인터페이스를 정의해서 제네릭으로 넘기는 게 좋다.
헤더 디코딩
Payload뿐만 아니라 Header도 디코딩할 수 있다. 두 번째 인자로 옵션을 넘기면 된다.
import { jwtDecode } from 'jwt-decode';
const header = jwtDecode(token, { header: true });
console.log(header);
// { alg: "HS256", typ: "JWT" }
Header에는 서명 알고리즘(alg)과 토큰 타입(typ) 정보가 들어있다. 클라이언트에서 이 정보를 직접 쓸 일은 많지 않지만, 디버깅할 때 유용하다.
토큰 만료 확인
가장 흔한 사용 사례 중 하나가 토큰 만료 여부 확인이다. JWT의 exp 클레임은 Unix timestamp(초 단위)로 만료 시간을 나타낸다.
function isTokenExpired(token: string): boolean {
try {
const decoded = jwtDecode<{ exp: number }>(token);
const now = Date.now() / 1000; // 밀리초 → 초 변환
return decoded.exp < now;
} catch {
return true; // 디코딩 실패 = 유효하지 않은 토큰
}
}
주의할 점이 있다. Date.now()는 밀리초를 반환하고, JWT의 exp는 초 단위다. 1000으로 나눠서 단위를 맞춰야 한다. 이걸 빠뜨리면 토큰이 항상 유효하지 않다고 판단되는 버그가 발생한다.
또 하나, 클라이언트 시계가 서버와 다를 수 있다는 점도 고려해야 한다. 보통 몇 초~몇 분 정도의 여유(buffer)를 두고 만료를 판단한다.
function isTokenExpired(token: string, bufferSeconds = 30): boolean {
try {
const decoded = jwtDecode<{ exp: number }>(token);
const now = Date.now() / 1000;
return decoded.exp < now + bufferSeconds;
} catch {
return true;
}
}
30초 버퍼를 두면, 토큰이 실제로 만료되기 30초 전에 미리 만료된 것으로 처리해서 갱신 요청을 보낼 수 있다.
React에서 활용 패턴
로그인 상태 관리
토큰을 디코딩해서 사용자 정보를 추출하고, 만료 여부에 따라 자동 로그아웃하는 패턴이 흔하다.
function useAuth() {
const [user, setUser] = useState<MyTokenPayload | null>(null);
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const decoded = jwtDecode<MyTokenPayload>(token);
const now = Date.now() / 1000;
if (decoded.exp < now) {
localStorage.removeItem('accessToken');
setUser(null);
} else {
setUser(decoded);
}
} catch {
localStorage.removeItem('accessToken');
setUser(null);
}
}, []);
return { user, isLoggedIn: !!user };
}
토큰 자동 갱신
Access Token이 곧 만료될 때 Refresh Token으로 자동 갱신하는 패턴에서도 jwt-decode가 쓰인다.
async function fetchWithAuth(url: string, options?: RequestInit) {
let token = localStorage.getItem('accessToken');
if (token && isTokenExpired(token)) {
// 토큰이 만료됐거나 곧 만료될 예정이면 갱신
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Refresh Token은 httpOnly 쿠키로
});
if (response.ok) {
const data = await response.json();
token = data.accessToken;
localStorage.setItem('accessToken', token);
} else {
// 갱신 실패 → 로그아웃
localStorage.removeItem('accessToken');
window.location.href = '/login';
return;
}
}
return fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${token}`,
},
});
}
이 패턴에서 핵심은 Access Token의 만료 시간을 클라이언트에서 확인해서 API 요청 전에 선제적으로 갱신하는 것이다. 만료된 토큰으로 요청을 보내고 401을 받아서 재시도하는 것보다 사용자 경험이 훨씬 낫다.
에러 처리
jwt-decode는 잘못된 입력에 대해 InvalidTokenError를 던진다.
import { jwtDecode, InvalidTokenError } from 'jwt-decode';
try {
const decoded = jwtDecode('이건.유효하지.않은토큰');
} catch (error) {
if (error instanceof InvalidTokenError) {
console.error('유효하지 않은 토큰:', error.message);
}
}
다음과 같은 경우에 에러가 발생한다:
- 인자가 문자열이 아닌 경우
.으로 분리했을 때 세 부분이 아닌 경우- Base64URL 디코딩에 실패한 경우
- JSON 파싱에 실패한 경우
프로덕션 코드에서는 반드시 try-catch로 감싸야 한다. 토큰이 localStorage나 쿠키에서 오기 때문에, 사용자가 직접 값을 변조하거나 스토리지가 오염될 수 있다.
주의사항
서명 검증은 하지 않는다
이건 아무리 강조해도 부족하다. jwt-decode는 디코딩만 한다. 누군가가 Payload를 임의로 변조해서 만든 JWT도 아무 문제없이 디코딩된다. 따라서 토큰에서 추출한 정보로 보안 결정을 내리면 안 된다. 예를 들어 role: "admin"이라고 해서 클라이언트에서 관리자 기능을 보여주는 건 괜찮지만(어차피 서버에서 검증하니까), 서버 없이 클라이언트만으로 권한을 판단하면 안 된다.
SSR 환경에서의 주의
서버 사이드에서 jwt-decode를 사용하는 것 자체는 문제없다. 하지만 localStorage는 서버에 없으므로, SSR 코드에서 토큰을 가져올 때는 쿠키나 요청 헤더에서 가져와야 한다.
// ❌ SSR에서 에러
const token = localStorage.getItem('accessToken');
// ✅ 서버에서는 쿠키/헤더에서 가져오기
const token = req.cookies.accessToken;
버전 차이 (v3 → v4)
v3까지는 default export였지만, v4부터 named export로 변경되었다.
// v3 (구버전)
import jwt_decode from 'jwt-decode';
// v4 (현재)
import { jwtDecode } from 'jwt-decode';
기존 프로젝트를 업그레이드할 때 import 구문을 바꿔야 한다. 함수명도 jwt_decode에서 jwtDecode로 camelCase로 변경되었다.
정리
- JWT Payload를 클라이언트에서 안전하게 디코딩하는 라이브러리로, Base64URL/유니코드 엣지 케이스를 처리하며 번들 크기는 약 600바이트
- 토큰 만료 확인(
exp클레임)과 선제적 갱신 패턴에 핵심적으로 사용되며, 시계 오차를 고려한 버퍼 설정이 중요 - 디코딩만 수행하고 서명 검증은 하지 않으므로, 클라이언트에서 추출한 정보로 보안 결정을 내리면 안 되고 권한 검증은 반드시 서버에서 처리
관련 문서
- Refresh Token + Access Token 분리 - JWT 이중 토큰 전략
- Axios 인터셉터
- Protected Route