Advertisement

The web has a peculiar foundation: HTTP is stateless, meaning each request is, by default, independent and knows nothing about the ones before it. Yet websites obviously remember who you are as you click around. Bridging that gap is the job of cookies, sessions and tokens — three related ideas that developers routinely muddle.

Sorting them out clarifies a lot about how authentication actually works.

Cookies: the memory the browser carries

A cookie is a small piece of data a server asks the browser to store and send back with future requests to that site. This is the basic mechanism that lets a server recognise a returning visitor across otherwise-independent requests. On its own a cookie is just storage — the meaning comes from what you put in it and how you protect it.

Because cookies travel with requests automatically, they are convenient but must be handled carefully, with appropriate security flags, to avoid being misused.

Advertisement

Sessions: state kept on the server

In the session approach, the server keeps the real information about a logged-in user in its own storage and gives the browser only an identifier — typically in a cookie. Each request sends that identifier, and the server looks up who it belongs to. The sensitive state lives on the server; the browser just carries a claim ticket.

This gives the server tight control — it can invalidate a session instantly — at the cost of having to store and look up session data for every active user.

Tokens: state carried by the client

The token approach flips this: the server issues a signed token containing (or referencing) the user’s identity, and the client sends it with each request. The server verifies the signature rather than looking the user up in its own session store, which suits systems spread across many services. The trade-off is that a genuinely stateless token is harder to revoke before it expires.

None of these is universally best. The right choice depends on your architecture and security needs — but knowing what each one actually stores, and where, is what lets you reason about it at all.

Advertisement

Three parts, dot-separated, and only one of them is protected

A JSON Web Token is three base64url-encoded segments joined by dots: a header describing the signing algorithm, a payload holding the actual claims — user id, roles, expiration time, whatever the issuer decided to include — and a signature computed over the first two segments. The critical, frequently misunderstood detail is that base64url encoding is not encryption: anyone holding a JWT can decode the header and payload instantly using nothing more than a text editor and a base64 decoder, and see every claim inside in plain readable text. What the signature actually protects is integrity, not secrecy — it lets the server verify that the payload has not been tampered with since it was issued, not that the payload's contents are hidden from whoever is holding the token.

This has a direct practical consequence that trips people up constantly: never put a genuine secret — a password, a raw credit card number, anything that should not be visible to whoever holds the token — into a JWT payload, because 'signed' very specifically does not mean 'encrypted,' and anyone intercepting the token can read every claim in it just by decoding, with no key or secret required at all.

Symmetric versus asymmetric signing: who is allowed to issue tokens

HS256, a symmetric algorithm, signs and verifies a token using the exact same secret key, which means anything capable of verifying a token is also capable of forging one — fine when a single service both issues and checks its own tokens, but a real liability the moment several independent services need to verify tokens issued by one central authority, because distributing that one shared secret to every verifying service also hands each of them the ability to mint arbitrary valid tokens themselves. RS256, an asymmetric algorithm, separates the two roles: tokens are signed with a private key only the issuing authority holds, and verified with a corresponding public key that can be distributed freely to any number of services, none of which gain any ability to forge new tokens just because they can verify existing ones. This is exactly why RS256 is the default choice in any system with more than one service that needs to check tokens independently, even though it is computationally a bit more expensive than the symmetric alternative.

Advertisement

The revocation problem, precisely stated

The entire performance appeal of tokens is that a server can verify one without looking anything up in shared storage — no database call, no cache lookup, just cryptographic verification of the signature and a check of the expiration claim. That same property is exactly what makes a JWT hard to revoke: there is no central record for a server to check against, and none to delete, so a token remains fully valid, accepted by any verifier, for its entire stated lifetime regardless of anything that happens after it was issued — a user changing their password, an administrator disabling their account, a security team explicitly wanting a specific token invalidated right now. This is not a bug or an oversight in how JWTs are designed; it is the direct, unavoidable consequence of the exact same statelessness that makes them fast and easy to scale in the first place.

Why access tokens are kept short-lived and refresh tokens are not

