Every API has to answer one question on every request: who is this? Access tokens and session tokens are the two standard answers, and choosing between them decides how your service scales, how fast you can log someone out, and how much damage a stolen credential can do.
The short version: a session token is a meaningless identifier that your server looks up, and an access token carries its claims inside itself so no lookup is needed. That single difference drives everything else, including the trade-off almost everyone gets wrong, which is revocation.
How Session Tokens Work
A session token is a long random string with no meaning of its own. When a user logs in, your server creates a session record, stores it somewhere both fast and shared, usually Redis or a database table, and hands the user the identifier.
The session record holds the real information: the user ID, when the session was created, roles, and anything else your app needs. The token is just the key.
On every subsequent request the server takes the token, looks up the record, and either finds a valid session or rejects the request. The client never sees, and cannot alter, what the session contains.
Redis is used by 30.7% of professional developers according to the Stack Overflow 2025 Developer Survey, and session storage is a large part of why.
How Access Tokens Work
An access token carries its own claims. The dominant format is the JSON Web Token, or JWT, which is three base64-encoded parts joined by dots: a header, a payload of claims, and a signature.
The payload holds the user ID, an expiry timestamp, the issuer, and whatever else you put there. The signature is what makes it trustworthy: it's computed with a secret or a private key that only your server holds, so any change to the payload invalidates it.
Verification is local. Your server checks the signature and the expiry, and if both pass, it trusts the claims. There is no database lookup, which is the entire point.
One thing worth being blunt about, because it causes real breaches: a JWT payload is encoded, not encrypted. Anyone holding the token can read every claim inside it. Never put anything in there you wouldn't be willing to print in a log file.
The Trade-Off Everyone Misses
Most comparisons stop at "sessions need a lookup, tokens don't" and treat that as a straight win for tokens. It isn't, and the reason is revocation.
With sessions, logging a user out is one operation: delete the record. The next request fails immediately. Suspending an account, forcing a password reset across every device, or cutting off a compromised session all work the same way, and they take effect on the next request.
With access tokens there is nothing to delete. The token is valid because it's correctly signed and hasn't expired, and your server has no memory of having issued it. If a token is stolen, it works until it expires. If you fire an employee, their token keeps working until it expires.
The standard mitigation is a short expiry, typically 5 to 15 minutes, paired with a longer-lived refresh token that can be revoked. That works, and it's the right pattern, but look at what it costs you. The refresh token has to be stored and checked server side, which means you now have exactly the stateful lookup you adopted access tokens to avoid, plus a second credential to protect and a rotation flow to get right.
So the honest framing is not stateless versus stateful. It's this: access tokens let you choose how stale your authorisation decisions may be. A 15-minute expiry means up to 15 minutes of a revoked user still getting through. Whether that's acceptable is a product decision, not an engineering one, and it should be made deliberately rather than inherited from a tutorial.
A Session Token in Practice
Here's the shape of a session check in Express, using Redis as the store:
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
async function requireSession(req, res, next) {
const token = req.cookies.sid;
if (!token) return res.status(401).json({ error: "not authenticated" });
const raw = await redis.get(`session:${token}`);
if (!raw) return res.status(401).json({ error: "session expired" });
req.user = JSON.parse(raw);
await redis.expire(`session:${token}`, 60 * 60 * 24);
next();
}
The expire call is the sliding-window renewal: an active user stays logged in, an idle one is dropped after a day. Revoking access is redis.del on that key, and it takes effect on the very next request.
An Access Token in Practice
The equivalent verification in FastAPI:
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer = HTTPBearer()
SECRET = "load-this-from-the-environment"
def current_user(creds: HTTPAuthorizationCredentials = Depends(bearer)):
try:
claims = jwt.decode(creds.credentials, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "token expired")
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
return {"id": claims["sub"], "roles": claims.get("roles", [])}
Note the algorithms argument. Pinning it is not optional. Leaving the algorithm to be read from the token's own header is how the alg: none attack works, and it's one of the oldest JWT vulnerabilities there is. Your library should reject it by default, and you should pin the algorithm anyway.
If you're building this out in FastAPI, we covered the surrounding pieces, including basic auth and API keys, in our guide to FastAPI authentication.
Where Each One Fits
| Requirement | Session tokens | Access tokens |
|---|---|---|
| Instant revocation | Yes | No, only at expiry |
| Lookup on every request | Yes | No |
| Works across separate services | Needs a shared store | Yes, verify locally |
| Claims readable by the client | No | Yes |
| Size on the wire | Tens of bytes | Hundreds of bytes |
| Roles change mid-session | Applies immediately | Applies at next refresh |
Choose session tokens for a single application with a browser front end, for anything handling money or health data where an administrator must be able to cut someone off now, and any time you already run Redis. A well-tuned Redis lookup costs well under a millisecond, which is nothing next to your database queries.
Choose access tokens when several independent services need to verify the same credential without sharing a session store, when you're issuing credentials to third-party clients or mobile apps, or when the verifier genuinely cannot reach your store, such as at an edge function.
Choose both when you're honest about the trade-off. Short-lived access tokens for the request path, a revocable refresh token in the store, and a denylist for the handful of tokens you need to kill immediately. That's what most production systems converge on.
Getting the Storage Right
Whichever you pick, where the credential lives on the client matters as much as its format.
Storing a token in localStorage means any script running on your page can read it, so a single cross-site scripting flaw is a full account takeover. This is the most common mistake in JWT tutorials.
The safer default is a cookie with HttpOnly, so scripts cannot read it, Secure, so it only travels over HTTPS, and SameSite=Lax or Strict, which handles most cross-site request forgery. Set an explicit Max-Age rather than relying on session cookies.
Note that this works for both formats. "JWTs go in localStorage" is a habit, not a requirement. You can put an access token in an HttpOnly cookie and get the isolation benefits without giving up stateless verification.
One more thing that applies to both: rotate the credential on privilege change. When a user logs in, changes their password, or gains an admin role, issue a new token and invalidate the old one. Session fixation attacks depend on the identifier surviving a privilege change.
Frequently Asked Questions
Is a JWT an Access Token or a Session Token?
A JWT is a format, not a role. It's usually used as an access token, because self-contained claims are the reason to reach for it, but you can also use a JWT as a session identifier by storing a matching record server side and looking it up on every request. Doing that gives you revocation back at the cost of the stateless property.
How Long Should an Access Token Live?
Between 5 and 15 minutes for most APIs, with a refresh token lasting days or weeks. The access token's lifetime is your maximum revocation delay, so pick it by asking how long you can tolerate a compromised credential still working.
Are Sessions Bad for Scaling?
No. This is the most overstated claim in the whole comparison. It was a real problem when sessions lived in a single server's memory, because that forced sticky routing. With a shared Redis instance every server can serve every user, and the lookup is sub-millisecond. Scaling is rarely the reason to abandon sessions.
What Is a Refresh Token?
A long-lived credential whose only job is to obtain new access tokens. It's stored server side so it can be revoked, and it should be rotated on every use: issuing a new refresh token each time and invalidating the old one means a stolen token is detectable, because two clients will eventually present the same one.
Can I Migrate from Sessions to Access Tokens Later?
Yes, and running both at once during the transition is normal. Accept either credential in your authentication middleware, issue only the new format for fresh logins, and retire the old path once existing sessions have aged out.
Summary
Session tokens are identifiers your server looks up. Access tokens carry their claims and are verified locally. The lookup is not the deciding factor, because a Redis read is fast and a refresh token reintroduces the lookup anyway.
The deciding factor is how quickly you need to be able to revoke access. If the answer is "immediately", use sessions, or accept that you're building a denylist. If the answer is "within a few minutes is fine" and several services need to verify independently, access tokens earn their place.
Either way, put the credential in an HttpOnly cookie, pin your signing algorithm, and rotate on privilege change. Those three habits prevent more real incidents than the choice between the two formats does.
Auth is also one of the topics that comes up in almost every backend interview, usually as "walk me through what happens after a user logs in". It sits alongside the other recurring themes in our set of backend interview questions, and you can practise answering it under time pressure at interviews.masteringbackend.com. If you'd rather build the thing than describe it, there are project briefs that ask you to implement a full authentication flow.
For how authentication fits into the rest of the stack, from databases and caching through to deployment, our guide to backend engineering maps the whole discipline.



