Decode a JWT Manually Without a Library

You paste a JWT into a plain base64 decoder, hit go, and get back a mangled string with stray characters or an outright "invalid input" error. The token is fine. The decoder is wrong for the job. A JSON Web Token is not encoded with standard base64 — it uses base64url, a different alphabet. The JWT spec (RFC 7519) mandates base64url, and the alphabet itself is defined in RFC 4648 §5. If you want the bigger picture on token structure first, our guide to how JWTs work covers the claims and headers. Once you understand the two small differences in the encoding, you can read any token's claims by hand in a shell, an edge function, or anywhere you cannot or do not want to install a dependency.

Why a normal base64 decoder chokes on a JWT

Standard base64 (RFC 4648 §4) uses the alphabet A-Z a-z 0-9 + / and pads the output with = so the length is always a multiple of four. The problem: +, /, and = are unsafe in URLs and query strings, and JWTs are designed to travel in URLs and HTTP headers. So JWTs use the URL-safe variant (RFC 4648 §5), which makes two changes to the alphabet plus one to the padding:

  • + becomes - (minus)
  • / becomes _ (underscore)
  • the trailing = padding is stripped entirely

A strict base64 decoder sees the - and _ as illegal characters, or chokes because the length is not a multiple of four. That is the garbage you are seeing. Fix those two things and the bytes decode cleanly.

The 3-step manual decode

A JWT has three parts joined by dots: header.payload.signature. To read the human-readable claims, you decode the middle part (the payload). The header is decoded the same way; the signature is raw bytes, not JSON.

  1. Split on the dot. Take the second segment (index 1) — that is the payload. Ignore the third segment unless you are verifying.
  2. Translate the alphabet. Replace every - with + and every _ with /. This converts base64url back to standard base64.
  3. Restore the padding. Append = until the string length is a multiple of four. The math: padNeeded = (4 - len % 4) % 4. A remainder of 2 needs two =, a remainder of 3 needs one =, a remainder of 0 needs none. (A remainder of 1 is never valid base64.)

Then run it through any standard base64 decoder and parse the resulting JSON.

JavaScript (browser, Node, edge runtime)

Browsers and most edge runtimes expose atob(), which is a standard base64 decoder — so you do the alphabet swap and padding yourself first. Per MDN, atob() returns a binary string, so for non-ASCII claims you may want to run the result through a percent-decode or TextDecoder to recover the UTF-8 bytes.

function decodeJwtPayload(token) {
  const part = token.split('.')[1];
  let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
  b64 += '='.repeat((4 - b64.length % 4) % 4);
  const json = decodeURIComponent(
    atob(b64).split('').map(c =>
      '%' + c.charCodeAt(0).toString(16).padStart(2, '0')
    ).join('')
  );
  return JSON.parse(json);
}

In Node you can skip atob entirely — Buffer.from(part, 'base64url') handles the URL-safe alphabet and missing padding natively, no manual fix needed. (The 'base64url' encoding was added in Node 16.)

Python

Python's base64.urlsafe_b64decode already understands the -/_ alphabet, so step 2 is free. It is still strict about padding, so you must restore the = yourself or it raises binascii.Error: Incorrect padding.

import base64, json

def decode_jwt_payload(token):
    part = token.split('.')[1]
    pad = '=' * (-len(part) % 4)
    raw = base64.urlsafe_b64decode(part + pad)
    return json.loads(raw)

The expression -len(part) % 4 is a tidy Python trick: it returns exactly how many pad characters you need (0, 1, or 2) without a conditional, because Python's modulo always returns a non-negative result for a positive divisor.

Bash one-liner

GNU coreutils ships base64, but it does not speak the URL-safe alphabet, so you translate with tr first and feed padding manually. The cleanest approach adds padding before decoding:

TOKEN="eyJhbGciOi...your.jwt.here"
PAYLOAD=$(echo -n "$TOKEN" | cut -d. -f2 | tr '_-' '/+')
case $(( ${#PAYLOAD} % 4 )) in 2) PAYLOAD="$PAYLOAD==";; 3) PAYLOAD="$PAYLOAD=";; esac
echo -n "$PAYLOAD" | base64 -d

The tr '_-' '/+' maps positionally: _ becomes / and - becomes +. If jq is installed, pipe the final output through | jq . for pretty-printed claims. On macOS the BSD base64 is more forgiving about padding, but the tr translation is still required everywhere.

The caveat that matters most: decoding is not verifying

This is the part people skip and regret. Decoding a JWT only reads what is inside it. It does not check the signature, so it proves nothing about authenticity. Anyone can craft a token with "admin": true in the payload and base64url-encode it — the decode will succeed and show whatever they wrote.

The signature (the third segment) is what binds the claims to a key the issuer holds. Verifying it means recomputing the HMAC or checking the RSA/ECDSA signature against the issuer's key, validating the alg header, and checking claims like exp, nbf, and iss. None of that happens during a manual decode. So: decode by hand freely for debugging, logging, or reading a token you already trust — but never make an authorization decision on decoded-but-unverified claims. On a server, always verify with a vetted library and the correct key.

When to reach for a tool instead

The manual path is perfect for shells and zero-dependency runtimes. When you just want to read a token fast and inspect every claim, paste it into our client-side JWT decoder — it splits, fixes the alphabet, restores padding, and pretty-prints the header and payload in the browser with nothing leaving your machine. To understand the raw bytes underneath, the base64 encoder/decoder lets you experiment with both the standard and URL-safe alphabets, and the URL encoder helps you see exactly why the +, /, and = characters needed replacing in the first place.

Frequently Asked Questions

Because JWTs use the base64url alphabet, not standard base64. They replace + with -, / with _, and strip the trailing = padding. A strict decoder treats - and _ as illegal characters and rejects the missing padding. Swap the two characters back and re-add = until the length is a multiple of four, then it decodes cleanly.

Compute (4 - length % 4) % 4. If the remainder when dividing the length by four is 2, add two = signs; if it is 3, add one = sign; if it is 0, add none. A remainder of 1 never occurs in valid base64. In Python the shortcut '=' * (-len(s) % 4) gives the exact count.

No. Decoding only reads the header and payload; it ignores the signature entirely. Anyone can forge a payload that decodes successfully. Verification recomputes the signature against the issuer's key and checks claims like exp and iss. Never base an authorization decision on decoded-but-unverified claims — always verify server-side with a trusted library.

A JWT is header.payload.signature, joined by dots. The first segment is the header (algorithm and type), the second is the payload (the claims like sub, exp, and iss), and the third is the binary signature. Decode the second segment with base64url to read the claims. The first decodes the same way; the third is raw bytes, not JSON.

Yes. Node's Buffer supports the base64url encoding directly (added in Node 16): Buffer.from(token.split('.')[1], 'base64url').toString() handles the URL-safe alphabet and the missing padding for you, so you skip the character swap and the pad math. The manual approach is only needed in environments without that built-in, like browsers using atob or a plain bash shell.