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.signatureDecoding 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.7QZ0Uy0v3XZmvUxYQXQ4kEzmO0mYqPfMqRzYQVQvXwMThe Base64URL Detail That Breaks Naive Decoders
JWT segments are Base64URL (RFC 4648 §5), not standard Base64. Two differences cause almost every failed decode:
| Standard Base64 | Base64URL | |
|---|---|---|
| Character 62 | + | - |
| Character 63 | / | _ |
| Padding | = to a multiple of 4 | Stripped 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")" # payloadOn macOS or anywhere without basenc, translate the alphabet and use base64 -d:
printf '%s' "$seg" | tr '_-' '/+' | base64 -dWith 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, unverifiedMethod 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 / BSDnew Date(payload.exp * 1000).toISOString(); // note the * 1000A 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
| Symptom | Cause | Fix |
|---|---|---|
Invalid character from a Base64 decoder | Segment contains - or _ | Translate to + and /, or use a Base64URL decoder |
| Decoded output is truncated or garbled | Missing = padding | Pad to a multiple of 4 |
| Accented characters come out wrong | Decoded as Latin-1 rather than UTF-8 | Decode the bytes with TextDecoder |
exp looks like the year 56000 | Value is in milliseconds | Divide by 1000; exp is seconds |
| Only two segments after splitting | Truncated token, or an opaque token that is not a JWT | Check the source — not every bearer token is a JWT |
| Signature segment will not decode as JSON | It is raw signature bytes, not JSON | Only the first two segments are JSON |
| Decodes fine but the API rejects it | Expired, wrong audience, or bad signature | Verify 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?.
ProfitTool Team
Security & DevWritten 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.
More from the Knowledge Hub
JWT Expiration Explained: exp, iat and nbf Claims
How JWT time claims work — exp, iat and nbf — plus clock skew tolerance, choosing a token lifetime, refresh-token rotation and the revocation gap.
Developer ToolsWhat Is a JWT? JSON Web Tokens Explained
A plain-language guide to JSON Web Tokens — what they contain, how they are structured, and where they are used in modern authentication systems.
Developer ToolsBase64 Encoding Explained: What It Is and How It Works
Base64 turns binary into 64 safe ASCII characters, at a 33% size cost. How the encoding works, what padding is for, and how Base64URL differs.