Skip to content
ProfitTool
Back to all articles
Developer Tools
4 min read

How to Decode a JWT Token (Without a Library)

You can decode a JWT token in any language using nothing but base64url decoding and JSON parsing. Here is how to do it in JavaScript, Python and the browser.

ProfitTool Team

Security & Dev

What "Decoding" a JWT Actually Means

A JWT is three Base64URL segments separated by dots:

header.payload.signature

Decoding means Base64URL-decoding the first two segments back into JSON. There is no key, no secret and no cryptography involved — the payload of every JWT ever issued is readable by anyone holding the token. That is by design, and it is why nothing confidential belongs in one.

What decoding does not do is tell you whether the token is genuine. That distinction is covered at the end of this guide, and it matters more than any of the commands below.

The examples all use this token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJKYW5lIFJvZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc2OTAwMDAwMCwiZXhwIjoxNzY5MDAzNjAwfQ.7QZ0Uy0v3XZmvUxYQXQ4kEzmO0mYqPfMqRzYQVQvXwM

The Base64URL Detail That Breaks Naive Decoders

JWT segments are Base64URL (RFC 4648 §5), not standard Base64. Two differences cause almost every failed decode:

Standard Base64Base64URL
Character 62+-
Character 63/_
Padding= to a multiple of 4Stripped entirely

So a plain Base64 decoder fed a JWT segment either throws an "invalid character" error or silently produces garbage. Every correct approach either uses a Base64URL-aware decoder or translates the two characters and re-adds the padding first.

The padding rule: take the segment length modulo 4. A remainder of 2 needs ==, a remainder of 3 needs =, a remainder of 0 needs nothing. A remainder of 1 means the segment is malformed.

Method 1: Command Line

GNU coreutils 8.31 and later ships basenc, which understands the Base64URL alphabet directly. Padding still has to be supplied:

TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSJ9.sig"

decode_segment() {
  local seg="$1"
  # Re-add the padding that Base64URL strips.
  local pad=$(( (4 - ${#seg} % 4) % 4 ))
  [ "$pad" -eq 1 ] && { echo "malformed segment" >&2; return 1; }
  printf '%s%s' "$seg" "$(printf '=%.0s' $(seq 1 $pad))" | basenc --base64url -d
}

decode_segment "$(cut -d. -f1 <<<"$TOKEN")"   # header
decode_segment "$(cut -d. -f2 <<<"$TOKEN")"   # payload

On macOS or anywhere without basenc, translate the alphabet and use base64 -d:

printf '%s' "$seg" | tr '_-' '/+' | base64 -d

With jq available, this becomes a one-liner that also pretty-prints — @base64d tolerates the missing padding:

jq -R 'split(".") | .[1] | @base64d | fromjson' <<<"$TOKEN"
{
  "sub": "12345",
  "name": "Jane Roe",
  "role": "admin",
  "iat": 1769000000,
  "exp": 1769003600
}

Method 2: Node.js

Node 16 and later supports base64url as a buffer encoding, which handles both the alphabet and the missing padding:

function decodeJwt(token) {
  const [h, p] = token.split(".");
  if (!h || !p) throw new Error("Not a JWT — expected three dot-separated segments");
  return {
    header: JSON.parse(Buffer.from(h, "base64url").toString("utf8")),
    payload: JSON.parse(Buffer.from(p, "base64url").toString("utf8")),
  };
}

console.log(decodeJwt(process.argv[2]));

The jsonwebtoken library's decode() does the same thing, and its name is worth internalising: decode never checks the signature. Only verify does.

import jwt from "jsonwebtoken";
jwt.decode(token, { complete: true });   // header + payload, unverified

Method 3: Python

import base64
import json


def decode_segment(segment: str) -> dict:
    # urlsafe_b64decode requires correct padding; JWTs ship without it.
    padding = "=" * (-len(segment) % 4)
    return json.loads(base64.urlsafe_b64decode(segment + padding))


def decode_jwt(token: str) -> tuple[dict, dict]:
    parts = token.split(".")
    if len(parts) != 3:
        raise ValueError("Not a JWT — expected three dot-separated segments")
    return decode_segment(parts[0]), decode_segment(parts[1])


header, payload = decode_jwt(token)
print(json.dumps(payload, indent=2))

With PyJWT, skipping verification has to be explicit — a good API design, because it makes the unsafe path visible in code review:

import jwt
jwt.decode(token, options={"verify_signature": False})

Method 4: In the Browser

atob() handles neither the Base64URL alphabet nor multi-byte UTF-8, so a name like José decodes to mojibake. Both problems need handling:

function decodeJwtPayload(token) {
  const segment = token.split(".")[1];
  if (!segment) throw new Error("Not a JWT");

  // Base64URL → Base64, then restore the stripped padding.
  const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);

  // atob yields a binary string; TextDecoder turns those bytes into real UTF-8.
  const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
  return JSON.parse(new TextDecoder().decode(bytes));
}

The TextDecoder step is the part usually missing from copied snippets. Without it, any non-ASCII claim value comes out corrupted, and the bug only surfaces for users whose names contain accents.

Reading the Timestamps

exp, iat and nbf are NumericDate values — seconds since the Unix epoch, not milliseconds. Interpreting them as milliseconds puts every token in 1970; producing them as milliseconds puts every token fifty thousand years in the future.

# 1769000000 → a readable date
date -d @1769000000        # GNU
date -r 1769000000         # macOS / BSD
new Date(payload.exp * 1000).toISOString();   // note the * 1000

A quick sanity check: a current exp is a ten-digit number. Thirteen digits means milliseconds crept in somewhere.

Decoding Is Not Verifying

This is the point of the whole guide. Decoding proves only that a string was Base64URL-encoded JSON. It proves nothing about who produced it. Anyone can craft a token with "role": "admin" and any decoder will happily print it.

// Never do this — the payload is attacker-controlled input.
const user = jwt.decode(token);
if (user.role === "admin") grantAccess();

Verification checks the signature against a key you control, and confirms the standard claims:

// Node — jsonwebtoken
const claims = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],          // pin it; never trust the header's alg
  issuer: "https://auth.example.com",
  audience: "api.example.com",
});
# Python — PyJWT
claims = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    issuer="https://auth.example.com",
    audience="api.example.com",
)

