Skip to content
Security

How to Decode a JWT: Reading the JSON Inside a JSON Web Token

·8 min read
How to Decode a JWT: Reading the JSON Inside a JSON Web Token - Security guide from JSON Prettifier

Quick answer: A JWT is three base64url strings joined by dots: header.payload.signature. The header and payload are JSON objects, so decoding means splitting on the dot, base64url-decoding the first two parts, and parsing the result. In Node that is JSON.parse(Buffer.from(token.split(".")[1], "base64url")). Decoding needs no key, but it proves nothing; only a signature check does.

The API says 401 Unauthorized and you have a token in your clipboard that looks like eyJhbGciOi.... Before you open a ticket with the auth team, thirty seconds of decoding will tell you whether the token expired, was issued for a different audience, or belongs to the wrong user. Here is how to read it in whatever environment you have open.

Explore an individual decoded JSON payload in the JSON Viewer. Reading its fields does not verify a JWT signature or establish that the token is trustworthy.

JWT Structure: Three Segments, Two of Them JSON

RFC 7519 defines a JSON Web Token as a compact string with three parts separated by periods:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzhmM2EiLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJvcmRlcnMtYXBpIiwiaWF0IjoxNzgzMDM2ODAwLCJleHAiOjE3ODMxMjMyMDB9
.
sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  1. Header: JSON describing the signing algorithm and token type, for example {"alg":"HS256","typ":"JWT"}. It may also carry kid, a key ID the verifier uses to pick the right public key.
  2. Payload: JSON containing the claims, the facts the issuer is asserting about the subject.
  3. Signature: raw bytes, not JSON. It is an HMAC or a digital signature over the first two segments exactly as encoded.

The encoding is base64url (RFC 4648 section 5), not standard base64. The difference is small but it is the reason naive decoders fail: + becomes -, / becomes _, and the trailing = padding is removed so the token is safe in URLs and headers. Almost every token starts with eyJ because that is what {" looks like in base64.

What the header tells you

The header is small but worth reading before the payload. alg names the signing algorithm: HS256 means a shared-secret HMAC, while RS256 and ES256 mean an asymmetric signature that anyone with the public key can verify. typ is almost always JWT. kid, when present, is the key ID the issuer used, which is how a verifier picks the right key from a JWKS endpoint during rotation. If you are debugging an invalid signature error, a kid that does not match any key your server has cached is the first thing to check.

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "2026-07-key-1"
}

Decode a JWT in the Browser With atob()

atob() handles standard base64 only, so convert the alphabet and restore padding first. Decode the bytes as UTF-8 so non-ASCII names in claims survive:

function decodeJwtSegment(segment) {
  // base64url -> base64
  let b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  // restore padding to a multiple of 4
  b64 += "=".repeat((4 - (b64.length % 4)) % 4);
  const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  return JSON.parse(new TextDecoder().decode(bytes));
}

function decodeJwt(token) {
  const [header, payload] = token.split(".");
  return { header: decodeJwtSegment(header), payload: decodeJwtSegment(payload) };
}

const { header, payload } = decodeJwt(token);
console.log(header);   // { alg: "HS256", typ: "JWT" }
console.log(payload);  // { sub: "user_8f3a", iss: "https://auth.example.com", ... }

Skipping the padding step is the most common mistake. Depending on the browser, atob() either throws InvalidCharacterError: The string to be decoded is not correctly encoded or returns a truncated string that then fails in JSON.parse with an unexpected end of JSON input error.

Decode a JWT in Node.js

Node's Buffer understands base64url directly (since Node 15.7 / 14.18, as documented), which makes this a one-liner:

const [h, p] = token.split(".");
const header  = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
const payload = JSON.parse(Buffer.from(p, "base64url").toString("utf8"));

console.log(payload.exp, new Date(payload.exp * 1000).toISOString());
// 1783123200 2026-07-04T00:00:00.000Z

From a terminal, with the token in an environment variable:

node -e 'console.log(JSON.parse(Buffer.from(process.env.T.split(".")[1], "base64url")))'

If you already depend on the jsonwebtoken package, jwt.decode(token) returns the payload and jwt.decode(token, { complete: true }) returns header, payload, and signature. Note the name: decode does not verify. That function is covered later.

Decode a JWT in Python

base64.urlsafe_b64decode handles the alphabet but still insists on padding, so add it back:

import base64
import json
from datetime import datetime, timezone

def decode_segment(segment: str) -> dict:
    padded = segment + "=" * (-len(segment) % 4)
    return json.loads(base64.urlsafe_b64decode(padded))

header, payload, _signature = token.split(".")
claims = decode_segment(payload)

print(json.dumps(claims, indent=2))
print(datetime.fromtimestamp(claims["exp"], tz=timezone.utc))
# 2026-07-04 00:00:00+00:00

-len(segment) % 4 is the compact way to compute how many = characters are missing. If you prefer a library, PyJWT's jwt.decode(token, options={"verify_signature": False}) does the same and makes the lack of verification explicit in the call. The Python json module guide covers the json.loads and json.dumps options used here.

Decode a JWT With jq

jq can do the whole thing on its own because @base64d accepts input without padding:

echo "$T" | jq -R 'split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson'

Change [1] to [0] for the header. Pipe through .exp | todate to get an ISO timestamp from the expiry. More filters like this are in the jq cheat sheet.

Reading the Payload: Standard Claims

RFC 7519 registers seven claim names. All are optional, but iss, sub, exp, and iat appear in nearly every real token.

