JSON Web Tokens (JWTs) are everywhere. Whether you're debugging an OAuth flow, a rogue microservice, or a broken single-page application, inspecting a JWT is a daily task for most developers.
But there's a massive, glaring problem with how we usually do it: We paste production tokens into random third-party websites.
Many online JWT decoders send your token to their backend to parse or verify it. If that token contains sensitive claims, PII, or internal routing data - and if it hasn't expired - you've just leaked it.
I was tired of wondering if a random tool was logging my tokens, so I decided to build a privacy-first JWT Inspector for my tool hub, ToolsMatic.
The goal? Zero backend. Zero dependencies. 100% client-side processing.
Here's how I built it using nothing but Vanilla JavaScript and the native Web Crypto API.
Step 1: Safely Decoding Base64URL in the Browser
A JWT is just three strings separated by dots (header.payload.signature), encoded in Base64URL.
The first challenge is that the browser's native atob() function only understands standard Base64, not Base64URL (which swaps + and / for - and _, and removes the = padding).
To decode the token without a library, we have to normalize the string back to standard Base64 first:
const b64urlDecodeToString = (part) => {
try {
// 1. Swap characters back to standard Base64
const norm = part.replace(/-/g, '+').replace(/_/g, '/');
// 2. Add back the missing '=' padding
const pad = '='.repeat((4 - (norm.length % 4)) % 4);
// 3. Decode to binary, then convert to a string
const str = atob(norm + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i += 1) bytes[i] = str.charCodeAt(i);
return new TextDecoder('utf-8').decode(bytes);
} catch (err) {
return null; // Malformed padding or characters
}
};
With this, decoding the header and payload is as simple as splitting the token by . and running JSON.parse().
Step 2: The Hard Part - Cryptographic Verification
Decoding the JSON is easy, but a JWT is useless if you can't verify its cryptographic signature.
Most people reach for the jsonwebtoken npm package for this, but that requires a Node.js backend. Instead, we can use the browser's native window.crypto.subtle API.
Verifying HMAC (HS256)
HMAC algorithms (like HS256) use a shared secret. We need to import the secret key as raw bytes, and then calculate the signature over the header.payload string to see if it matches the token's signature.
// The data we are verifying is the first two parts of the token
const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
const secretMaterial = "my-super-secret-key";
// 1. Import the raw secret into the Web Crypto API
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secretMaterial),
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
// 2. Generate a signature for our data using the secret
const sig = await crypto.subtle.sign('HMAC', key, data);
// 3. Encode our generated signature and compare it!
const calc = b64urlEncode(new Uint8Array(sig));
const isValid = (calc === parts[2]);
Verifying RSA (RS256)
RSA algorithms (like RS256) are more complex because they use a public/private key pair. You verify the token using a PEM-formatted Public Key.
The Web Crypto API requires us to strip out the -----BEGIN PUBLIC KEY----- headers, convert the base64 payload into an ArrayBuffer, and import it as an spki key.
// 1. Clean the PEM string and convert to ArrayBuffer
const clean = pem.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/g, '');
const raw = atob(clean);
const keyBuffer = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) keyBuffer[i] = raw.charCodeAt(i);
// 2. Import the Public Key
const publicKey = await crypto.subtle.importKey(
'spki',
keyBuffer.buffer,
{ name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } },
false,
['verify']
);
// 3. Verify the signature against the data
const sigBytes = b64urlDecode(parts[2]);
const isValid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
publicKey,
sigBytes,
data
);
Step 3: Accounting for Clock Skew
One of the most frustrating JWT bugs happens when your authorization server and your API server's clocks are out of sync by a few seconds. A token might be rejected as "expired" (exp) or "not valid yet" (nbf).
To make this a professional debugging tool, I added a manual Clock Skew slider. When validating claims, the tool simply offsets the current time:
const nowSec = Math.floor(Date.now() / 1000);
const skew = 60; // Allow 60 seconds of drift
if (payload.exp && (payload.exp + skew < nowSec)) {
console.error(`Expired ${Math.round(nowSec - payload.exp)}s ago`);
}
The Result
By combining basic base64url parsing with the native crypto.subtle API, I was able to build a robust JWT inspector that supports HS256/384/512, RS256/384/512, and claim validation - all without sending a single byte over the network.
If you're debugging tokens and want to make sure your data stays on your machine, you can use the live tool here: ToolsMatic JWT Inspector.
Have you ever accidentally leaked a token to a debugging tool? Let me know in the comments!
Top comments (0)