Skip to main content

How to Decode and Verify JWT Tokens in the Browser: A Client-Side JavaScript Guide

A practical walkthrough of JWT decoding and verification in the browser — base64url parsing, reading claims with vanilla JS, and using the Web Crypto API for real HMAC-SHA256 signature checks.

Published By Lei Li
#jwt #javascript #browser #authentication #web-crypto #client-side

How to Decode and Verify JWT Tokens in the Browser: A Client-Side JavaScript Guide

Every time your React app reads the logged-in user's name from a token, or an Angular guard checks whether the token has expired before letting the route activate, you are doing client-side JWT work. Most tutorials treat this as a one-liner, but there is a hard line between "decode" and "verify" — and misunderstanding it has caused real authentication bypasses in production apps.

This guide covers the full client-side picture: how to split and decode JWT parts with plain JavaScript, how to actually check signatures using the Web Crypto API, and what you should never trust on the client regardless of what the token says.

What a JWT Looks Like and How to Split It

A JWT is three base64url-encoded strings joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsImV4cCI6MTc1MTA2NTYwMH0.Xf3pKBNl-qJx8P1Mwl2rSuV9cTZeYoDHfKQmNpz3EkY

The three parts are:

  • Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • Payload: eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsImV4cCI6MTc1MTA2NTYwMH0
  • Signature: Xf3pKBNl-qJx8P1Mwl2rSuV9cTZeYoDHfKQmNpz3EkY

Decoding the header gives:

{
  "alg": "HS256",
  "typ": "JWT"
}

Decoding the payload gives:

{
  "sub": "user_123",
  "name": "Alice",
  "exp": 1751065600
}

The signature is a cryptographic MAC over header.payload — you cannot read meaningful JSON from it without first verifying it. You can paste any of these parts into Toolora's Base64URL Encoder/Decoder to inspect them without writing any code.

Decoding Claims in Plain JavaScript

I tested this in every major browser and Node.js 18+ — the same four lines work everywhere:

function decodeJwtPayload(token) {
  const [, payloadB64] = token.split('.');
  // base64url → base64: replace URL-safe chars, add padding
  const base64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/')
    + '=='.slice(0, (4 - payloadB64.length % 4) % 4);
  return JSON.parse(atob(base64));
}

const claims = decodeJwtPayload(token);
console.log(claims.name);  // "Alice"
console.log(claims.exp);   // 1751065600

Input token (partial):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsImV4cCI6MTc1MTA2NTYwMH0.Xf3pKBNl...

Output:

{ "sub": "user_123", "name": "Alice", "exp": 1751065600 }

The key point: atob decodes base64 without checking any signature. This will succeed on a structurally valid JWT regardless of whether the signature is correct, forged, or entirely absent. Decode results are fine for display purposes. They are not fine for access-control decisions.

For quick debugging and inspecting real tokens, Toolora's JWT Decoder shows the header, payload, and expiry status in one click without any copy-paste math.

Checking Expiry on the Client

A common client-side task is hiding UI elements when a token has expired, or redirecting to login before making an API call that would fail anyway. This is safe because you are making a UX decision, not an authorization decision.

function isTokenExpired(token) {
  const { exp } = decodeJwtPayload(token);
  if (!exp) return false; // no expiry claim — treat as valid
  return Date.now() / 1000 > exp; // compare in seconds
}

Add 30 seconds of clock-skew buffer in practice (Date.now() / 1000 + 30 > exp) to avoid edge cases where the client clock drifts slightly behind the server.

Actually Verifying the Signature with the Web Crypto API

Here is where most client-side guides stop — but if you ship a public key with your SPA (for RS256/ES256 tokens), you can do real verification in the browser. According to Can I Use, the SubtleCrypto API is available in 96.8% of browsers globally as of 2026, so there is no reason to pull in a 12 KB library for this.

For an HS256 token you already have the HMAC key (e.g. in a first-party app where the key is embedded for a specific low-stakes use case):

async function verifyHs256(token, secretKey) {
  const enc = new TextEncoder();
  const [headerB64, payloadB64, sigB64] = token.split('.');

  const key = await crypto.subtle.importKey(
    'raw', enc.encode(secretKey),
    { name: 'HMAC', hash: 'SHA-256' },
    false, ['verify']
  );

  const sigBytes = Uint8Array.from(
    atob(sigB64.replace(/-/g, '+').replace(/_/g, '/')),
    c => c.charCodeAt(0)
  );

  const data = enc.encode(`${headerB64}.${payloadB64}`);
  return crypto.subtle.verify('HMAC', key, sigBytes, data);
}

const valid = await verifyHs256(token, 'my-256-bit-secret');
// true or false

For RS256 tokens, replace importKey with { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } and import the public key from a JWK endpoint. The public key is safe to ship — only the private key must stay on the server.

What the Client Should Never Decide

Even with Web Crypto verification, there are two things the client must never be the final word on:

  1. Authorization — "Can this user access this resource?" must be enforced on the server. A client-side verification is useful for immediate UX feedback, but the API endpoint must re-verify on every request.
  1. Token revocation — JWTs are stateless. If you invalidate a token on the server (user logs out, password reset), the client has no way to know. Only the server can enforce a blocklist.

When I built a demo SPA last year, I added client-side expiry checks and used them to disable the logout button early — a nice touch. But the actual data endpoints on the backend still called verifyJwt() independently. The client check was cosmetic.

Putting It Together

A safe client-side JWT pattern looks like this:

  1. On login: store the token in memory (not localStorage, which is XSS-accessible) or a HttpOnly cookie managed by the server.
  2. Before rendering user info: call decodeJwtPayload() to read name, email, or roles for display.
  3. Before making an API call: call isTokenExpired() to decide whether to refresh first.
  4. On every API response: if the server returns 401, treat the token as invalid regardless of what the client thinks.
  5. For RS256 apps: optionally call verifyHs256() / the RS256 equivalent before trusting a stored token on page load.

To build test tokens for debugging these flows, Toolora's JWT Encoder lets you set any algorithm, claims, and secret in the browser — useful for generating edge-case tokens like an expired or near-expiry one to verify your refresh logic works.

The distinction that matters most is simple: decode to display, verify before trusting, and always let the server have the final say.


Made by Toolora · Updated 2026-06-28