Decode a JWT in JavaScript

JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))))

This line reads a JWT’s payload in any browser and in Node 16 and later:

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiZXhwIjoxODkzNDU2MDAwfQ.72UkVFWtIXY6nrOr15MKLJZHX3siVEPNHrmmE3MkQBs';
JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))));
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }

A JWT is three base64url parts joined by dots: header, payload and signature. The line takes the middle one, turns base64url back into standard Base64, decodes the bytes as UTF-8 and parses the JSON. As a function, for either part:

function decodePart(part) {
  const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
  const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
  return JSON.parse(new TextDecoder().decode(bytes));
}

const [header, payload] = token.split('.');
decodePart(header);
// { alg: 'HS256', typ: 'JWT' }
decodePart(payload);
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }

The decoder below shows the same header and payload for this token. In Node, Buffer reads base64url directly:

JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }

The details that break it

  • The alphabet. JWTs use base64url (RFC 7515), with - and _. atob reads only + and /, so atob(payload) on this token throws InvalidCharacterError because of its _.
  • Padding. JWTs leave off the = padding. atob doesn’t need it, so there’s nothing to add back; Python’s decoder and some libraries do need it.
  • Unicode. atob returns one character per byte. Without TextDecoder, JSON.parse(atob(...)) succeeds and gives the name as Zoë, because the two UTF-8 bytes of ë become two characters.
  • Seconds. exp, iat and nbf count seconds, and Date counts milliseconds: the token has expired once Date.now() >= payload.exp * 1000. Here, new Date(payload.exp * 1000).toISOString() is 2030-01-01T00:00:00.000Z.

Decoding isn’t verifying

Anyone can write a token that decodes to whatever they like. Change false to true, re-encode the payload, keep the old signature, and the code above reads it without complaint:

const [h, , signature] = token.split('.');
const claims = { sub: 'user_8231', name: 'Zoë', admin: true, exp: 1893456000 };
const forged = `${h}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.${signature}`;

decodePart(forged.split('.')[1]);
// { sub: 'user_8231', name: 'Zoë', admin: true, exp: 1893456000 }

Decode in the browser to show a name or schedule a refresh. A server that acts on a claim must verify the signature first, with the algorithms it expects named (RFC 8725). With jose:

import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload: verified } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
verified;
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }
await jwtVerify(forged, secret, { algorithms: ['HS256'] });
// JWSSignatureVerificationFailed: signature verification failed

With jsonwebtoken, jwt.verify(token, secret, { algorithms: ['HS256'] }) returns the payload, and the forged token throws JsonWebTokenError: invalid signature. Its jwt.decode() is the decode-only call. Both libraries check exp as part of verifying: once it has passed, jose throws JWTExpired and jsonwebtoken throws TokenExpiredError: jwt expired.

Try it in the JWT Decoder

Nothing leaves your browser: the token is decoded here, in the page, and never sent anywhere.

Valid now: it expires in 4 years.

The three parts

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiZXhwIjoxODkzNDU2MDAwfQ.72UkVFWtIXY6nrOr15MKLJZHX3siVEPNHrmmE3MkQBs

headerpayloadsignature

The algorithm is HS256: HMAC with SHA: one shared secret makes the signature and checks it, so anyone who can check this token can also forge it. Decoding only reads what the token says: nothing here checks the signature, so a token shown on this page can still be forged or altered.

header
{
  "alg": "HS256",
  "typ": "JWT"
}
payload
{
  "sub": "user_8231",
  "name": "Zoë",
  "admin": false,
  "exp": 1893456000
}
Claims
ClaimMeansIn this token
subSubject is who the token is about, usually a user iduser_8231
expExpires is when the token stops being accepted18934560002030-01-01T00:00:00.000Z

Runs in your browser. Nothing you enter is sent anywhere.