Development

What Is Actually Inside a JWT (And Why You Should Never Put Secrets in One)

Published on Sep 14, 20265 min read

Featured image for What Is Actually Inside a JWT (And Why You Should Never Put Secrets in One)

A JWT payload is encoded, not encrypted — anyone holding the token can read it. What the signature really protects, and why tokens expire.

JSON Web Tokens are used by almost every modern web application and misunderstood by a remarkable number of the people using them. The single most consequential misunderstanding is the belief that a JWT hides its contents. It does not, it never has, and a surprising amount of production data has been exposed by teams who assumed otherwise.

Three parts, separated by dots

A JWT is a string with three sections divided by full stops: header, payload and signature.

The header declares the token type and the algorithm used to sign it, typically something like HS256 or RS256.

The payload carries the claims — the actual data. Standard claims have short registered names: sub for the subject, usually a user ID; exp for the expiry time; iat for when it was issued; iss for the issuer; and aud for the intended audience. Applications add their own claims alongside these.

The signature is computed over the header and payload together, using a secret or a private key. It is what makes the token trustworthy.

Encoded is not encrypted

The header and payload are Base64URL encoded. Base64 is an encoding, not a form of protection. It exists to represent binary data safely in a URL or an HTTP header, and it is trivially reversible — any decoder, including a browser console, will turn it straight back into readable JSON. There is no key involved and no secret required.

This means anyone who obtains a token can read everything in it. Not a determined attacker with tooling — anyone, in about three seconds.

The practical consequence is a rule worth stating plainly: never put anything sensitive in a JWT payload. No personal data beyond an opaque identifier, no internal system details, no roles you would rather competitors not enumerate, and obviously no credentials. Tokens travel through browsers, get written into logs, appear in error reports and sit in local storage. Everything in the payload should be treated as published.

What the signature does and does not do

If the payload is readable, what is the signature for? Integrity, not confidentiality. It proves that the token was issued by someone holding the signing key and has not been altered since.

Change a single character of the payload — flip a role from user to admin, push the expiry out a year — and the signature no longer matches the content. A server that verifies properly rejects it. That is the entire security model: you can read the token, but you cannot forge or modify one.

Which makes verification the part that has to be right. A server that decodes a token without checking the signature has no security at all, because the token is just user-supplied JSON at that point.

The classic failure here is the alg: none attack. The spec permits a "none" algorithm meaning unsigned, and some libraries historically honoured whatever the token's own header requested. An attacker could set the algorithm to none, strip the signature, write any payload they liked, and be believed. A related attack switches RS256 to HS256 so the library verifies using the public key as if it were a shared secret. Both are fixed in maintained libraries, and both are reasons to specify the expected algorithm server-side rather than trusting the header.

Why your token stopped working

By a wide margin, the most common JWT problem in practice is an expired token producing a 401 that seems to come from nowhere — everything worked five minutes ago and now nothing does.

The exp claim is a Unix timestamp, seconds since 1 January 1970 UTC. Two things about it cause repeated confusion. First, it is in seconds, while JavaScript's Date.now returns milliseconds — mixing them up produces expiry dates either in 1970 or tens of thousands of years from now, both of which are immediately recognisable once you know to look. Second, verification compares against the server's clock, so a server whose time has drifted will reject perfectly valid tokens or accept expired ones.

When debugging an authentication problem, decoding the token and comparing exp against the current time answers the question most of the time, before you look at anything else.

Short-lived access, long-lived refresh

That leads to the design pattern most systems settle on, and the reason it exists.

A signed JWT cannot be revoked. Once issued, it is valid until it expires, because verification is a local computation — the server checks the signature and the expiry and needs to consult nothing. That statelessness is precisely why JWTs scale well, and it is also the problem: if a token is stolen, or a user logs out, or an account is suspended, any already-issued token keeps working.

The standard answer is two tokens. A short-lived access token, often fifteen minutes, is sent with every request and carries the authorisation. A long-lived refresh token is stored more carefully, sent only to the endpoint that issues new access tokens, and can be tracked and revoked server-side because that endpoint is consulted rarely enough to afford a database lookup.

This bounds the damage. A stolen access token is useful for minutes rather than weeks, and revoking the refresh token stops new ones being issued. It is a compromise between stateless scalability and the ability to actually revoke access, and understanding it as a compromise makes the design choices clearer.

Where to store them

Browser storage for tokens is a genuine trade-off rather than a solved problem, and both common answers have real weaknesses.

Local storage is readable by any JavaScript running on the page, which means a single cross-site scripting vulnerability, including one in a third-party script you did not write, exposes the token.

HttpOnly cookies cannot be read by JavaScript, which removes that risk, but cookies are attached automatically to requests and therefore introduce cross-site request forgery exposure unless you set SameSite appropriately and use anti-CSRF measures.

The generally preferred approach is HttpOnly, Secure, SameSite cookies with CSRF protection, because XSS is both more common and more damaging than CSRF. But "preferred" is doing real work in that sentence — neither option is free, and choosing one means accepting the mitigation burden that comes with it.

A short debugging checklist

Decode the token and read the claims. Check exp against the current time, minding seconds versus milliseconds. Confirm iss and aud match what the server expects. Verify the algorithm is what you intended and not whatever the header asked for. Check the server's clock. And confirm nothing sensitive is sitting in the payload, because if it is, it has already been readable to everyone who has ever held the token.

Share on XShare on LinkedInShare on WhatsApp

Looking for free tools? Try our word counter, image compressor, or password generator— or browse all 100+ free tools in our Tools section.

Free Tools You Might Find Useful

Need help building your website?

CodexStudio builds fast, SEO-optimized websites for businesses in Islamabad and worldwide.

About the Author

Saif Ali

Saif Ali

Founder & Lead Developer at CodexStudio

Saif builds SEO-focused websites, web apps, and product systems for startups and businesses in Pakistan and worldwide.

More from our blog