How JWT Authentication Actually Works Under the Hood
Most developers use JWTs every day without knowing what's actually inside them. Here's how the signing, verification, and stateless auth actually works, and where it breaks.
On this page
How JWT Authentication Actually Works Under the Hood
If you’ve worked on any backend project, you’ve probably used JWT without really thinking about what’s inside that token. Called the library, got a token back, stored it, moved on. But the interesting part is how the server verifies that token on every request without ever touching a database.
What a JWT Actually Is
Strip away the jargon and a JWT is just three base64-encoded strings joined by dots.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwMDAwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Split it at the dots and you get three parts:
| Part | What it contains |
|---|---|
| Header | Which algorithm was used to sign this token |
| Payload | Your claims: user ID, roles, expiry time |
| Signature | Proof that the header and payload were not tampered with |
Go to jwt.io, paste any JWT, and you can read the header and payload in plain JSON immediately. No password. No key. Just paste and read.
Which brings up something a lot of developers miss: JWTs are encoded, not encrypted. Anyone who gets their hands on your token can read everything in the payload. Encoding is just a compact representation format, not a security measure. Never put passwords, sensitive personal data, or anything you would not want exposed in a JWT payload. If you genuinely need the payload to be unreadable, that is what JWE (JSON Web Encryption) is for, which is a separate spec entirely.
How the Signing Actually Works
The signature is the interesting part. It is what makes the whole trust model work.
When you log in, the server takes the encoded header and encoded payload, combines them, then runs that string through a signing algorithm using a secret key. The output gets appended as the third part of the token. The server sends you the full token and forgets about it.
When you send that token on a later request, the server takes the header and payload out of your token, runs the same signing operation with the same secret key, and compares the output to the signature you sent. If they match, the server knows the token was created by someone who had the secret key and that nobody modified the header or payload after it was signed. Change even a single character in the payload and the signature comparison will fail.
That is the whole trust model. The server is not storing your session anywhere. It is just checking math.
HS256 vs RS256
The algorithm field in the header tells verifiers how the token was signed. You will mostly run into two options.
HS256 uses a single shared secret. The same key signs the token and verifies it. Fine for a monolith or a single service. The problem shows up when you have multiple services that all need to verify tokens. Now every one of those services needs access to the same secret, which means more places for it to leak.
RS256 uses a key pair. The auth server signs with a private key that it never shares. Every other service verifies with the public key, which can be distributed freely. Your other services can validate tokens independently without ever having access to anything sensitive.
For a single-service app, HS256 is fine. For anything with multiple services, RS256 is worth the slightly more involved setup.
What the Full Flow Looks Like
Starting from login through to a verified API request:
Login: You send credentials to the auth server. It validates them and builds a payload with your claims.
{
"sub": "user_123",
"role": "admin",
"iat": 1700000000,
"exp": 1700003600
}
sub is your user ID. iat is the issued-at timestamp. exp is when the token stops being valid. The server signs this and sends you the token.
Storage: Where you store the token on the client side actually matters. In memory is safest but disappears on page refresh. In an HttpOnly cookie, JavaScript on the page cannot read it, which protects against XSS attacks. In localStorage, any JavaScript running on your page can read it, including anything injected by an attacker.
Requests: Every call to a protected endpoint includes the token in the Authorization header.
Authorization: Bearer eyJhbGci...
Verification: The server pulls the token out of the header, verifies the signature, checks the expiry, and reads the claims directly from the payload to make authorization decisions. No database query. This is the thing that makes JWT genuinely fast at scale.
The Refresh Token Pattern
Short-lived tokens reduce the damage window if a token gets stolen. But expiring tokens every 15 minutes and making users log in again is not a real product.
The standard solution is two tokens at login. An access token with a short lifetime, maybe 15 minutes to an hour. And a refresh token with a long lifetime, days or weeks. The access token goes in memory or a short-lived cookie. The refresh token goes in an HttpOnly cookie and never gets sent to your API endpoints.
When a request comes back with a 401 because the access token expired, the client sends the refresh token to the auth server, gets a new access token, and retries. The user sees nothing.
Refresh tokens are stored server-side, which means they can be revoked. If a user logs out or an account gets compromised, you invalidate the refresh token and no new access tokens can be issued. The existing access token stays valid until it expires naturally, but with a short lifetime that window is manageable.
Where JWT Causes Problems
This is also the part that bites teams in production.
Revocation is genuinely hard. If you need to immediately invalidate a user’s session, JWT makes that painful. The access token is valid until it expires and there is no built-in way to revoke it. You can maintain a server-side denylist of invalidated token IDs, but then you have a database lookup on every request, which is what you were trying to avoid. Short expiry times are your best practical mitigation.
The alg:none attack. The JWT spec includes an algorithm value of none for unsigned tokens. Some older JWT libraries treated this as valid and would accept any token with alg: none without checking a signature at all. An attacker could craft a token with arbitrary claims and it would pass verification. Always hardcode the expected algorithm on your server. Never accept the algorithm value from the incoming token header.
Algorithm confusion. If your server uses RS256 but accepts whatever algorithm the token claims to use, an attacker can change the header to say HS256 and sign the token using your server’s public key as the HMAC secret. The public key is not secret, so they already have it. A naive verifier using the algorithm from the token header might accept this. The fix is the same as above: hardcode the algorithm.
Sensitive data in the payload. This keeps showing up in code reviews. Developers put email addresses, phone numbers, and internal role identifiers in JWT payloads assuming the encoding provides some protection. It does not. If you are logging Authorization headers anywhere in your infrastructure, you are logging that data in a readable form.
When JWT Makes Sense and When It Does Not
JWT is a good fit when you are building stateless REST APIs across multiple services, when you need cross-domain authentication where one auth provider serves several applications, or when you are building for mobile and SPA clients where managing cookies across domains gets complicated. In these contexts, JWTs are commonly used as access tokens issued via OAuth 2.0.
It is not the right choice when you need to revoke sessions immediately (account bans, security incidents), when you are building a simple server-rendered web app where a session cookie is simpler and easier to manage, or when your payload would need to contain sensitive data.
The Actual Tradeoff
JWT removes the database lookup from every request by moving trust into the token itself. That is genuinely useful at scale. But the reason you can skip the database lookup is that the server has no record of the token and cannot cancel it. The speed and the revocation problem come from the same design decision. If you go into JWT knowing that, you will make better choices about token lifetimes and when to reach for something else instead.