JWT passes have one flaw we have kept quiet about so far. A legionary turns out to be a traitor and you want him thrown out of the camp at once - but his pass is valid for another twenty hours. The server has no way to revoke it, because it never stored it anywhere. Everything it knows about the document is inside the document.
There is an older road to the fort: the guest list at the gate. The sentry does not read passes - he looks into the book and checks whether the visitor is on the list. Striking a name off works instantly. We call this road session-based authentication.
The difference between these two roads comes down to one question: does the server remember anything?
JWT is stateless. All the knowledge about the legionary travels inside the token, the server stores nothing. The advantage is scaling - stand up ten servers and each will read the same token, because the secret key is all it needs to verify. The disadvantage is precisely that traitor: there is nothing to strike off.
A session is stateful. After login the server writes an entry of its own - who this person is - and sends the client only a session identifier in a cookie. The cookie means nothing by itself; it is a cloakroom ticket. On every request the server uses that ticket to find the entry. Logging out deletes the entry - and the visitor ceases to exist immediately.
Keep this pair of terms together with its consequence, because that is what gets asked: stateless = nothing to revoke, but easy scaling; stateful = instant revocation, but the server must remember.
We introduce sessions in a fixed order. We start with the packages:
1npm install express-session
2npm install -D @types/express-sessionThe second package contains nothing but TypeScript types - hence the
-D flag, since running the application in production does not need it.The second step is wiring up the middleware. We do it in
main.ts, because sessions must be ready before any request reaches the controllers:1app.use(
2 session({
3 secret: process.env.SESSION_SECRET,
4 resave: false,
5 saveUninitialized: false,
6 cookie: {
7 httpOnly: true,
8 secure: true,
9 maxAge: 3600000,
10 },
11 }),
12);Let's go through these options, because two of them decide the security of the whole arrangement.
secret signs the cookie so nobody can swap the ticket for someone else's - we read it from an environment variable, just like the JWT key.httpOnly: true is the most important line in this block. It makes the cookie unreadable from JavaScript in the browser. Should someone inject a foreign script into your page, without this flag they would steal the session identifier with a single document.cookie. secure: true adds a second condition: the cookie travels over HTTPS only, so it cannot be intercepted along the way.maxAge is the lifetime in milliseconds - an hour here. resave: false and saveUninitialized: false cut out needless writes: do not rewrite the entry when nothing changed, and do not create a session for a visitor who has not done anything yet.The third step is reaching for the session in a controller:
1@Post('login')
2login(@Body() dto: LoginDto, @Session() session: Record<string, any>) {
3 const user = this.authService.validate(dto);
4 session.userId = user.id;
5 return { message: 'Logged in' };
6}
7
8@Get('profile')
9getProfile(@Session() session: Record<string, any>) {
10 if (!session.userId) {
11 throw new UnauthorizedException();
12 }
13 return this.usersService.findOne(session.userId);
14}The
@Session() decorator injects the session object - an ordinary object into which you write whatever you want remembered. Note what is absent: we return no token. The client receives the cookie automatically, in the response headers, and sends it back just as automatically with every subsequent request. Hence the feeling that it "just works" - and hence the trap, because it also works when the request comes from someone else's page. That threat is called CSRF and needs separate protection, which a JWT in a header does not.By default
express-session keeps entries in the Node process's memory. That is fine on your laptop and fails in production for two reasons: restarting the application logs everyone out, and with two servers a user logged in on the first is unknown to the second.So in production we point at an external store:
1app.use(
2 session({
3 store: new RedisStore({ client: redisClient }),
4 secret: process.env.SESSION_SECRET,
5 resave: false,
6 saveUninitialized: false,
7 }),
8);Redis suits this best because it holds data in memory and removes entries by itself once they expire. All the servers ask the same store, so the cloakroom ticket works no matter which sentry inspects it. And this is the price of statefulness we spoke of: remembering costs infrastructure.
The practical rule is short. JWT for APIs serving mobile apps, microservices and anywhere the client is not a browser - it travels in a header and needs no shared store. Sessions for classic server-rendered web applications, especially when you need to log someone out immediately or to see a list of active sessions.
There is no better and worse solution here - there is a choice between easy scaling and instant control. And that is the question I recommend starting from, @name: do I need to be able to revoke access within a second?
The fort has two gates and you know which to open when:
main.ts, @Session() in the controller, choose a store,httpOnly: true cuts the cookie off from JavaScript, secure: true forces HTTPS - both flags are mandatory,In the next lesson you will face a project: building a complete authentication system for the legion. For now remember: a JWT is a pass you carry with you, a session is a cloakroom ticket - the document speaks for itself, the ticket works only as long as the sentry keeps the entry in his book.