A JWT looks like random noise, but two of its three parts are base64url-encoded JSON that anyone holding the token can read. You can decode a JWT in one line in your terminal, so there's no need to paste a production token into a website to see what's in it.
You usually need this when a request comes back 401 and you're holding an access token and a refresh token. Decoding both tells you which one expired and what each payload claims.
Quick answer
Decode the payload with jq:
echo "$TOKEN" | jq -R 'split(".") | .[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'
Or with Node.js, which understands base64url natively:
node -e 'const [h,p]=process.argv[1].split(".");console.log(JSON.parse(Buffer.from(h,"base64url")),JSON.parse(Buffer.from(p,"base64url")))' "$TOKEN"
Change .[1] to .[0] in the jq version to get the header. The two gsub calls matter: JWTs use the base64url alphabet (- and _ instead of + and /), and jq's @base64d rejects those characters with is not valid base64 data.
The three parts of a JWT
Here's a sample token, signed with a throwaway secret:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9zw6kgUml6YWwiLCJlbWFpbCI6Impvc2VAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiJ1c2VyXzhmMmMiLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTE3NjMyMDAsImV4cCI6MTc5MTc2NDEwMH0.I7iI1g6EZLoyD3cgEPgLNS80sh6t4mvSpHdSsowirvI
It splits on the dots into header.payload.signature.
The header says how the token was signed:
{ "alg": "HS256", "typ": "JWT" }
HS256 is HMAC with SHA-256 and a shared secret. You'll also see RS256 or ES256 (signed with a private key, checked with a public key), and often a kid that tells the server which key to use.
The payload holds the claims:
{
"name": "José Rizal",
"email": "jose@example.com",
"role": "admin",
"sub": "user_8f2c",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"iat": 1791763200,
"exp": 1791764100
}
The three-letter names are registered claims from RFC 7519:
iss: who issued the tokensub: who the token is about, usually a user IDaud: who the token is for, usually your APIexp,iat,nbf: expires at, issued at, not valid beforejti: a unique token ID, used for revocation lists
Everything else (name, email, role) is whatever the issuer decided to add.
The signature is the last part. The issuer computes it over header.payload (the two encoded strings joined by the dot) with the secret or private key. It's binary, so decoding it gives you nothing readable. It only means something to code that has the key.
Reading exp, iat and nbf
The time claims are Unix timestamps in seconds. Convert one with date:
date -u -d @1791764100
# Mon Oct 12 00:15:00 UTC 2026
On macOS it's date -u -r 1791764100.
exp - iat tells you the token lifetime. Here it's 900 seconds, so 15 minutes, a common lifetime for an access token. If a token you're debugging lives for 30 days, that's worth asking about.
In JavaScript, multiply by 1000 before comparing:
const expiresAt = new Date(payload.exp * 1000)
const isExpired = expiresAt.getTime() < Date.now()
payload.exp < Date.now() compares seconds to milliseconds, and it's true for every token, including one issued a second ago.
Decoding a JWT in the browser
atob looks like the obvious tool, and it fails in two ways. It throws Invalid character on any - or _, which shows up in plenty of real tokens. When it doesn't throw, it decodes to Latin-1, so the José in the payload above comes out as José.
This version handles both:
function decodeJwtPart(part) {
const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
}
const [rawHeader, rawPayload] = token.split('.')
const header = decodeJwtPart(rawHeader)
const payload = decodeJwtPart(rawPayload)
Skip the third part. The signature is binary, so JSON.parse would throw on it.
Base64url also drops the trailing = padding. atob accepts unpadded input, but base64 -d on the command line doesn't, which is why the shell examples above use jq and node instead.
Decoding a JWT doesn't verify it
Anyone can decode a token, and anyone can build a new one. Take the sample token, change role to superadmin, and keep the original signature. The result decodes fine, and any code that only decodes will believe it.
On the server, use a library that checks the signature and the claims. With jose:
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})
Each case, run against jose 5.10:
correct secret → OK, payload.sub = user_8f2c
wrong secret → ERR_JWS_SIGNATURE_VERIFICATION_FAILED
role changed by hand → ERR_JWS_SIGNATURE_VERIFICATION_FAILED
after exp → ERR_JWT_EXPIRED
header alg: "none" → ERR_JOSE_ALG_NOT_ALLOWED
Pin algorithms explicitly. Old JWT libraries trusted the alg in the header, which let an attacker send "alg": "none" with an empty signature and have it accepted.
Decode-only helpers such as jwt-decode or jose's decodeJwt are fine in the frontend, for things like showing the user's name or refreshing a token before exp. They should never decide what a request is allowed to do.
Why you shouldn't paste production tokens into random sites
A valid JWT is a bearer token. Whoever has the string can call your API as that user until exp, and most setups can't revoke a single JWT early because the server doesn't store them. A token copied from a production request is a live credential for that window.
When you paste it into a web decoder, you can't see what that site does with it. It might decode everything in the browser. It might also send the token to its server for analytics or error logging.
What to do instead:
- Decode locally with the
jqornodecommand above. - If you use a web tool, open the DevTools Network tab first, paste the token, and check that no request goes out.
- Prefer a token from a dev or staging environment, or one that has already expired. The structure is the same.
- If a production token did end up somewhere it shouldn't, rotate what you can: log the user out, and if it was a long-lived token, rotate the signing key.
If you'd rather not type commands, the JWT decoder on this site shows the header, payload, and exp/iat/nbf as dates, and flags expired tokens. It decodes in your browser and sends nothing, which you can confirm with the Network tab check above.
Common mistakes
Putting secrets in the payload. The payload is encoded, which anyone can reverse. Don't put passwords, API keys, or anything you wouldn't put in a URL. If the contents must stay private, you need an encrypted JWE, which has five dot-separated parts instead of three.
Leaving the Bearer prefix on. If you copied the Authorization header, strip the prefix first, or split(".") gives you a garbage first part. In jq, add sub("^Bearer +";"") before the split.
Trusting a decoded claim on the server. Reading role from a decoded token and skipping verification means anyone can make themselves an admin, as the edited token above shows.
Comparing exp to Date.now() directly. That compares seconds to milliseconds, so every token looks expired.
Conclusion
To decode a JWT, split it on the dots, base64url-decode the first two parts, and read the JSON. Do it with jq or node on your own machine instead of pasting a live token into a website. Decoding only shows you what the token claims. On the server, verify the signature with a library like jose and pin the algorithm.