Decoding a JWT by Hand: Anatomy of a JSON Web Token

The first time I saw a JWT, I did what most people do — I stared at that long, dot-separated string and thought: "this is obviously encoded in some way, but how exactly?" Then I pasted it into jwt.io, saw my username pop up, and moved on without really understanding what happened. That was a mistake I've seen bite a lot of developers later, usually at the worst possible time.

Let's fix that today. We're going to take a real JWT, crack it open using nothing but Base64URL decoding, and read exactly what's inside — no libraries, no debugger websites, just you and a terminal.

What a JWT Actually Looks Like

Here's a typical JWT you might find sitting in your browser's localStorage or flying around in an Authorization header:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJQcml5YSBTaGFybWEiLCJpYXQiOjE3MTkwMDAwMDAsImV4cCI6MTcxOTAwMzYwMCwicm9sZSI6ImFkbWluIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Look at the structure. Two periods, three chunks. That pattern is not accidental — it's the entire architecture of a JWT laid bare. Each chunk is a separate Base64URL-encoded segment, and each one has a distinct job.

The three segments are: Header . Payload . Signature

Let's decode them one by one.

Step 1 — Decode the Header

Take the first segment: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Open a terminal and run:

echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d

On macOS, you might need to add padding first or pipe through Python:

python3 -c "import base64; print(base64.urlsafe_b64decode('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9==').decode())"

Either way, you get:

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

That's it. The header is just a small JSON object with two fields:

  • alg — the signing algorithm. Here it's HS256, meaning HMAC-SHA256. Other common values include RS256 (RSA + SHA256) and ES256 (Elliptic Curve). The algorithm tells the receiver exactly how to verify the signature in part three.
  • typ — the token type. Almost always JWT. Some tokens carry at+JWT or dpop+jwt for specific OAuth 2.0 flows, but plain JWT is the overwhelming majority.

One important detail here: this header is not encrypted. It's just encoded. Anyone who intercepts a JWT can read the header immediately. This matters because there's a well-known attack where a malicious client sends a token with "alg":"none" hoping the server accepts it unsigned. A well-written JWT library rejects this, but you should always verify your library is configured to only accept the algorithms you explicitly allow.

Step 2 — Decode the Payload (the Interesting Part)

The middle segment is where the actual data lives. Take:

eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJQcml5YSBTaGFybWEiLCJpYXQiOjE3MTkwMDAwMDAsImV4cCI6MTcxOTAwMzYwMCwicm9sZSI6ImFkbWluIn0

Decode it:

python3 -c "import base64; print(base64.urlsafe_b64decode('eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJQcml5YSBTaGFybWEiLCJpYXQiOjE3MTkwMDAwMDAsImV4cCI6MTcxOTAwMzYwMCwicm9sZSI6ImFkbWluIn0==').decode())"

Output:

{"sub":"user_123","name":"Priya Sharma","iat":1719000000,"exp":1719003600,"role":"admin"}

Now we're getting somewhere. Let's go through every field:

Registered Claims (defined by the JWT spec)

sub (Subject) — This identifies who the token is about. Here it's user_123 — probably a database user ID. The spec says this should be unique within your system or globally unique if you're dealing with multiple issuers. You'll often see this used as the primary way to look up the authenticated user in your database.

iat (Issued At) — A Unix timestamp for when the token was created. 1719000000 converts to roughly June 21, 2024 at 21:20 UTC. Your server can use this to decide if a token is "too old" independent of the expiration claim — useful if you want shorter effective windows without shortening the actual expiry.

exp (Expiration Time) — The Unix timestamp after which the token must be rejected. Here it's 1719003600, which is exactly one hour after iat. This is the most critical claim for security. Any JWT library worth using checks this automatically, but if you ever write custom token validation code, failing to check exp is a serious vulnerability.