The standard mitigation for the revocation problem is architectural rather than cryptographic: issue access tokens with a short lifetime — commonly fifteen minutes or less — precisely so that a compromised or logically-should-be-revoked token has only that same short window during which it stays exploitable. A refresh token, longer-lived and used only to obtain fresh access tokens rather than sent with every ordinary request, is what makes short-lived access tokens practical without forcing the user to log in again every fifteen minutes — and critically, refresh tokens are typically tracked server-side and can be revoked directly, since they are used rarely enough that a database lookup on each refresh is an acceptable cost, unlike the far more frequent access-token checks that need to stay lookup-free. This split is the actual mechanism that gives JWT-based systems something close to session-store-style revocability, just with a deliberate delay bounded by the access token's short lifetime rather than truly immediate effect.

Where to actually store a token in the browser, and why the obvious answer is wrong

Storing a JWT in `localStorage` is a common first instinct because it is simple and directly accessible from JavaScript, and it is also a genuine security liability: any successful cross-site scripting attack on the page gets trivial, complete access to read the token directly, because `localStorage` has no equivalent to a cookie's HttpOnly flag that would keep it out of reach of running JavaScript. Storing the token in an HttpOnly, Secure, SameSite cookie instead closes off that specific theft vector — JavaScript cannot read an HttpOnly cookie's contents even if an XSS vulnerability exists elsewhere on the page — but it reopens exposure to cross-site request forgery, since cookies are attached to requests automatically by the browser regardless of which site's page triggered the request. Neither storage location is a strictly safe default; each closes one class of attack while leaving the other theoretically open, which is why real hardening combines HttpOnly cookie storage with CSRF tokens or the SameSite cookie attribute, addressing both risks together rather than treating either storage choice alone as sufficient protection on its own.

The "none" algorithm vulnerability: a lesson in trusting the wrong field

A well-known class of real JWT vulnerabilities came from libraries that trusted the algorithm named in the token's own header rather than enforcing which algorithm the verifier actually expected — the JWT specification includes an 'alg: none' option for genuinely unsigned tokens, and a verifier that naively reads the algorithm from the token itself and honors whatever it says can be tricked into accepting a completely unsigned, trivially forged token simply because the attacker set the header to say so. The correct, hardened verification pattern never trusts the token's own claimed algorithm; it is configured in advance with the specific algorithm and key the verifier expects, and rejects outright any token that does not match, regardless of what the token's header itself claims about how it was supposedly signed.

Clock skew and the expiration check that fails in a confusing way

A JWT's expiration claim is just a timestamp, checked against the verifying server's own clock, which introduces a subtle operational failure mode in any system spanning multiple servers: if one server's clock has drifted even a little relative to the one that issued the token, a token that should still be valid can be rejected as expired, or one that should have expired can still be accepted, purely because of a clock disagreement that has nothing to do with the token itself. Production JWT libraries typically build in a small configurable leeway — a few seconds of tolerance around the exact expiration boundary — specifically to absorb minor, expected clock drift between machines, though this is a mitigation for a symptom rather than a fix for the underlying cause, which is why keeping server clocks properly synchronized via NTP remains the actual first line of defense.

Claims beyond identity: audience and issuer as a second-guard against misuse

Beyond who the user is and when the token expires, the JWT specification defines an issuer claim, naming which authority created the token, and an audience claim, naming which service or services the token is actually meant for — and checking both, not just the signature, matters more than it might first appear: a token correctly signed by a legitimate authority but intended for one internal service could otherwise be replayed against a completely different service if that service only checks the signature and never confirms the token was actually meant for it. Verifying audience and issuer alongside the signature closes off exactly this kind of token-replay-across-services risk, which is a real concern in any system where one central identity provider issues tokens consumed by many different internal services with different trust levels.

Why a JWT is usually the wrong place for frequently changing data

Because a JWT's claims are baked in at signing time and cannot be updated without issuing an entirely new token, embedding data that changes often — a live permission set, a mutable profile field — quietly reintroduces a staleness problem: the token keeps asserting whatever was true at issuance until it is refreshed, which is fine for slowly changing facts like a user id but a poor fit for anything expected to reflect current state at the moment of each request.

Refresh token rotation: catching a stolen refresh token in the act

A further hardening on top of a plain long-lived refresh token is rotation: each time a refresh token is used to obtain a new access token, the server also issues a brand new refresh token and immediately invalidates the old one, so a legitimate client and an attacker who both happen to be holding the same stolen refresh token will have one of their next refresh attempts rejected outright, since only the first one to use it succeeds — and a server seeing an already-invalidated refresh token reused is itself a strong, actionable signal that a theft has actually occurred.