How to Decode a JWT Token in JavaScript (No Libraries)

A JWT's header and payload are Base64URL-encoded JSON — meaning you can read them with nothing but atob() and JSON.parse(). This guide shows the full decode function, how to handle expiration, Node.js compatibility, and when to reach for a library.

The Base64URL Decoding Approach

A JWT has three parts separated by dots: header.payload.signature. The header and payload are Base64URL-encoded JSON. Base64URL is almost identical to Base64, but uses - instead of + and _ instead of / (URL-safe characters), and omits padding = characters.

To decode it with the browser's atob(), you need to:

  1. Replace - with +
  2. Replace _ with /
  3. Add padding == if needed (length must be a multiple of 4)
  4. Call atob() to decode
  5. Call JSON.parse() on the result

Full decodeJwt() Function

function decodeBase64Url(str) { // Convert Base64URL to Base64 const base64 = str .replace(/-/g, '+') .replace(/_/g, '/'); // Add padding const padded = base64.padEnd( base64.length + (4 - base64.length % 4) % 4, '=' ); return atob(padded); } function decodeJwt(token) { const parts = token.split('.'); if (parts.length !== 3) { throw new Error('Invalid JWT: must have 3 parts'); } const [encodedHeader, encodedPayload] = parts; return { header: JSON.parse(decodeBase64Url(encodedHeader)), payload: JSON.parse(decodeBase64Url(encodedPayload)), // signature is not decoded — it's binary, not JSON }; } // Usage const { header, payload } = decodeJwt( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + '.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzM1Njg5NjAwfQ' + '.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' ); console.log(header); // { alg: 'HS256', typ: 'JWT' } console.log(payload); // { sub: '1234567890', name: 'John Doe', exp: 1735689600 }

Handling Expiration (exp Claim)

The exp claim is a Unix timestamp in seconds (not milliseconds). Compare it with Date.now() / 1000:

function getJwtStatus(token) { const { payload } = decodeJwt(token); const nowSecs = Date.now() / 1000; return { subject: payload.sub, issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : null, expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : null, isExpired: payload.exp ? payload.exp < nowSecs : false, secsUntilExpiry: payload.exp ? Math.round(payload.exp - nowSecs) : null, }; } // Example output // { // subject: '1234567890', // issuedAt: '2024-01-01T00:00:00.000Z', // expiresAt: '2025-01-01T00:00:00.000Z', // isExpired: true, // secsUntilExpiry: -31536000 // }

atob vs Buffer in Node.js

atob() was added to Node.js in v16. For older Node.js environments, use Buffer:

// Universal: works in browser and all Node.js versions function decodeBase64Url(str) { const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64.padEnd( base64.length + (4 - base64.length % 4) % 4, '=' ); if (typeof Buffer !== 'undefined') { // Node.js return Buffer.from(padded, 'base64').toString('utf8'); } else { // Browser return atob(padded); } }

TypeScript Typed Version

interface JwtHeader { alg: string; typ: string; kid?: string; } interface JwtPayload { sub?: string; iss?: string; aud?: string | string[]; exp?: number; iat?: number; nbf?: number; jti?: string; [key: string]: unknown; // custom claims } interface DecodedJwt { header: JwtHeader; payload: JwtPayload; } function decodeJwt(token: string): DecodedJwt { const parts = token.split('.'); if (parts.length !== 3) throw new Error('Invalid JWT'); const decode = (str: string): unknown => { const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); return JSON.parse(atob(padded)); }; return { header: decode(parts[0]) as JwtHeader, payload: decode(parts[1]) as JwtPayload, }; } // Type-safe usage const { payload } = decodeJwt(token); const userId = payload.sub; // string | undefined const expiresAt = payload.exp; // number | undefined const role = payload['role'] as string; // custom claim

Verifying vs Decoding — When to Use a Library

Manual decoding is fine for reading a JWT — inspecting claims, checking expiration display, debugging. But if you need to trust the claims and use them to authorize access, you need to verify the signature.

Use caseApproach
Debugging — read payload in browser devtoolsManual decode (this guide)
Display user name/email from token in UIManual decode (safe, display-only)
Server-side auth middlewareLibrary: jose or jsonwebtoken
Verify token before granting accessLibrary: jose or jsonwebtoken
Generate signed tokensLibrary: jose or jsonwebtoken

Related Tools

Related Guides

FAQ

Why can I decode a JWT without a library?

A JWT's header and payload are simply Base64URL-encoded JSON strings. Base64URL is a variant of Base64 that uses - instead of + and _ instead of /. Since browsers have a built-in atob() function for Base64 decoding, you can decode a JWT with just a few lines of vanilla JavaScript.

Does decoding a JWT verify its signature?

No. Decoding only reads the payload — it does not verify that the token was signed by the expected server. Never trust the claims in a decoded JWT without server-side signature verification. Decoding is useful for debugging and inspecting claims, but is not a security measure.

What is the difference between atob() and Buffer in Node.js?

atob() is the browser's built-in Base64 decoder, available in browsers and Node.js 16+. In older Node.js versions, use Buffer.from(str, 'base64').toString('utf8'). For Base64URL (JWT encoding), replace - with + and _ with / before decoding with either method.

How do I check if a JWT is expired?

Decode the payload and read the exp claim, which is a Unix timestamp (seconds since epoch). Compare it with Date.now() / 1000. If exp < Date.now() / 1000, the token is expired.

When should I use a library instead of manual decoding?

Use a library (like jose or jsonwebtoken) when you need to verify signatures, not just decode. Signature verification requires cryptographic operations and knowledge of the algorithm and key. For read-only decoding of non-sensitive tokens during debugging, manual decoding is perfectly fine.