If you've built an API, a login flow, or anything that needs to know who's making a request, you've probably run into JSON Web Tokens. Here's a closer look at what's actually inside one.
The basic idea
A JWT is a string your server hands to a client after a successful login. The client sends that string back with every request afterward, instead of a username and password. The server checks it, sees it's legitimate, and knows who's asking without having to look anything up in a database.
That's the whole point: no server-side session to store or manage. The information about who you are travels with the token itself.
What's actually inside it
A JWT is three chunks of text separated by dots, each one Base64Url-encoded on its own. Something like:
xxxxx.yyyyy.zzzzz
Each chunk means something different:
Header. Just says what algorithm was used to sign the thing, and that it's a JWT. The algorithm choice actually matters, a few you'll run into regularly:
HS256 (HMAC + SHA-256): one shared secret signs and verifies. Simple, but that secret has to live on every server that needs to check a token, which gets awkward once you have more than one.
RS256 (RSA + SHA-256): a private key signs, a public key verifies. Useful when other services need to check tokens without ever having the ability to issue new ones.
ES256 (ECDSA + SHA-256): does the same job as RS256, asymmetric signing, with smaller keys and faster verification.
none: a real, valid value in the spec that skips signing entirely. Some libraries used to trust it by default, which let attackers just strip the signature and have the token accepted anyway. Any maintained JWT library rejects it now, but it's worth knowing it exists, and worth checking that your dependencies actually reject it.
Payload. This is where the actual data lives, called claims. Who the user is, maybe their role, when the token was issued, when it expires. This part is readable by anyone who has the token. It's not hidden, it's just encoded into a URL-safe text format. If you paste a JWT into a decoder, you'll see the payload as plain JSON. That's worth repeating because people mix this up constantly: a JWT is encoded, not encrypted. Don't put anything in there you wouldn't want a user to read directly, because they can.
Signature. This is the part that prevents the token from being tampered with, using the algorithm named in the header to verify that the payload hasn't changed since it was issued. The server signs the header and payload using a secret key only it knows: the shared secret if the header says HS256, or a private key if it says RS256 or ES256. Technically it signs the encoded header and payload joined by a dot, not the raw JSON, but that's the core idea. If anyone tampers with the payload, even changing one character, the signature won't match anymore and the server rejects the token. This is what stops a user from editing their own token to say
"admin": true.
How it works
Here's the flow at its simplest. Real systems usually add more around it, refresh tokens, rate limiting, service-to-service checks, but this is the core exchange everything else builds on.
User authenticates, username and password, OAuth, or whatever the app uses.
Server checks the credentials, and if they're good, builds a token and signs it.
Client stores that token somewhere and attaches it to every future request, usually in an HTTP header.
Server receives the request, checks the signature, and if it's valid, trusts the claims inside without touching a database.
In practice, step three looks like this. The client adds an Authorization header to the request, with the word Bearer in front of the token:
GET /user-data HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzEzODgzNjAwfQ.T6nby8szcF3a2lOxhD5dUbNleSy7A9vBPsPHmCyQcY4That token isn't a black box. Paste it into jwt.io and it decodes into exactly what's described above, no secret required to see this part:
{
"alg": "HS256",
"typ": "JWT"
}{
"sub": "user123",
"role": "admin",
"exp": 1713883600
}The secret is only needed if you want to verify the signature itself, which jwt.io also lets you test. This token is actually signed with the secret jwt.io's debugger has pre-filled by default, so it'll show as a valid, verified signature without you having to type anything in.
Step four is really the whole reason JWTs exist. A traditional session needs the server to remember something: a session ID stored somewhere, looked up on every request. A JWT carries its own proof. The server just does a bit of math to check the signature and moves on.
Where this actually helps
It's most useful when you've got more than one server or service that needs to check who's making a request, without all of them sharing a session store.
Picture an app split into an auth service, an orders service, and an inventory service. With traditional sessions, every one of those either needs access to the same session database, or has to call back to the auth service on every single request just to ask "is this user real?" That's a shared dependency, and a network hop, on every request that comes in.
With a JWT, the orders service and the inventory service don't need to talk to the auth service at all. They just need the shared secret or public key to check the signature themselves. The token is the passport: any service that can verify the signature can trust the claims inside it, no round trip back to whoever issued it.
That's the pattern behind why JWTs show up so often in APIs, microservices, single-page apps calling multiple backends, and mobile apps talking to a handful of services instead of one monolith. Different setups, same underlying reason: many services, one identity, nothing shared to keep in sync.
Common pitfalls
A few notes worth keeping in mind:
Tokens don't expire on their own just because you'd like them to. You set an expiry time in the payload, and it's on you to check it and issue a new one before or after it runs out.
Once a token is issued, there's no built-in way to cancel it. It's valid until it expires, no matter what happens on your end in the meantime. This is the tradeoff: you gain statelessness, you lose easy revocation. Most real systems work around this with short expiry times and a separate refresh token to renew things quietly in the background.
And because the payload is just readable text once decoded, it's not the place for anything sensitive. Names and roles, fine. Anything you'd hesitate to put in a URL, leave it out.
In short
A JWT is a signed, self-contained proof of identity. The signature prevents tampering, the payload carries the claims, and the whole design trades centralized session control for stateless verification. That tradeoff is worth understanding before you reach for JWT by default, not after.