Private Claims (your application's custom data)

name — A human-readable display name. This is not a standard JWT claim, just something this application chose to embed. Perfectly valid — the spec allows any additional fields you want.

role — Here set to admin. This is an extremely common pattern where permissions or roles get embedded directly into the token so the server doesn't need to hit the database on every request to check what the user can do.

Here's the thing that catches people off guard: that role: admin claim is also completely readable by anyone who has the token. There's no encryption happening here. This is fine for your own API — your backend trusts the signature, not the readability. But it means you should think carefully about what you embed. Sensitive PII, credit card numbers, internal pricing data — none of that belongs in a JWT payload.

Step 3 — Understanding the Signature

The third segment is different from the first two. You cannot decode it into readable JSON, because it's not JSON — it's a cryptographic hash.

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

What this actually is: the HMAC-SHA256 hash of the string header_encoded + "." + payload_encoded, computed using a secret key that only the server knows.

To be precise, the server computed:

HMAC-SHA256(
  key = "your-256-bit-secret",
  data = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIs..."
)

Then it Base64URL-encoded the resulting bytes to get that signature string.

When your backend receives a JWT, it re-runs this computation using the same secret key and compares results. If they match, the token is legitimate and untampered. If someone modified even a single character in the payload — say, changed "role":"user" to "role":"admin" — the signature would no longer match, and the server would reject it.

This is the fundamental guarantee of a JWT: the payload can be read by anyone, but it can only be created or modified by someone who holds the signing key.

The Base64URL Quirk (and Why It Exists)

You might have noticed I keep saying "Base64URL" rather than just "Base64." The difference matters in practice. Standard Base64 uses + and / as its 62nd and 63rd characters, and pads output with =. All three of those characters are special in URLs — + means a space in query strings, / is a path separator, and = is an assignment operator.

Base64URL swaps these out: + becomes -, / becomes _, and the = padding is usually stripped entirely. That's why you need to add == back when decoding manually in Python — the standard library expects standard padding.

This is also why JWTs can safely sit in URL query parameters, cookies, and Authorization headers without needing additional percent-encoding. The encoding was specifically designed for this use case.

Try This Right Now

If you have a JWT from any real application you're working on, here's a one-liner to decode all three parts at once (macOS/Linux):

python3 -c "
import base64, json, sys

token = 'PASTE_YOUR_JWT_HERE'
parts = token.split('.')

for i, part in enumerate(['Header', 'Payload', 'Signature']):
    if i < 2:
        padded = parts[i] + '==' * (4 - len(parts[i]) % 4 if len(parts[i]) % 4 else 0)
        decoded = base64.urlsafe_b64decode(padded).decode()
        print(f'\n--- {part} ---')
        print(json.dumps(json.loads(decoded), indent=2))
    else:
        print(f'\n--- {part} (raw, not decodable to JSON) ---')
        print(parts[i])
"

Replace PASTE_YOUR_JWT_HERE with an actual token and you'll see a formatted breakdown in seconds.

What to Watch Out For

A few things that have caused real incidents:

Not validating expiry on the backend. Frontend code might show a logged-in user even after a token expires. Your API must reject expired tokens regardless of what the frontend says.

Embedding too much in the payload. Every token in every Authorization header carries this data in plaintext. Keep payloads small and free of sensitive information.

Using weak or hardcoded secrets with HS256. The signature is only as strong as the key. A secret like "secret" or "password" can be brute-forced. Use a cryptographically random 256-bit key minimum.

Trusting algorithm negotiation blindly. Pin your JWT library to a specific expected algorithm. Never let the client header dictate what algorithm the server uses to verify.

The Takeaway

A JWT is three Base64URL chunks separated by dots. The first describes the algorithm. The second carries your claims — user ID, permissions, expiry — in plain, readable JSON. The third is a cryptographic signature that ties the first two together and makes tampering detectable.

Nothing is hidden. Everything is signed. That's the whole design. Once you've decoded a JWT by hand even once, the entire concept clicks into place in a way that no diagram or documentation quite achieves.