How to Decode a JWT (and Why Decoding Isn't Verifying)
Decode a JSON Web Token's header and payload by hand or in code, read exp, nbf and iat claims, and see why only signature verification proves a token valid.
By the koducat teamUpdated 6 min read
What is inside a JWT
A JSON Web Token (JWT, defined in RFC 7519) is a compact string made of three parts separated by dots: a header, a payload and a signature. The header and payload are JSON objects encoded with base64url; the signature is binary data, also base64url-encoded.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cDecoding the first two parts of this well-known example token gives:
{ "alg": "HS256", "typ": "JWT" }
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }Base64url is ordinary base64 with two substitutions — - instead of + and _ instead of / — and with the trailing = padding removed, so the token is safe in URLs and HTTP headers. It is an encoding, not encryption: anyone who has the token can read the header and payload. Never put secrets such as passwords in a JWT payload.
How to decode a JWT online
- Copy the token, for example from an
Authorization: Bearer …header in your browser's network panel. Copy only the token itself, not the wordBearer. - Paste it into the JWT Decoder. The header and payload are decoded and pretty-printed immediately.
- Read the
algvalue in the header to see how the token was signed, andkidif present to see which key was used. - Check the time claims. The decoder converts
exp,nbfandiatto readable dates and tells you whether the token has expired. - If you need to inspect a timestamp elsewhere, paste it into the Timestamp Converter — JWT times are in seconds, not milliseconds.
Decoding a JWT in JavaScript, Python and bash
Decoding needs no library — only base64url decoding and JSON.parse. In Node.js, Buffer understands base64url directly:
const [headerPart, payloadPart] = token.split(".");
const header = JSON.parse(Buffer.from(headerPart, "base64url").toString("utf8"));
const payload = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8"));In the browser, convert base64url back to base64, restore the padding and decode the bytes as UTF-8 (using atob alone breaks on non-ASCII characters such as accented names):
function decodeJwtPart(part: string): unknown {
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.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(decodeJwtPart);import base64, json
def decode_part(part: str) -> dict:
padded = part + "=" * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
header_b64, payload_b64, _signature = token.split(".")
print(decode_part(header_b64), decode_part(payload_b64))# Prints the payload (field 2). Use 1 for the header.
jwt_part() {
local s pad
s=$(printf '%s' "$1" | cut -d. -f"$2" | tr '_-' '/+')
pad=$(( (4 - ${#s} % 4) % 4 ))
s="$s$(printf '%*s' "$pad" '' | tr ' ' '=')"
printf '%s' "$s" | base64 -d
}
jwt_part "$TOKEN" 2 | jq .Reading the standard claims
RFC 7519 defines a set of registered claim names. None are mandatory, but most identity providers use them, and verification libraries check several of them for you.
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who created and signed the token, often a URL |
sub | Subject | Who the token is about, usually a user ID |
aud | Audience | Which API or client the token is intended for; a string or an array |
exp | Expiration time | After this moment the token must be rejected |
nbf | Not before | Before this moment the token must be rejected |
iat | Issued at | When the token was created |
jti | JWT ID | A unique identifier, useful for detecting replays or revoking tokens |
exp, nbf and iat are NumericDate values: the number of seconds since 1970-01-01T00:00:00Z, not milliseconds. JavaScript's Date.now() returns milliseconds, so divide by 1000 before comparing:
const nowSeconds = Math.floor(Date.now() / 1000);
const expired = typeof payload.exp === "number" && payload.exp <= nowSeconds;
const notYetValid = typeof payload.nbf === "number" && payload.nbf > nowSeconds;Servers usually allow a small clock-skew tolerance of a few seconds to a minute, because the issuer's clock and yours are never perfectly in sync.
Why decoding is not verifying
Decoding tells you what a token claims. It says nothing about whether those claims are true. Anyone can create a token with "role": "admin" in the payload — the JWT Generator does exactly that for testing. What makes a token trustworthy is the signature: a value computed over the header and payload with a secret (HMAC algorithms such as HS256) or a private key (RSA or ECDSA algorithms such as RS256 and ES256).
Verification recomputes or checks that signature with the expected key and then validates the claims. Use a maintained library rather than writing this yourself. With jose in JavaScript or TypeScript:
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com/",
audience: "my-api",
algorithms: ["RS256"], // never accept whatever alg the token asks for
});import jwt # pip install PyJWT
payload = jwt.decode(
token,
key=SECRET,
algorithms=["HS256"],
audience="my-api",
issuer="https://auth.example.com/",
)Common JWT mistakes and attacks
- The `alg: none` attack. RFC 7519 allows unsecured tokens with
"alg": "none"and an empty signature. A library that trusts the header'salgmay accept a forged token with no signature. Always pass an explicit allow-list of algorithms when verifying. - Algorithm confusion. If a server expects RS256 but lets the token choose its algorithm, an attacker can sign a token with HS256 using the server's public key as the HMAC secret. An explicit algorithm list prevents this too.
- Not checking `aud` and `iss`. A valid token issued for a different application should not be accepted by yours.
- Weak HMAC secrets. HS256 tokens can be brute-forced offline if the secret is short or guessable. Use a long random secret, at least 256 bits.
- Long lifetimes. JWTs cannot easily be revoked before
exp, so keep access tokens short-lived and use refresh tokens for longer sessions. - Sensitive data in the payload. The payload is readable by anyone who holds the token. If you need confidentiality, use encrypted JWE tokens or keep the data server-side.
For more security utilities — hashing, password generation and token tools — browse the security tools category.
Try it now with the free JWT Decoder — it runs in your browser.
Open JWT DecoderFrequently asked questions
Can I decode a JWT without the secret key?
Yes. The header and payload are only base64url-encoded, so anyone can decode them. The secret or public key is needed to verify the signature, not to read the contents.
Why does my JWT show a date in 1970 or far in the future?
JWT time claims are in seconds. If code treats exp as milliseconds you get dates in January 1970; if it writes milliseconds into exp you get dates thousands of years ahead. Convert with seconds × 1000 when creating a JavaScript Date.
Is it safe to store JWTs in localStorage?
It is readable by any JavaScript on the page, so a cross-site scripting bug can steal the token. Many teams prefer HttpOnly, Secure cookies with SameSite protection for session tokens. Whichever you choose, keep tokens short-lived.
What is the difference between JWT, JWS and JWE?
JWT defines the claims format. A signed JWT is a JWS (JSON Web Signature) — the common three-part token. A JWE (JSON Web Encryption) token is encrypted, has five parts, and cannot be read without the decryption key.