To decode a JWT token, split the string on its two dots into three parts, then base64url-decode the header and payload. The payload is plain JSON that anyone can read, because a JWT is encoded, not encrypted. In JavaScript you can do it in one line with atob, but only after fixing the base64url characters that break a naive decode. This guide shows exactly how to decode a JWT token by hand, the gotcha most snippets miss, and why decoding tells you nothing about whether the token is real.
If you have ever pasted a token somewhere and watched the claims appear instantly, that is the part throwing people off. There is no secret key involved in reading a JWT. The privacy comes from the signature, not from hiding the contents, and confusing those two ideas is how developers ship real bugs. Let us decode one properly first, then clear up the dangerous misunderstanding underneath it.
What a JWT Actually Looks Like#
A JSON Web Token is a single string made of three parts joined by periods:
header.payload.signature
A real token looks like this (line-wrapped here for readability, it is one continuous string):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNzE3NzI4MDAwLCJleHAiOjE3MTc3MzE2MDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Each part has a job:
- Header: a tiny JSON object naming the signing algorithm (
alg) and the token type (typ). Base64url encoded. - Payload: the JSON object holding your claims (user id, roles, expiry, anything you put there). Base64url encoded.
- Signature: a cryptographic hash of the header and payload, computed with a secret or private key. This is the only part that proves the token has not been tampered with.
The header and payload are not hidden. They are base64url, which is reversible by anyone with no key. Treat everything in a JWT payload as public information.
Why base64url, not regular base64#
JWTs live in URLs, HTTP headers, and cookies. Standard base64 uses the characters +, /, and trailing = padding, all of which are unsafe or annoying in those contexts. So JWTs use base64url, defined in RFC 4648, which swaps + for -, / for _, and drops the = padding entirely.
That single difference is the reason most copy-paste decode snippets fail on real tokens. If you base64-decode a payload that contains a - or _ without converting it first, you get garbled output or an error. We handle that explicitly below. If you want to see the encoding mechanics on their own, our base64 encoder and decoder lets you experiment with standard versus URL-safe output.
How to Decode a JWT Token in JavaScript#
The browser gives you atob for base64 decoding. The trick is converting base64url back to standard base64 first, then handling Unicode correctly. Here is the full, correct version, not the broken one-liner you usually find.
Step 1: Split the token on the dots#
The token is header.payload.signature. Split it and grab the parts you want.
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIn0.signature_here";
const [headerB64, payloadB64, signatureB64] = token.split(".");
If token.split(".") does not return exactly three parts, the string is not a well-formed JWT and you should stop here rather than decode garbage.
Step 2: Convert base64url back to standard base64#
This is the step nearly every quick snippet skips, and it is exactly why those snippets break on production tokens that contain - or _.
function base64UrlToBase64(input) {
// Replace URL-safe characters with standard base64 characters
let output = input.replace(/-/g, "+").replace(/_/g, "/");
// Re-add the padding that base64url stripped
const pad = output.length % 4;
if (pad) {
output += "=".repeat(4 - pad);
}
return output;
}
Without restoring the padding, some environments throw an InvalidCharacterError on atob. With it, you decode reliably every time.
Step 3: Decode with atob and parse the JSON#
Now atob works, but there is one more catch. atob returns a binary string, so any non-ASCII characters (accented names, emoji, non-Latin scripts) come out mangled. Decode the bytes as UTF-8 to be safe.
function decodeJwtPart(part) {
const base64 = base64UrlToBase64(part);
const binary = atob(base64);
// Convert the binary string to a proper UTF-8 string
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const json = new TextDecoder("utf-8").decode(bytes);
return JSON.parse(json);
}
const header = decodeJwtPart(headerB64);
const payload = decodeJwtPart(payloadB64);
console.log(header); // { alg: "HS256", typ: "JWT" }
console.log(payload); // { sub: "1234567890", name: "Jane Doe", ... }
Step 4: Read the claims you care about#
The payload is now a normal JavaScript object. Pull out the standard claims:
console.log("Subject:", payload.sub);
console.log("Issued at:", new Date(payload.iat * 1000).toISOString());
console.log("Expires at:", new Date(payload.exp * 1000).toISOString());
console.log("Expired?", payload.exp * 1000 < Date.now());
Note that iat and exp are Unix timestamps in seconds, so multiply by 1000 before passing them to Date. Forgetting that gives you timestamps in 1970 and a very confusing afternoon.
A note on Node.js#
In Node, atob exists in modern versions, but the idiomatic decode uses Buffer, which handles base64url and UTF-8 in one shot:
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf8")
);
The "base64url" encoding flag does the character swap and padding for you, which is why Node code looks cleaner than browser code for this task.
Decoding Is Not Verifying: The Mistake That Causes Breaches#
Here is the part the snippets never warn you about, and it is the single most important thing on this page. Decoding a JWT only reads the claims. It does nothing to confirm those claims are true.
Anyone can take a token, change the payload (say, flip "role": "user" to "role": "admin"), re-encode it, and hand it back to your server. The decoded payload will look perfectly valid. The only thing standing between that forged token and your data is signature verification, which requires the secret or public key and which decoding never touches.
| Action | Needs a key? | What it proves |
|---|---|---|
| Decode header/payload | No | What the token claims to say |
| Verify signature | Yes | That the token was issued by you and is unmodified |
Check exp / nbf | No (but only after verifying) | That the token is within its valid time window |
The rule is simple and absolute:
Never trust a decoded JWT payload to make a security decision. Decode it for display and debugging only. On the server, always verify the signature with a trusted library before you act on a single claim.
A decoded token is a claim, not a fact. Treating the payload as gospel because it "looks right" is exactly how privilege-escalation bugs get shipped. For the full breakdown of why these are two different operations and where teams get burned, read our guide on decode versus verify in JWT security.
"Is a JWT encrypted?" No, and that matters#
This is the misconception behind most JWT mistakes. A standard signed JWT (a JWS) is encoded, not encrypted. The payload is fully readable by anyone who intercepts the token. Signing protects integrity (nobody can change it undetected), not confidentiality (anyone can read it).
The practical consequences:
- Do not put secrets in a JWT payload. No passwords, no API keys, no personal data you would not print on a billboard. It is one base64url decode away from being read.
- If you genuinely need confidentiality, you want JWE (JSON Web Encryption), a different and less common format that actually encrypts the payload.
- Assume every JWT you issue will be inspected. Logs, browser dev tools, proxies, and any decoder can read it.
Reading the Standard Claims#
The payload can hold anything, but a set of registered claims have defined meanings. Knowing them helps you debug auth issues fast.
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who created and signed the token |
sub | Subject | Who the token is about, usually a user id |
aud | Audience | Who the token is intended for |
exp | Expiration | Unix time (seconds) after which the token is invalid |
nbf | Not before | Unix time before which the token is invalid |
iat | Issued at | Unix time the token was created |
jti | JWT ID | A unique identifier for the token |
When you are chasing a "why is this user getting logged out" bug, decode the token and check exp first. An expired exp or a clock-skew problem with nbf is the most common culprit, and you can spot it in seconds once the payload is readable.
When to Use a Decoder Tool Instead#
Writing the decode function is worth doing once so you understand the moving parts. For everyday debugging, though, pasting a function into the console for every token is slow, and a bad copy-paste reintroduces the base64url bug you just fixed.
A browser-based decoder is faster and safer for inspection:
- It splits, converts, and pretty-prints the header and payload instantly.
- It flags an expired
expso you do not have to do the timestamp math. - It runs in your browser, so the token does not get sent to a server (important, since the token is a live credential).
That last point matters more than people realize. A JWT is often an active session token. Pasting it into a sketchy online decoder that posts it to a backend is handing over working credentials. Our free JWT decoder decodes entirely in your browser, shows the header, payload, and a human-readable expiry, and never transmits the token anywhere. For the security context around what a decoder can and cannot tell you, our JWT decoder and token security guide goes deeper on safe inspection habits.
How to Decode a JWT Token Safely: The Summary#
To decode a JWT token, split it on the dots, convert each base64url part to standard base64 (swap - and _, restore padding), run atob, and parse the JSON. In Node, Buffer.from(part, "base64url") does the conversion for you. The header tells you the algorithm, the payload holds your claims, and iat and exp are seconds since 1970.
The non-negotiable part to carry away: decoding is reading, not trusting. A JWT payload is public, reversible, and forgeable. Decode it freely for display and debugging, but make every real authorization decision on the server after verifying the signature with a key. Get that distinction right and JWTs are simple. Blur it and you have a security hole that looks like working code.
Frequently Asked Questions#
How do I decode a JWT token without a library?
Split the token on its periods into header, payload, and signature, then base64url-decode the header and payload. In the browser, convert base64url to base64 (replace - with +, _ with /, and re-add = padding) before calling atob, then JSON.parse the result. In Node, Buffer.from(part, "base64url").toString("utf8") does it in one line.
Why does my atob call fail on a JWT?
Because JWTs use base64url, not standard base64. They contain - and _ instead of + and /, and they strip the = padding. Passing that raw string to atob throws an error or returns garbage. Convert the characters and restore the padding first, which is the step most quick snippets leave out.
Is a JWT encrypted or just encoded? A standard signed JWT is encoded, not encrypted. The payload is base64url, which anyone can reverse with no key, so treat its contents as public. The signature protects against tampering, not against reading. If you need the payload to be confidential, use JWE (JSON Web Encryption) instead.
Can someone read or change my JWT payload? Anyone who has the token can read the payload instantly, since it is just base64url. They can also change it and re-encode it. What they cannot do is produce a valid signature without your secret or private key, which is why your server must verify the signature rather than trusting the decoded claims.
Does decoding a JWT verify that it is valid?
No. Decoding only reads what the token claims. Verification is a separate step that checks the signature against your key to confirm the token is authentic and unmodified, and then checks exp and nbf for timing. Never make an authorization decision based on a decoded payload alone.
What do iat and exp mean in a JWT?
They are timestamps. iat (issued at) is when the token was created, and exp (expiration) is when it stops being valid. Both are Unix time in seconds, so multiply by 1000 before constructing a JavaScript Date. A token is expired when exp * 1000 is less than the current time.



