junyeokk
Blog
React·2025. 05. 07

jwt-decode

로그인 후 서버에서 JWT(JSON Web Token)를 받으면, 클라이언트에서 이 토큰 안에 담긴 정보를 꺼내 써야 할 때가 있다. 예를 들어 토큰에 들어있는 사용자 ID, 역할(role), 만료 시간 같은 정보를 UI에서 사용하거나, 토큰이 만료되었는지 확인해서 갱신 요청을 보내는 경우다.

JWT는 .으로 구분된 세 부분(Header.Payload.Signature)으로 이루어져 있고, Payload 부분이 Base64URL로 인코딩되어 있기 때문에 직접 디코딩할 수도 있다.

javascript
const payload = JSON.parse(atob(token.split('.')[1]));

이렇게 하면 되긴 하는데, 몇 가지 문제가 있다.

  1. Base64URL ≠ Base64: JWT는 Base64URL 인코딩을 사용하는데, atob()는 표준 Base64만 처리한다. -+로, _/로 치환해야 하고, 패딩(=)도 직접 처리해야 한다.
  2. 유니코드 문제: Payload에 한글이나 이모지 같은 멀티바이트 문자가 포함되면 atob()로 디코딩한 결과가 깨진다.
  3. 에러 처리: 잘못된 토큰이 들어왔을 때 적절한 에러를 던지지 않고 의미 불명의 에러가 발생한다.

jwt-decode는 이런 엣지 케이스를 모두 처리해주는 가벼운 라이브러리다. 번들 크기도 약 600바이트 정도라 부담이 없다.


설치와 기본 사용법

bash
npm install jwt-decode
typescript
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 타입을 지정할 수 있다는 것이다.

typescript
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도 디코딩할 수 있다. 두 번째 인자로 옵션을 넘기면 된다.

typescript
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(초 단위)로 만료 시간을 나타낸다.

typescript
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)를 두고 만료를 판단한다.

typescript
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에서 활용 패턴

로그인 상태 관리

토큰을 디코딩해서 사용자 정보를 추출하고, 만료 여부에 따라 자동 로그아웃하는 패턴이 흔하다.

tsx
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가 쓰인다.

typescript
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를 던진다.

typescript
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 코드에서 토큰을 가져올 때는 쿠키나 요청 헤더에서 가져와야 한다.

typescript
// ❌ SSR에서 에러
const token = localStorage.getItem('accessToken');

// ✅ 서버에서는 쿠키/헤더에서 가져오기
const token = req.cookies.accessToken;

버전 차이 (v3 → v4)

v3까지는 default export였지만, v4부터 named export로 변경되었다.

typescript
// v3 (구버전)
import jwt_decode from 'jwt-decode';

// v4 (현재)
import { jwtDecode } from 'jwt-decode';

기존 프로젝트를 업그레이드할 때 import 구문을 바꿔야 한다. 함수명도 jwt_decode에서 jwtDecode로 camelCase로 변경되었다.


정리

  • JWT Payload를 클라이언트에서 안전하게 디코딩하는 라이브러리로, Base64URL/유니코드 엣지 케이스를 처리하며 번들 크기는 약 600바이트
  • 토큰 만료 확인(exp 클레임)과 선제적 갱신 패턴에 핵심적으로 사용되며, 시계 오차를 고려한 버퍼 설정이 중요
  • 디코딩만 수행하고 서명 검증은 하지 않으므로, 클라이언트에서 추출한 정보로 보안 결정을 내리면 안 되고 권한 검증은 반드시 서버에서 처리

관련 문서