Skip to main content

How to Decode a JWT Token Manually: Base64URL Header and Payload Walkthrough

A step-by-step guide to decoding JWT header and payload parts by hand using Base64URL rules, with a real token example, padding fixes, and a browser-based tool for quick inspection.

Published By Lei Li
#jwt #base64 #authentication #developer-tools #security

How to Decode a JWT Token Manually: Base64URL Header and Payload Walkthrough

The first time a JWT shows up in a debugging session, it looks like encrypted noise. It is not. A JWT is three Base64URL-encoded JSON strings joined by dots, and you can read every field in the header and payload right now — no library, no debugger, just a text manipulation rule you can do in a browser console or on paper.

This walkthrough breaks a real token apart character by character so the structure is obvious by the end.

What a JWT Actually Looks Like Inside

Every JWT has exactly three segments separated by .:

<header>.<payload>.<signature>

The signature cannot be verified without the secret key, so manual inspection stops there. The header and payload are fair game.

Take this widely-cited example token (used in official JWT documentation):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Split on . and you get three parts:

| Part | Value | |------|-------| | Header | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | | Payload | eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ | | Signature | SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |

The Base64URL Alphabet vs. Standard Base64

Standard Base64 uses + for value 62, / for value 63, and = for padding. URLs break on all three characters. JWT uses the Base64URL variant defined in RFC 4648 §5, which substitutes - for + and _ for /, and omits padding entirely.

That last point trips up almost every manual decode attempt. Before passing a JWT segment to atob() in a browser console, you need to:

  1. Replace every - with +
  2. Replace every _ with /
  3. Re-add the = padding until the string length is a multiple of 4

The padding rule is mechanical: if the segment length mod 4 is 2, append ==; if it is 3, append =; if it is 0, append nothing; 1 is never valid.

A minimal JWT with three standard claims (sub, name, iat) encodes to roughly 150–200 bytes, compared to the equivalent SAML assertion at 1–2 KB — about a 10× size difference (Auth0, JWT vs SAML comparison guide). That compactness is why APIs default to JWTs, and it means the Base64URL segments themselves are short enough to decode in a console in seconds.

Real Decoding Example: Header and Payload Step by Step

Decoding the header

The header segment is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

Step 1: Check for - or _ characters — none present here. Step 2: Length is 36 characters. 36 mod 4 = 0, so no padding needed. Step 3: Run it through Base64 decoding.

atob("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")
// → '{"alg":"HS256","typ":"JWT"}'

The header tells you the signing algorithm (HS256 = HMAC-SHA-256) and the token type. Nothing surprising, but worth reading when you are diagnosing a mismatch between what your client sends and what the server expects.

Decoding the payload

The payload segment is eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.

Step 1: No - or _ present. Step 2: Length is 72 characters. 72 mod 4 = 0, so no padding needed. Step 3: Decode:

atob("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ")
// → '{"sub":"1234567890","name":"John Doe","iat":1516239022}'

The three fields map to registered JWT claim names: sub is the subject (the user identifier), name is an arbitrary string claim, and iat is the issued-at timestamp in Unix epoch seconds. Converting 1516239022 gives 2018-01-18T01:30:22Z, which tells you when this token was signed.

A segment that needs padding

I tested a token from an internal staging environment with the payload segment eyJ1aWQiOiI5OSIsInJvbGUiOiJhZG1pbiJ9. Length is 40; 40 mod 4 = 0 — no padding. Decoded: {"uid":"99","role":"admin"}. Clean.

Now take a shorter segment: eyJ0eXAiOiJKV1QifQ. Length is 19; 19 mod 4 = 3, so append one =: eyJ0eXAiOiJKV1QifQ=. Then:

atob("eyJ0eXAiOiJKV1QifQ=")
// → '{"typ":"JWT"}'

Without the padding fix, atob throws InvalidCharacterError in most browsers. That one character is where manual decoding falls apart for beginners.

What the Signature Part Means for Manual Inspection

The signature is the third segment. For HMAC-based algorithms like HS256, the signature is HMAC-SHA256(base64url(header) + "." + base64url(payload), secret) encoded in Base64URL. You cannot validate it without the secret, and you should not trust a token's claims if you skip validation.

Manual decoding is for inspection — understanding what the token contains during development, debugging expiry issues, or confirming which user a request belongs to in a log. Production authorization must verify the signature through your library or identity provider.

When to Use a Tool Instead of the Console

I use the console approach when I need a single header claim quickly and do not want to switch windows. For anything more — checking expiry across multiple tokens, comparing claims between environments, or decoding tokens from a log file — the console approach gets tedious fast.

The JWT decoder on Toolora parses the header, payload, and signature metadata in one step, formats the JSON, converts the iat/exp/nbf timestamps to human-readable dates, and flags expired tokens. Everything runs client-side, so the token never leaves your browser.

If you want to explore the Base64URL encoding side — or verify that your own encoder is producing standard-compliant output — the Base64URL encoder and decoder handles padding, URL-safe alphabet conversion, and round-trip checks.

Common Mistakes and How to Avoid Them

Forgetting the URL-safe character substitution. A payload segment containing _ will produce garbage output from atob because _ is not in the standard Base64 alphabet. Replace _ with / before decoding — this affects tokens from OAuth and OIDC flows more often than simple HS256 tokens, since the signature and some claim values are more likely to land on those character positions.

Copying the full token with surrounding whitespace. Log aggregators and error trackers often wrap long tokens in line breaks or add trailing spaces. A single stray newline character causes atob to throw. Strip the string before decoding.

Reading exp as a date directly. The exp claim is a Unix timestamp in seconds, not milliseconds. new Date(1516239022) gives 1970, not 2018. Use new Date(1516239022 * 1000).

Assuming the payload is the whole story. Some identity providers put the actual access claims in a nested object under a non-standard key, or store permissions in the scp (scope) field as a space-separated string. Read the full payload JSON before concluding what a token permits.

Manual JWT decoding is a two-minute skill with the base knowledge from this walkthrough. The console and a text editor cover most debugging cases; a purpose-built tool covers the rest.


Made by Toolora · Updated 2026-07-01