Decoding a JWT by Hand: What's Really Inside That Token

Every developer who has worked with modern authentication has copy-pasted a JWT into jwt.io to peek inside it. That website is genuinely useful. But if you have never decoded one yourself — without any tool — you are missing something important: a real understanding of what a JWT actually is, why the structure exists, and where its security guarantees actually come from (and, crucially, where they do not).

This article walks through decoding a JWT entirely by hand. No libraries, no debugger websites. Just a terminal, some base64 commands, and your brain. By the end you will read any JWT the way a network engineer reads a raw packet — structure first, assumptions second.

What You Are Actually Looking At

A JSON Web Token is three base64url-encoded strings joined by dots. That is the entire format. When you see something like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlBhd2FuIiwiaWF0IjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three segments. Three dots separating them. Split on the dot and you have your parts.

The encoding used is base64url — a variant of standard base64 that replaces + with - and / with _, and strips trailing = padding. This makes the token safe to embed in a URL query parameter without percent-encoding. Neat design choice. Also one that trips people up when they try to decode it with a standard base64 decoder and get garbage.

Decoding the Header

Take just the first segment:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

In your terminal:

echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | python3 -c "
import sys, base64
seg = sys.stdin.read().strip()
# Re-pad to a multiple of 4
seg += '=' * (-len(seg) % 4)
# Convert base64url to standard base64
seg = seg.replace('-', '+').replace('_', '/')
print(base64.b64decode(seg).decode())
"

Output:

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

That is the header. Two fields. alg tells the verifying party which algorithm was used to sign the token. typ is almost always JWT and exists largely for historical reasons — it was meant to distinguish token types in systems that might handle multiple formats.

The algorithm field is where things get dangerous. A notorious class of attacks — the "algorithm confusion" or "alg:none" attack — exploits the fact that some early JWT libraries trusted this header field to determine how to verify the signature. An attacker could craft a token with "alg":"none", strip the signature, and certain naive implementations would accept it as valid. Any library written in the last five years should reject this, but the vulnerability lived in production systems for years. The header is not signed by anything at time of reading — it is just JSON that you are trusting came from the right place. Keep that in mind.

Decoding the Payload (Claims)

The second segment is the payload, also called the claims set. Same decoding process:

echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlBhd2FuIiwiaWF0IjoxNzE2MjM5MDIyfQ" | python3 -c "
import sys, base64
seg = sys.stdin.read().strip()
seg += '=' * (-len(seg) % 4)
seg = seg.replace('-', '+').replace('_', '/')
print(base64.b64decode(seg).decode())
"

Output:

{"sub":"1234567890","name":"Pawan","iat":1716239022}

This is the data the token carries. The JWT specification defines a handful of registered claims — field names with standardized meanings:

  • sub — Subject. Who this token is about. Usually a user ID.
  • iss — Issuer. Which service created the token.
  • aud — Audience. Which service(s) should accept this token.
  • exp — Expiration time. A Unix timestamp. If the current time is past this value, the token is expired and must be rejected.
  • nbf — Not Before. The token is invalid before this time.
  • iat — Issued At. When the token was minted.
  • jti — JWT ID. A unique identifier for this specific token, useful for revocation lists.

Everything else is a custom claim. Your application can put whatever it needs in here — roles, tenant IDs, feature flags. The payload is just JSON, and it is readable by anyone who has the token. This is a point that trips up developers who are new to JWTs: the payload is not encrypted. It is encoded, not secret. Do not put a user's password, credit card number, or anything sensitive in a JWT payload unless you are using JWE (JSON Web Encryption), which is a different beast entirely.

The iat timestamp above, 1716239022, decodes to roughly May 2024. You can verify this in any shell:

date -r 1716239022

This is useful when debugging API issues. When a client reports "my token stopped working," the first thing to decode is the exp claim. Nine times out of ten the token is simply expired.

The Signature: What It Actually Proves

The third segment is the signature. Unlike the first two, you cannot simply decode it to get readable text — it is raw cryptographic output, also base64url-encoded.

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

For an HS256 token, this is an HMAC-SHA256 hash computed over the string header_b64url.payload_b64url using a secret key that only the server knows. For RS256 tokens — common in enterprise and OAuth systems — it is an RSA signature using a private key, verifiable with the corresponding public key.

To manually verify an HS256 signature in Python:

import hmac, hashlib, base64

header_b64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
payload_b64 = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlBhd2FuIiwiaWF0IjoxNzE2MjM5MDIyfQ"
secret = b"your-secret-key"

message = f"{header_b64}.{payload_b64}".encode()
sig = hmac.new(secret, message, hashlib.sha256).digest()

# Encode as base64url
b64url_sig = base64.urlsafe_b64encode(sig).rstrip(b'=').decode()
print(b64url_sig)
# Compare this to the third segment of your token

If they match, the token was created with that secret. If they differ, either the secret is wrong or — more concerning — someone tampered with the header or payload after the token was issued. Because the signature covers both segments as a concatenated string, changing a single character anywhere in the header or payload will produce a completely different signature. This is the tamper-evident property that makes JWTs useful.

For RS256 tokens, the process is different — you verify against a public key, often fetched from a JWKS (JSON Web Key Set) endpoint that the identity provider publishes. The private key never leaves the authorization server. This is why RS256 is preferred for multi-service architectures: any service can verify tokens without sharing a secret.

A Note on What the Signature Does Not Protect

The signature proves the token was created by someone with the signing key and has not been modified. It does not prove:

  • That the token has not been stolen. A valid JWT presented by a different user is still valid.
  • That the user's session should still be active. There is no server-side state in a stateless JWT system. If you need instant revocation — say, after a user changes their password — you need either short expiry times, a token blocklist, or a different architecture entirely.
  • That the claims inside are accurate. The issuer could lie.

These are design tradeoffs, not bugs. JWTs trade revocability for scalability. Understanding exactly what is and is not guaranteed by the format lets you make better architecture decisions rather than blindly following tutorials that treat JWTs as magic authentication objects.

Putting It Together: A One-Liner Decode

For quick terminal debugging, this one-liner decodes both segments of a JWT and prints them as formatted JSON:

TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlBhd2FuIiwiaWF0IjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"

python3 -c "
import sys, base64, json
token = '$TOKEN'
parts = token.split('.')
for i, part in enumerate(parts[:2]):
    part += '=' * (-len(part) % 4)
    part = part.replace('-', '+').replace('_', '/')
    decoded = base64.b64decode(part).decode()
    label = 'Header' if i == 0 else 'Payload'
    print(f'--- {label} ---')
    print(json.dumps(json.loads(decoded), indent=2))
"

Add this as a shell function in your .zshrc or .bashrc and you will reach for it constantly when debugging APIs.

The Bigger Picture

Understanding JWT internals pays off in ways that go beyond debugging. When you review an API's authentication code, you know what questions to ask: Is the algorithm field validated server-side? Are the aud and iss claims checked? What is the token lifetime, and is it appropriate for this use case? Is there any revocation mechanism for compromised tokens?

The encoding and structure of a JWT are not incidental — they reflect deliberate choices about statelessness, portability, and where trust is placed. A token is just structured data with a proof of origin. Once you can read it directly, you stop treating it as a black box and start reasoning about it clearly — which is exactly where you want to be when security is on the line.