Upload a file
  1. base64hub
  2. Learn Base64
  3. Base64 in JWT
Explainer

How JWT Uses Base64URL: Decode a Token by Hand

A JWT is three Base64URL strings joined by dots. Anyone can decode the first two, and only the signature protects them. Here is how each part is built and read.

Anatomy of a token

Here is a complete HS256 token, signed with the demo secret demo-secret-do-not-use. Line breaks are added for readability; a real token is one line:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk

The three parts, separated by .:

PartBase64URL lengthDecodes to
Header36{"alg":"HS256","typ":"JWT"}
Payload104{"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600}
Signature4332 raw bytes of HMAC-SHA256

The header and payload are JSON encoded as UTF-8 and then as Base64URL. The signature is binary, so it only makes sense as bytes. iat and exp are Unix timestamps in seconds: this token was issued at 2025-10-09 08:53:20 UTC and expired one hour later.

Why Base64URL without padding

A token travels in places where +, / and = cause trouble: query strings (+ turns into a space), URL paths (/ is a separator), cookies and HTTP headers. So JWS, the signing format behind JWT, defined in RFC 7515, uses the URL-safe alphabet from RFC 4648 and strips the padding:

Base64 encoding using the URL- and filename-safe character set defined in Section 5 of RFC 4648, with all trailing ‘=’ characters omitted (as permitted by Section 3.2) and without the inclusion of any line breaks, whitespace, or other additional characters.

Two changes from standard Base64 follow:

  • + becomes - and / becomes _. The signature above contains _, which standard decoders reject.
  • No = at the end. A segment’s length is never 4n+1, so a decoder can always work out how much padding was removed: length mod 4 of 2 means ==, 3 means =.

More on the alphabet difference in Base64 vs Base64URL.

Decoding the parts by hand

Split on the dots, restore the standard alphabet and padding, decode, then parse JSON. In the browser:

function decodePart(part) {
  const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
  const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
  const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
  return JSON.parse(new TextDecoder().decode(bytes));
}

const [header, payload] = token.split(".").slice(0, 2).map(decodePart);

Skip the TextDecoder step and non-ASCII text breaks. A payload with {"name":"Zoë 日本"} encodes to eyJuYW1lIjoiWm_DqyDml6XmnKwifQ. Run the converted string through atob alone and the name comes out as mojibake starting with Zoë, because atob returns one character per byte. In runtimes that have it, Uint8Array.fromBase64(part, { alphabet: "base64url" }) replaces the first three lines (see Uint8Array.toBase64). In Node.js, Buffer.from(part, "base64url") does the same.

In Python, urlsafe_b64decode handles the alphabet but still wants padding:

import base64, json

def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header)))   # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload)))  # {'sub': 'user-42', 'name': 'Ada', ...}

In a shell, translate the alphabet with tr and pad with a loop:

jwt_part() {
  local s
  s=$(printf '%s' "$1" | tr '_-' '/+')
  while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
  printf '%s' "$s" | base64 -d
}
jwt_part "$(echo "$TOKEN" | cut -d. -f2)"

If you just need to look inside a token, paste it into the JWT decoder. It runs in your browser, so a production token does not leave your machine.

Why the payload is readable

Encoding is not encryption. Base64URL only changes how the bytes are written, and the JWT decoder reverses it without any key. A signed JWT guarantees that the payload has not been changed since it was signed. It does not hide the payload.

The encoding also costs space. Every claim grows by a third once it is Base64URL-encoded, and the whole token travels with each request in a header or cookie, so keep the payload small: an ID and a few short claims rather than a full user profile.

Treat every claim as public. User IDs, roles and expiry times are fine. Passwords, API keys and personal data that the token holder should not see are not. If the content must be confidential, use an encrypted JWT (JWE). Its compact form has five Base64URL parts: header, encrypted key, IV, ciphertext and authentication tag. The encoding vs encryption explainer covers the difference in general.

How the signature is computed

The signature covers the exact ASCII text header.payload, the two Base64URL strings with the dot between them. For HS256:

import hmac, hashlib

signing_input = f"{header}.{payload}".encode("ascii")
mac = hmac.new(b"demo-secret-do-not-use", signing_input, hashlib.sha256).digest()
hmac.compare_digest(mac, b64url_decode(signature))   # True

The raw signature bytes are then Base64URL-encoded without padding. Their length depends on the algorithm:

AlgorithmSignature bytesBase64URL characters
HS2563243
ES2566486
RS256 (2048-bit key)256342

ES256 signatures in JWS are the raw R || S values, 32 bytes each. OpenSSL and many crypto libraries produce a DER-encoded ECDSA signature instead, which starts with 30 and has a variable length. Converting between the two is a common source of “invalid signature” errors.

To build and sign a token with HS, RS, ES or EdDSA keys, use the JWT encoder. For a raw HMAC in Base64, see the HMAC tool.

Common mistakes

  • Using atob or a standard Base64 decoder directly. It works on some tokens and throws on others, depending on whether a - or _ happens to appear. Our example payload decodes with atob, the signature does not.
  • Adding padding when creating tokens. Some libraries reject segments that end with =. Strip it.
  • Re-encoding the header or payload before verifying. The signature is over the original Base64URL text. Parsing the JSON and serializing it again can change key order or whitespace, and the signature no longer matches. Always verify against the segments as received.
  • Trusting a decoded payload. Decoding is not verification. Check the signature with the expected algorithm, and never accept "alg":"none" (header eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0) unless you mean to.
  • Using the token string as a unique key. 32 signature bytes need 256 bits, and 43 characters carry 258. The last 2 bits are unused, so in our example the final k can be replaced with l, m or n and lenient decoders still return the same bytes. The signature still verifies. That is harmless for security, but two different strings can represent the same token.
  • Reading timestamps as milliseconds. exp and iat are seconds. Multiply by 1000 before passing them to JavaScript’s Date.

FAQ

Is the JWT payload encrypted?

No. A normal signed JWT (JWS) only Base64URL-encodes the payload, so anyone holding the token can read it. Encrypted tokens use JWE, which has five parts instead of three.

Why does a JWT not end with = signs?

RFC 7515 defines the encoding as Base64URL with all trailing = removed, so tokens can go into URLs and headers without escaping. Decoders have to add the padding back or accept input without it.

Can the three parts be decoded without the secret key?

Yes. The header and payload are plain Base64URL. The key is only needed to verify the signature or to create a new one. The JWT decoder shows both parts and checks the signature if you supply the key.

Why does every JWT start with eyJ?

The header is a JSON object starting with {", and those two bytes always encode to eyJ. More prefixes like this are listed in common Base64 prefixes.

Keep reading