Three Dots, Three Parts, No Secret Required

A JWT, short for JSON Web Token, is the long string starting with eyJ that shows up in an Authorization header, a session cookie, or an OAuth response almost anywhere modern web authentication is used. It looks like ciphertext at a glance, dense and unreadable. It is not ciphertext. A JWT is three segments, each encoded in Base64url, joined by two dots:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMzQ1IiwibmFtZSI6IkpvaG4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODMwMjAwMDAsImV4cCI6MTc4MzAyMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The first two segments, header and payload, decode straight to readable JSON text. No secret key, no library, and no special permission is required to read them; anyone holding the token can read exactly what it says. That single fact is the most important thing to understand about JWTs before touching any auth code, and it drives every security rule further down this page.

Decoding the Header by Hand

Take the first segment: eyJhbGciOiJIUzI1NiJ9. JWTs use Base64url rather than standard Base64, which means two characters are swapped: a - stands in for +, and a _ stands in for /. This segment happens to use neither swapped character, so it decodes the same way standard Base64 would. Pasting it into a browser console:

> atob('eyJhbGciOiJIUzI1NiJ9')
'{"alg":"HS256"}'

Or paste it into our Base64 decoder for the same result without opening developer tools at all. The header tells you which algorithm signed the token. HS256 means HMAC with SHA-256, the same hash and hashing family covered in our hashing guide. RS256 would mean RSA public key signing instead, a different trust model entirely.

Decoding the Payload by Hand

The middle segment is where the actual claims live, and it is the part worth learning to read fluently. Take eyJzdWIiOiJ1c2VyXzEyMzQ1IiwibmFtZSI6IkpvaG4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODMwMjAwMDAsImV4cCI6MTc4MzAyMzYwMH0 and decode it the same way:

{"sub":"user_12345","name":"John","role":"admin","iat":1783020000,"exp":1783023600}

Reading each claim: sub is the subject, the user this token identifies. name and role are custom claims this particular application chose to add; nothing in the JWT spec requires them, they are simply convenient data the issuer decided to embed. iat, issued at, and exp, expiry, are both Unix timestamps, seconds since January 1970. Subtracting the two in this example gives 3600 seconds, meaning this token is valid for exactly one hour from issuance.

This is genuinely useful outside of security research. If a user reports "I keep getting logged out" or "I'm unauthorized for no reason," decoding their token and checking whether exp has already passed, or whether an expected role claim is simply missing, answers the question in about ten seconds, faster than reproducing the bug in a debugger.

Why the Signature Stays Unreadable

The third segment is different in kind from the first two. It is not JSON encoded in Base64url waiting to be decoded into text; it is raw binary output from a cryptographic signing operation, run over the header and payload combined with a secret or private key. Feeding it to a Base64 decoder produces garbage bytes, not text, because there was never text there to begin with. That is by design. The signature exists so that a server holding the correct key can verify the header and payload have not been altered since signing; it carries no readable content of its own.

The One Rule That Prevents Most JWT Security Bugs

Encoded is not encrypted. Anyone who has the token can read the payload in full. The signature stops tampering; it does nothing to stop disclosure.

This has direct consequences for how tokens should be built and handled:

  • Never put real secrets in a payload. No plaintext passwords, no API keys, no more personal data than the token's stated purpose actually needs. Every proxy, load balancer log, browser extension, and error tracking tool that ever sees the token also sees everything inside it.
  • Reading is not the same as trusting. You, or an attacker, can decode any JWT freely, but a server receiving one must verify the signature before believing a single claim inside it. An unverified JWT is nothing more than a JSON suggestion someone handed you. This is exactly why attackers do occasionally hand edit a decoded payload, for example changing "role":"user" to "role":"admin", then re-encode it and try their luck against a server that forgot to verify.
  • Watch for algorithm confusion. A well documented historical class of attacks involved setting the header's alg field to none, or swapping an expected RS256 token for one signed with HS256 using the public key as an HMAC secret, tricking a sloppily written verifier into accepting a forged token. Modern, maintained JWT libraries pin the expected algorithm and reject mismatches; this class of bug shows up almost exclusively in hand rolled verification code.
  • Treat a JWT exactly like a password in transit. A stolen token is a stolen session, full stop, the same mechanism covered from the attacker's side in our session hijacking guide. Short expiry windows and HTTPS everywhere are the standard, non optional mitigations.

Decoding in Three Languages

// JavaScript, for debugging only, this does NOT verify anything
const [headerB64, payloadB64] = token.split('.');
const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));

// PHP
$parts = explode('.', $token);
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);

// Python
import base64, json
parts = token.split('.')
padded = parts[1] + '=' * (-len(parts[1]) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))

Notice the Python example pads the string with = characters before decoding. Base64 requires the encoded string's length to be a multiple of four; JWTs strip that padding to keep the token shorter, so some decoders need it added back before they will accept the input. This is the single most common "why won't this decode" error people hit when writing their own quick decoding snippet.

None of these three snippets check the signature, the expiry, the issuer, or the audience. They exist purely so a developer can look inside a token while debugging. For anything that actually authenticates a request, use a maintained library in your language of choice that performs full verification; if you are building a login system from scratch, our developer guide to adding 2FA covers the wider authentication stack a JWT typically sits inside.

Frequently Asked Questions

Why does almost every JWT start with the same eyJ characters?

Because every JWT header is a small JSON object starting with an opening curly brace and a quote, the two characters {", and that specific two character sequence happens to encode to eyJ in Base64. It is not a version marker or a magic number; it is simply what JSON's opening syntax looks like once encoded, and it is consistent enough to be a reliable visual fingerprint of a JWT in the wild.

Can I verify the signature by hand as well, without a library?

Conceptually yes, for an HS256 token: you would compute an HMAC-SHA256 over the header and payload segments using the shared secret, then compare the result byte for byte against the signature segment. Our hash generator can demonstrate the HMAC concept directly. In practice, real verification belongs in a maintained library, because correct Base64url handling and constant time comparison against timing attacks are easy to get subtly wrong by hand.

Is it safe to paste a live production token into an online decoder?

Only paste an active session token into a tool you know decodes entirely inside your own browser with nothing transmitted anywhere, which is how our decoder works. As a general habit, prefer decoding expired or throwaway test tokens rather than active ones on any third party site, since handing over a live token is functionally identical to handing over the session it represents.

How is a JWT actually different from a traditional session cookie?

A classic session cookie holds only a random reference id; the actual session data lives in a database on the server, looked up on every request. A JWT instead carries its own data along with it, cryptographically signed, so the server can trust it without a database lookup. That self contained design is what makes JWTs attractive for scaling across many servers, but it creates a real revocation problem: you cannot force a single already issued token to stop working the way you can delete a row from a session table. Short expiries paired with separate refresh tokens exist specifically to manage that tradeoff.

My token's exp claim is clearly in the past, but the server still accepts it. Is that normal?

No, that indicates the server is not actually checking expiry, a real bug worth fixing immediately rather than a quirk to work around. Correct verification always means checking the cryptographic signature and the temporal claims together; a maintained library does both automatically whenever its verification function is actually called, so this symptom usually points to verification being skipped somewhere in the request path rather than to the library being broken.

Shoyeb Akter

Written by

Security Tools Developer and creator of 2FA Fast, a privacy-first browser-based authenticator and security tools platform.