AmouAI Hub/Courses/Software Engineering for AI Engineers/Chapter 4
State, Sessions and Who You Say You Are
State is what makes a server hard to replace, hard to scale and hard to reason about. Almost nobody decides where it should live; it accumulates wherever it was convenient, and then the architecture is whatever that turned out to imply. This chapter is about making it a decision.
By the end of this chapter you can
- Place a piece of state deliberately in the client, the server, a cache or a store, and say why
- Explain what “stateless service” actually means, and what it costs
- Choose between a session cookie and a self-contained token on the revocation question
- Describe the four moments in a session’s life, and what must happen at each
- Separate authentication from authorisation in the code, not only in the vocabulary
- Write an authorisation check that a scanner cannot verify and a reviewer can
1Four places state can live
Every piece of state in an application sits in one of four places, and each choice buys something specific at a specific price. Most systems have all four, which is fine. What is not fine is having them by accident.
| Where | Good for | The price | Failure mode |
|---|---|---|---|
| The client form state, UI preferences, a cart before checkout | Anything the server does not need to know and the user does not need on another device | It is gone when the tab closes, and the user can edit it | Trusting it. Anything that decides a price or a permission cannot live here. |
| The server process an in-memory session map | Nothing, in a system with more than one instance | The instance can no longer be replaced without logging people out | Sticky sessions, then an autoscaler that cannot scale down, then a deploy that logs everyone out. |
| A shared cache Redis-backed sessions | Session data: small, hot, and cheap to lose in the worst case | A new stateful component to run, and a new single point of failure | Treating it as durable. A cache eviction is a mass logout, and that has to be acceptable. |
| A durable store the database | Anything a user would be upset to lose | Slowest, and the hardest to change later | Putting session churn here and discovering you have a write-heavy table nobody planned for. |
The question is never “where is it easiest to put this”. It is what happens when this location disappears. If losing it means a user has to log in again, a cache is fine. If it means an order vanishes, it belongs in a durable store, and no amount of convenience changes that.
The second row is the one that quietly ruins architectures. An in-memory session map is the easiest thing in the world to write and it makes every instance non-interchangeable, which means every deploy is disruptive, every scale-down logs someone out, and the load balancer needs sticky sessions — which in turn defeats the autoscaler, because a busy instance keeps being sent more work by the users already pinned to it.
2“Stateless” is a relocation, not a deletion
A stateless service does not have less state. It has the same state somewhere it can be shared, which is what makes the instances interchangeable.
That interchangeability is the whole prize. When any instance can serve any request, you can add instances under load, remove them when it passes, replace one that is misbehaving, and deploy without draining connections carefully. Every one of those becomes hard the moment an instance holds something only it knows.
There is a cost, and it is worth saying plainly. Moving session state into a shared store adds a dependency to the read path of every authenticated request, so that store is now on the critical path for the whole product. You have traded a per-instance failure — one server restarting logs out the users pinned to it — for a global one: the store going down logs out everybody at once. That is usually the better trade, because the global failure is rarer, recoverable and visible, whereas the per-instance one happens on every routine deploy. But it is a trade, and a design review should name it rather than assume the shared store is simply better.
The same reasoning applies one level down. A cache holding sessions should be treated as a cache, which means an eviction is a mass logout and that outcome has to be acceptable rather than merely unlikely. If it is not acceptable — if logging out every user is a support incident you cannot absorb — then sessions are not cache data and belong somewhere durable, and the fact that a cache was faster is not an argument, it is a preference.
Notice what the marketing pages do. They do not depend on sessions at all, so they survive, and that is not luck — it is what you get when the dependency is drawn deliberately. A surprising number of products put their entire public site behind a session lookup because the middleware was applied globally, and then a cache outage takes down the page that says the product exists.
3Cookies and tokens, and the question that actually separates them
The debate is usually conducted about scalability. That is the wrong axis. The axis that matters is revocation, and it has an honest answer on both sides.
A session cookie carries an opaque identifier. The server looks it up on every request, which costs one lookup and buys instant revocation: delete the row and the session is over, everywhere, now.
A self-contained token such as a JWT carries signed claims. The server verifies the signature and needs no lookup at all — which is the entire point, and the entire problem. There is nothing to delete. A token issued to someone who has just been dismissed remains valid until it expires, and the only ways out are a short expiry (so you are doing lookups again, just at the refresh endpoint) or a revocation list (so you are doing lookups again, exactly as before).
| Opaque session cookie | Self-contained token | |
|---|---|---|
| Per-request cost | One lookup in a shared store | A signature verification, no I/O |
| Revocation | Immediate — delete the record | Not possible before expiry, without adding a lookup |
| Survives a store outage | No — everyone is signed out | Yes, which is genuinely valuable |
| Carries claims | No, you look them up — so they are always current | Yes, frozen at issue time, so a role change does not take effect |
| Size on the wire | Tens of bytes | Hundreds to thousands, on every request |
| Cross-domain use | Awkward; cookies are scoped to a domain | Straightforward, which is why APIs use them |
| Main risk | The store becomes a critical dependency | A leaked token is valid until it expires, and you cannot stop it |
Read the fourth row again, because it is the one that surprises people. A token carries the role it was issued with. Promote a user to administrator and nothing changes until their token is reissued; demote one and they keep the higher privilege for the rest of the token’s life. Session cookies do not have this problem at all, because the role is looked up on each request and is therefore always the current one.
None of which makes tokens a bad choice. They are the right answer for a public API where the caller is a machine, for cross-domain access, and anywhere a per-request lookup is genuinely too expensive. They are usually the wrong answer for a first-party web application, where you control both ends, the lookup is cheap, and an administrator will eventually need to remove somebody at short notice.
Choosing JWTs because they are stateless, then adding a revocation list because someone asks the obvious question about dismissals. You now do a lookup on every request — exactly like a session cookie — while still carrying token expiry, signature verification and key rotation. The symptom is an authentication system with two mechanisms and the benefits of neither. If you need instant revocation, a session cookie is the simpler answer and always was.
4The four moments in a session’s life
Creation, rotation, expiry, revocation. Most implementations get two of them right, and the one most often missing is rotation.
Creation. The identifier must be unguessable — generated by a cryptographic random source, not a counter, a timestamp or a hash of the username. It must be long enough that guessing is hopeless: 128 bits of entropy is the usual floor.
Rotation. Whenever the privilege attached to a session changes, the identifier must change too. Signing in is the important case. If the application hands out a session identifier to an anonymous visitor and then simply attaches an account to that same identifier at login, anyone who knew the pre-login value now holds an authenticated session. Issuing a fresh identifier at the moment of sign-in closes this completely, and it is one line.
Expiry should be absolute as well as idle-based. An idle timeout alone means a session that is touched by a background poll every four minutes never expires at all, which is a real pattern in single-page applications and a real way that a thirty-minute timeout becomes permanent.
Revocation needs to be a thing an administrator can actually do, from an interface that exists, without an engineer. “We would delete the row” is not a revocation capability if nobody outside the engineering team can trigger it at eleven at night.
5Two different questions
Authentication asks who you are. Authorisation asks what you may do. They are answered by different code, they fail differently, and only one of them is reliably found by an automated scanner.
Authentication is largely a solved problem you should not be solving: use a well-tested library or an identity provider, and the interesting decisions are about session lifetime and recovery flows. Authorisation is not solved, because it is entirely specific to your domain. Nobody else knows that in your product an accountant may view any invoice in their practice but may only edit ones they raised.
There is a second reason to prefer the scoped lookup, and it is about what the code makes possible rather than what it currently does. In the first version there is a moment when a row belonging to another organisation exists in a local variable. Nothing is wrong yet. Then somebody adds a cache above the lookup, or a debug log that prints the loaded object, or an error handler that includes it in a report — and each of those is a reasonable change made by someone who did not read the check four lines below. In the second version that moment never exists, so none of those changes can leak anything.
The other thing to notice is the status code. Returning 403 when a record exists but is not yours confirms that it exists, which turns the endpoint into an enumerator: an attacker can distinguish real invoice ids from imaginary ones by the response code alone. Returning 404 for both cases gives away nothing. This is a small thing that costs nothing to get right and is nearly impossible to retrofit once clients start branching on 403.
That class of bug — a valid user reaching another user’s object by changing an identifier — is consistently among the most common serious web vulnerabilities, and it is the one automated tooling is worst at. A scanner can tell that an endpoint requires a login. It cannot tell that this invoice belongs to that organisation, because that fact only exists in your domain model. Chapter 19 returns to this with threat modelling; here the point is narrower and structural.
6An authorisation model you can actually check
The goal is a model small enough to hold in your head, because a model nobody understands is a model nobody can review, and authorisation bugs are found in review or not at all.
Most products need three concepts and no more:
- A scope — the tenant, organisation or workspace the object belongs to. Almost every authorisation question starts here, and getting it into the query removes an entire bug class.
- A role — what this person may do within that scope. Keep the list short; five roles that people understand beat twenty nobody can explain.
- An ownership rule — the handful of actions restricted to the person who created the object. This is where the domain-specific complexity actually lives.
// One function, one place, no exceptions elsewhere.
const ALLOWED = {
owner: ['read', 'write', 'delete', 'invite'],
member: ['read', 'write'],
viewer: ['read']
};
function may(user, resource, action) {
// 1. scope first — nothing else matters if this fails
if (user.orgId !== resource.orgId) return false;
// 2. role
const allowed = ALLOWED[user.role] || [];
if (!allowed.includes(action)) return false;
// 3. the domain rule: only the raiser may delete an invoice
if (action === 'delete' && resource.createdBy !== user.id) {
return user.role === 'owner';
}
return true;
}
Three properties make this reviewable. It is one function, so there is a single place to read and a single place to test. It fails closed — an unknown role gets an empty list rather than a permissive default. And the ordering is deliberate: scope is checked first, so no amount of role confusion can leak across tenants.
Take something you have built and answer three questions in writing. Where does session state live, and what happens to users if that place restarts? Does the session identifier change at sign-in? And if someone had to remove a user’s access in the next sixty seconds, what exactly would they do — in an interface, not in a database console. The third question is the one that usually has no answer.
✓Checkpoint
Five questions. Commit before you read the explanation.
▶Playground
A harder brief than Ledger’s: a system where revocation is not a convenience but a regulatory requirement, and where the obvious modern answer is the wrong one.
✓Exercise set
Twelve problems. The JavaScript ones build the authorisation logic this chapter argues for — scoped lookups, session rotation, bounded revocation — and the design ones make you place state under constraints that conflict. Your work is saved in this browser.
Chapter 5 — Caching and Work You Refuse to Do Twice
A cache is a second copy of the truth that is allowed to be wrong for a while. Everything difficult about caching follows from that sentence.