algorithms is mandatory in both libraries for a reason: if the verifier accepts whatever the token's header declares, an attacker can set alg to none and strip the signature, or downgrade RS256 to HS256 and sign with the public key. Pinning the algorithm closes both.

Decoding is for debugging. Verification is for authorisation. Client-side code may decode a token to display a username or drive UI state, but every authorisation decision must be made by a server that verified the signature.

Troubleshooting

SymptomCauseFix
Invalid character from a Base64 decoderSegment contains - or _Translate to + and /, or use a Base64URL decoder
Decoded output is truncated or garbledMissing = paddingPad to a multiple of 4
Accented characters come out wrongDecoded as Latin-1 rather than UTF-8Decode the bytes with TextDecoder
exp looks like the year 56000Value is in millisecondsDivide by 1000; exp is seconds
Only two segments after splittingTruncated token, or an opaque token that is not a JWTCheck the source — not every bearer token is a JWT
Signature segment will not decode as JSONIt is raw signature bytes, not JSONOnly the first two segments are JSON
Decodes fine but the API rejects itExpired, wrong audience, or bad signatureVerify rather than decode; check exp, aud and iss

Frequently Asked Questions

Do I need the secret key to decode a JWT? No. The payload is only encoded. A key is needed to verify the signature, never to read the contents.

Is it safe to paste a token into an online decoder? Only if the decoding happens entirely in your browser and nothing is transmitted. A live production token is a credential — treat pasting one into an unknown server-side service as disclosing it.

Why is the third segment unreadable? It is the raw signature, not JSON. Decoding it yields bytes, which is expected.

Can I edit a JWT payload? You can change the text, but the signature will no longer match and any correct verifier rejects it. A verifier that accepts an edited token is misconfigured.

How do I tell an expired token from an invalid one? Verification libraries distinguish them — TokenExpiredError versus JsonWebTokenError in jsonwebtoken, ExpiredSignatureError versus InvalidTokenError in PyJWT. See JWT Expiration Explained for how exp, nbf and clock skew interact.

Decode a token instantly in your browser with the JWT Decoder — nothing is transmitted. For claim-by-claim inspection with expiry countdowns, use the JWT Claims Inspector; the Base64 & Base64URL Encoder / Decoder handles individual segments, and the Unix Timestamp Converter turns exp and iat into readable dates. For the structure and security model behind the format, see What Is a JWT?.

Tags:#jwt#base64url#decode jwt#developer tools#javascript

ProfitTool Team

Security & Dev

Written and maintained by the ProfitTool team. Where an article references a ProfitTool utility, the behaviour described is the behaviour of the tool on this site — you can open it and check the output yourself.

Try the Interactive Tools Mentioned

Free to use with no ProfitTool signup. Data handling varies by tool.