The situation that makes this matter
An API call is returning 401 Unauthorized. The token looks valid. The expiry timestamp looks right. The logs show the request hitting the server with a token that the auth middleware rejects. The bug is somewhere between the token being issued and the token being validated, and you don't know which claim is wrong.
In this situation, reading the token directly is the fastest way to narrow down the problem. What role is encoded in it? Is the iss (issuer) correct? Is the aud (audience) what the server expects? Is the expiry actually in the future, or has someone accidentally set a token with a 30-second lifetime?
If you know how to read a JWT token, this debug loop takes under a minute. If you don't, it can take an hour.
The structure of a JWT
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three chunks separated by two dots. That's all it is. Each chunk is Base64url-encoded:
- Chunk 1 — Header: Metadata about the token itself. Almost always contains
alg(the signing algorithm, e.g., HS256, RS256) andtyp(always"JWT"). - Chunk 2 — Payload: The actual claims — the useful data. This is where you find the user ID, email, roles, expiry time, issuer, audience, and any custom claims your system adds.
- Chunk 3 — Signature:A cryptographic signature over the first two chunks. This proves the token was issued by someone who holds the secret key and hasn't been tampered with since.
Reading the payload in the browser console
Copy any JWT token. Open your browser console (F12 → Console). Paste this:
const token = "your.jwt.token.here"
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
console.log(payload)That's it. The atob() function decodes Base64. The replace calls convert Base64url characters (- and _) to standard Base64 (+ and /) so atob() can handle it. JSON.parse gives you a JavaScript object you can inspect.
You can do the same with the header by replacing split('.')[1] with split('.')[0]. The signature (chunk 2, index 2) is a binary hash — it won't decode to readable text; don't try.
What to look at when debugging auth
Here are the payload fields I check first when an auth bug is unclear:
exp— Expiry timestamp, as a Unix timestamp (seconds since epoch). If this is in the past, the token is expired. Convert it:new Date(payload.exp * 1000)gives you a human-readable date.iat— Issued At. When the token was generated. Ifexp - iatis 30 (seconds), your token lifetime is 30 seconds. That's probably not intentional.iss— Issuer. The URL or identifier of the auth server that issued this token. If this doesn't match what your resource server expects, the request will fail even if the signature is valid.aud— Audience. Who the token is intended for. Resource servers validate thataudmatches their own identifier. Tokens issued for one service will be rejected by another if audience validation is enabled.sub— Subject. The user ID or entity the token represents. Check this if you're seeing authorization bugs where requests succeed but the wrong user's data is returned.scopeor custom role claims — Many auth systems include ascopefield (OAuth 2.0) or a customrolesarray. If your middleware checks role claims and the user is getting a 403, this is where to look.
Checking expiry without writing code
The exp field is a Unix timestamp. To convert it in your head: Unix timestamps count seconds since January 1, 1970 UTC. As of mid-2026, a current timestamp is around 1,750,000,000. An exp value significantly below that is expired. An exp value of 0 or missing means no expiry is set (some auth systems issue non-expiring tokens deliberately).
To check quickly: new Date(payload.exp * 1000).toISOString() gives you ISO 8601 format. If the date is yesterday, the token is expired.
What the signature does and doesn't protect
The signature proves that the first two chunks haven't been modified since the token was issued by someone holding the secret key. It does not:
- Encrypt the payload. Anyone who intercepts the token can read the claims.
- Prove the user is currently authorized — only that the token was validly issued.
- Prevent token replay if the token is stolen (that's what the
expis for).
This is why JWTs containing sensitive claims should only travel over HTTPS, and why the payload should contain the minimum necessary information. User roles are usually fine in a JWT payload. User passwords, credit card numbers, and SSNs are not.
Algorithm confusion attacks (the one thing to know about alg)
The header contains an algfield. In early JWT library implementations, attackers could modify a token's alg field from RS256 (RSA asymmetric — requires a private key to sign) to HS256(HMAC symmetric — signs with a shared secret) and then sign the token with the server's public key, which is often publicly available. Some early libraries would accept this as valid.
This vulnerability was common in 2016–2018. Modern JWT libraries (jsonwebtoken v9+, jose, python-jwt 2.x+) require you to specify the expected algorithm explicitly and reject tokens whose header claims a different one. If you're using a JWT library that doesn't require you to specify the expected algorithm, update it.
Reading tokens safely
Since the payload is Base64-encoded text, not encrypted, you should be careful about where you decode tokens for debugging. Pasting a production auth token into a random online JWT decoder sends the token to that service's server. If the token is still valid, you've just handed someone a working credential.
Use the browser console (described above) or the JWT decoder tool on this site — it runs entirely in your browser, nothing is sent to any server.
Related tools
- JWT decoder — paste any JWT to inspect its header, payload, and expiry without sending it anywhere.
- Base64 encoder/decoder — decode individual Base64url chunks manually.
- JSON formatter — format the decoded payload for easier reading if it's a large or deeply nested object.
Written by Achraf A., founder of TheFreeAITools — built in Morocco. The browser console technique above is the one I actually use when debugging auth in staging environments.