Building a JWT Debugger in the Browser: Base64URL Decoding and Signature Verification
Building a JWT Debugger in the Browser: Base64URL Decoding and Signature Verification#
If you’re a backend developer, you’ve probably pasted a JWT into jwt.io more times than you can count. But have you ever thought about what happens behind the scenes when you hit that “decode” button? Let’s walk through building one from scratch.
The Three-Part JWT Structure#
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three dot-separated segments: Header, Payload, Signature. The Header and Payload are JSON objects encoded in Base64URL. The Signature is computed from the first two parts plus a secret key using a specified algorithm.
Decoding is simply reversing this process.
Base64URL vs Standard Base64#
Here’s a subtle detail most people overlook. Standard Base64 uses +, /, and = characters. These are URL-unsafe — + gets interpreted as space, and / conflicts with URL path separators.
JWT uses Base64URL, a variant with two character substitutions:
+→-/→_- Trailing
=is stripped
To decode, you reverse the substitution:
function base64UrlDecode(str: string): string {
const base64 = str
.replace(/-/g, '+')
.replace(/_/g, '/')
return atob(base64)
}
function decodeJwt(token: string) {
const parts = token.split('.')
if (parts.length !== 3) return null
const header = JSON.parse(base64UrlDecode(parts[0]))
const payload = JSON.parse(base64UrlDecode(parts[1]))
const signature = parts[2]
return { header, payload, signature }
}
That’s essentially all you need for a basic decoder.
The Signature Verification Problem#
Decoding requires no secret key — anyone can read a JWT. But verifying the signature does require a key, and that creates a problem for browser-based tools.
HMAC algorithms (HS256/HS384/HS512) use symmetric signing — the same key signs and verifies. If you put the key in a browser tool, you’ve effectively leaked the signing key to anyone who opens DevTools.
RSA algorithms (RS256/RS384/RS512) use asymmetric signing — a private key signs, a public key verifies. The public key is safe to expose in the browser.
A well-designed JWT debugger offers two modes:
- Decode only (no key needed, show Header and Payload)
- Verify signature (user provides the key, using Web Crypto API)
Browser-side HMAC verification with the Web Crypto API:
async function verifySignature(
token: string,
secret: string,
algorithm: string
): Promise<boolean> {
const parts = token.split('.')
const data = new TextEncoder().encode(parts[0] + '.' + parts[1])
const signature = base64UrlToUint8Array(parts[2])
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: algorithm },
false,
['verify']
)
return crypto.subtle.verify('HMAC', key, signature, data)
}
Gotcha: atob Doesn’t Handle Unicode#
atob() only works with ASCII characters. If the decoded JSON contains Unicode (e.g., Chinese characters, emoji), JSON.parse will fail.
The fix is to use Uint8Array + TextDecoder instead:
function base64UrlDecode(str: string): string {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return new TextDecoder().decode(bytes)
}
This handles any Unicode characters correctly.
Security Red Flags Your Tool Should Catch#
A good JWT debugger does more than decode — it also helps users spot security issues.
The alg: none attack. Some badly configured JWT libraries skip signature verification when alg is set to none. An attacker can forge a token with {"alg":"none"} in the header. Your tool should flag these tokens with a security warning.
Algorithm confusion. An attacker changes the header alg from RS256 to HS256, then signs the token using the RSA public key as an HMAC secret. If the server reuses that public key for HMAC verification, the forged token passes. Your tool should remind users that verification algorithm must match the header’s alg claim.
Expiration checking. Decode the exp claim from the payload and compare it to the current time. Visual indicators (green for valid, red for expired) are surprisingly useful during debugging.
Putting It All Together#
Decoding a JWT is deceptively simple — it boils down to splitting on dots and Base64URL-decoding. But building a robust, browser-based JWT debugger means handling Unicode edge cases, supporting multiple signature algorithms via Web Crypto API, and surfacing security warnings that help users spot vulnerabilities before they hit production.
The source code is fully open, and you can find the complete implementation in the project repo. Give it a try next time you need to inspect a token.
Related tools: Base64 Encoder/Decoder | Timestamp Converter | UUID Generator