jwt-decode
jwt-decode is a tiny function from Auth0 that splits a JSON Web Token on its dots, base64url-decodes one of the parts, and hands you back the JSON inside. By default it decodes part two, the payload, where claims like sub, exp, and your app's custom claims live. Pass { header: true } and it decodes part one instead, which is how you read the kid before fetching a signing key. That is the entire library. It does not check the signature, it does not check whether the token expired, and it does not talk to your auth server. The README says this in bold at the very top because people keep getting it wrong: any well-formed JWT decodes fine, including one an attacker typed by hand.
For reading claims out of a token you already trust, in a browser, this is the right size of tool and costs you nothing. The moment a decision depends on the token being genuine, throw it out and verify with jose.
Use it if
- You are in browser or React Native code that already received a token from a trusted backend and you want to read exp, sub, or a custom claim to render UI, like showing a username or hiding an admin tab
- You need the kid or alg out of the token header so you can pick the right key from a JWKS endpoint before verifying elsewhere
- You want a scheduled silent refresh: read exp, subtract a safety margin, and set a timer to call your refresh endpoint before the access token dies
- You care about bundle size in a client app; the whole package gzips to well under a kilobyte with zero dependencies, versus the much larger crypto-carrying verification libraries
- You are on a server making an authorization decision. Decoding is not verifying. Use jose or fast-jwt to check the signature, issuer, audience, and expiry, or you have shipped an authentication bypass that takes one curl command to exploit
- You already depend on jose, jsonwebtoken, or an Auth0/Clerk/Firebase SDK. Those all decode claims for you and adding a second JWT package just spreads the same job across two dependencies
- You want expiry checking, clock skew tolerance, or claim validation built in. There is none. You compare exp against Date.now() / 1000 yourself and get the seconds versus milliseconds conversion right yourself
- You are handling opaque or encrypted tokens (JWE, or plain reference tokens). This only understands the three-part signed JWS layout and throws InvalidTokenError on anything else
- You are counting on active development. The last release, 4.0.0, shipped in October 2023; the repo still gets dependency bumps but the code has been effectively finished for years. That is fine for a 60-line function, less fine if you need a fix
Setup reality
npm install jwt-decode, then import { jwtDecode } and you are done. Two things trip people up. First, v4 removed the default export, so the widely copied `import jwtDecode from "jwt-decode"` snippet from old blog posts now yields undefined at call time; you must use the named import. Second, the library calls the global atob(), which v4 stopped polyfilling. Every current browser and Node 18 or newer has it, but React Native before 0.74 does not, so you import core-js/stable/atob or assign global.atob yourself before the first decode. There is no CJS/ESM drama otherwise, the exports map covers both, and TypeScript types ship in the package.
Patterns
Read the claims out of a tokendecode-payload
import { jwtDecode } from "jwt-decode";
const decoded = jwtDecode(accessToken);
console.log(decoded.sub, decoded.exp);Named import only. v4 dropped the default export, so `import jwtDecode from "jwt-decode"` compiles and then blows up with "jwtDecode is not a function" at runtime.
Get the kid and alg from the headerdecode-header
import { jwtDecode } from "jwt-decode";
const header = jwtDecode(token, { header: true });
console.log(header.kid, header.alg);
// { typ: "JWT", alg: "RS256", kid: "NkJC..." }This is the legitimate server-side use: read kid, fetch the matching key from JWKS, then verify with a real library. Never trust header.alg to choose your verification algorithm.
Type the payload with your own claimstyped-custom-claims
import { jwtDecode, type JwtPayload } from "jwt-decode";
interface AppClaims extends JwtPayload {
"https://myapp.com/roles": string[];
email: string;
}
const claims = jwtDecode<AppClaims>(token);
const roles = claims["https://myapp.com/roles"];The generic is a cast, not a check. If the token lacks the claim you get undefined at runtime while TypeScript still believes it is a string array.
Catch malformed tokenshandle-invalid-token
import { jwtDecode, InvalidTokenError } from "jwt-decode";
try {
return jwtDecode(raw);
} catch (err) {
if (err instanceof InvalidTokenError) {
// "missing part #2", "invalid base64 for part #2", etc.
return null;
}
throw err;
}It throws for a non-string input, a missing dot, undecodable base64, or non-JSON content. An empty string or null token throws too, so guard before calling on values from localStorage.
Test whether the token is past expcheck-expiry
import { jwtDecode } from "jwt-decode";
function isExpired(token, skewSeconds = 30) {
const { exp } = jwtDecode(token);
if (!exp) return false; // no exp claim means no expiry
return exp * 1000 <= Date.now() + skewSeconds * 1000;
}exp is seconds since the epoch, Date.now() is milliseconds. Forgetting the factor of 1000 is the single most common bug with this library. This is a UI hint only; the server still has to reject the expired token.
Refresh shortly before the token diesschedule-silent-refresh
import { jwtDecode } from "jwt-decode";
function scheduleRefresh(token, refresh) {
const { exp } = jwtDecode(token);
const msLeft = exp * 1000 - Date.now() - 60_000; // 60s early
return setTimeout(refresh, Math.max(msLeft, 0));
}Browser timers do not fire on schedule in background tabs and laptops that slept. Re-check expiry on every request too instead of trusting the timer alone.
Use it from CommonJScommonjs-require
const { jwtDecode } = require("jwt-decode");
const decoded = jwtDecode(token);The package.json exports map ships both builds, so require works without a bundler shim. Destructure the named export here as well.
Make it work on older React Nativepolyfill-atob
// entry point, before any decode happens
import "core-js/stable/atob";
// or roll your own
import { decode } from "base-64";
global.atob = decode;Needed on React Native before 0.74, where Hermes had no atob. Without it the first jwtDecode call throws a ReferenceError that looks nothing like a JWT problem.
Pull scopes out of a space-delimited claimread-namespaced-claims
import { jwtDecode } from "jwt-decode";
const { scope = "" } = jwtDecode(accessToken);
const scopes = scope.split(" ").filter(Boolean);
if (scopes.includes("read:reports")) showReportsTab();OAuth puts scope in a single space-separated string, not an array. Use this to decide what to render, never to decide what the API returns.
What to do instead on the serververify-server-side
// WRONG: trusts whatever the client sent
// const { sub } = jwtDecode(bearerToken);
// RIGHT: verify the signature first
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://issuer.example.com/.well-known/jwks.json"));
const { payload } = await jwtVerify(bearerToken, jwks, {
issuer: "https://issuer.example.com/",
audience: "my-api",
});Anyone can craft a token whose payload says admin: true, and jwtDecode will happily return it. jose checks the signature, issuer, audience, and expiry before you see the claims.
Load it in a plain HTML pagescript-tag-esm
<script type="module">
import { jwtDecode } from "/vendor/jwt-decode.js";
console.log(jwtDecode(token));
</script>v4 dropped the UMD bundle, so there is no global window.jwt_decode anymore. Copy build/esm/index.js out of the package or point at an ESM CDN.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jose | npm | You need actual verification: signature checks, JWKS fetching, JWE, and it works in Node, browsers, and edge runtimes on Web Crypto. |
| jsonwebtoken | npm | Node-only server code that signs and verifies tokens with a shared secret or PEM key and wants the long-established API. |
| fast-jwt | npm | High-throughput Node services where verification cost per request matters and you want caching built into the verifier. |