ClaimNameMeaningExample
issIssuerWho created the token"https://auth.example.com"
subSubjectWho the token is about (usually a user ID)"user_8f3a"
audAudienceWho is allowed to accept it"orders-api"
expExpirationUnix time after which it is invalid1783123200
nbfNot beforeUnix time before which it is invalid1783036800
iatIssued atUnix time it was created1783036800
jtiJWT IDUnique ID, used for revocation lists"c1b2-..."

Anything else in the payload is a custom claim: email, roles, tenant_id, whatever the issuer decided to include. Identity providers often add many of them, which is why a decoded payload can be forty lines long. Paste it into JSON Prettifier and use Sort Keys to find the one you are looking for; the tool runs client-side, so the decoded claims stay on your machine, though the safer habit is still to decode test tokens rather than production ones.

exp, iat, and nbf are Unix seconds

The three time claims are NumericDate values: seconds since 1970-01-01 UTC, as a plain number. The two bugs that follow from this:

  • JavaScript's Date uses milliseconds. new Date(1783123200) is January 21, 1970. Multiply by 1000.
  • Some issuers emit floats such as 1783123200.123. Verifiers accept them; your display code should not assume an integer.

A practical expiry check without any library:

const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp !== undefined && payload.exp <= nowSeconds) {
  console.log("expired", new Date(payload.exp * 1000).toISOString());
}

Verifiers usually allow a small clock skew (a minute or so). If a token looks expired by seconds, the real problem is likely a clock difference between the issuer and the server. For the broader question of how to represent times in JSON, see the JSON date format guide.

Decoding Is Not Verifying

This is the part that gets teams breached. Everything above reads data that anyone can write. A JWT with "role": "admin" in the payload is trivially forged; the only thing stopping it is the signature, and a decoder never checks the signature.

Verification means recomputing the signature with the key you trust and comparing it to the third segment, then validating the claims. In Node with jsonwebtoken:

const jwt = require("jsonwebtoken");

try {
  const payload = jwt.verify(token, process.env.JWT_SECRET, {
    algorithms: ["HS256"],          // never let the token choose
    audience: "orders-api",
    issuer: "https://auth.example.com",
  });
  // payload is now trustworthy
} catch (err) {
  // TokenExpiredError, JsonWebTokenError (invalid signature), NotBeforeError
  console.error(err.name, err.message);
}

And in Python with PyJWT:

import jwt

claims = jwt.decode(
    token,
    key=SECRET,
    algorithms=["HS256"],
    audience="orders-api",
    issuer="https://auth.example.com",
)  # raises jwt.ExpiredSignatureError, jwt.InvalidSignatureError, etc.

Rules that follow directly from how the format works:

  • Pin the algorithm list. The header's alg is attacker-controlled. Historic libraries honoured "alg": "none" and skipped verification entirely; always pass an explicit allow-list.
  • Check aud. A valid token for your billing service is not a valid token for your admin panel.
  • Treat the token as a password. Do not log it, do not put it in URLs, and do not paste a production token into a website you cannot vouch for. A captured bearer token is a captured session until exp.
  • Do not put secrets in the payload. It is readable by the client and every proxy in between. If you need hidden claims, use JWE (encrypted tokens) rather than JWS.

The JSON security best practices post covers the surrounding concerns: parsing untrusted input, prototype pollution, and what to do with JSON you did not produce.

Common Mistakes When Decoding JWTs

  • Decoding the signature as JSON. The third segment is binary. Parsing it gives SyntaxError: Unexpected token at position 0.
  • Splitting on the wrong character. A token pasted from a log may include the Bearer prefix. Strip it: token.replace(/^Bearer\s+/i, "").
  • Truncated tokens. Terminals and spreadsheet cells cut long strings. A token that decodes to half an object and then fails was cut off; compare its length with the original.
  • Assuming the payload is an object. It almost always is, but the spec allows any JSON value in a nested JWT. Check before accessing properties.
  • Reading sub as a number. Subject identifiers are strings per the RFC. Large numeric IDs stored as numbers run into the JSON number precision problem.

Frequently Asked Questions

Can I decode a JWT without the secret key?
Yes. The header and payload are only base64url-encoded, not encrypted, so anyone holding the token can read them with a few lines of code. The secret or public key is needed only to verify the signature, which is what proves the token is genuine and unmodified.
Why does atob() fail when decoding a JWT?
JWTs use base64url, which replaces + with -, / with _, and drops the = padding. atob() expects standard base64, so it throws an InvalidCharacterError on those characters. Swap them back and restore padding before calling atob(), or use Buffer.from(segment, 'base64url') in Node.
How do I convert the exp claim to a readable date?
exp is a NumericDate: seconds since the Unix epoch. In JavaScript use new Date(exp * 1000), because Date expects milliseconds. In Python use datetime.fromtimestamp(exp, tz=timezone.utc).
Is it safe to paste a JWT into an online decoder?
Only if the token is a test token or you can confirm the decoder runs entirely in your browser. A production JWT is a bearer credential; anyone who captures it can act as that user until it expires. Decode on your own machine with a one-liner instead.
What is the difference between decoding and verifying a JWT?
Decoding reads the JSON inside the token and requires no keys. Verifying recomputes the signature with the issuer's secret or public key and checks the claims such as exp and aud. Only a verified token should be trusted; a decoded one can say anything an attacker wants.

Format the Decoded Payload

Decode locally, then paste the claims JSON here to format and sort it. Everything runs in your browser; nothing is uploaded.

Format My JSON