Nest.js Tutorial

Using server-side sessions instead of JSON Web Tokens

Marcin Wanago
JavaScriptNestJS

So far, in this series, we’ve used JSON Web Tokens (JWT) to implement authentication. While this is a fitting choice for many applications, this is not the only choice out there. In this article, we look into server-side sessions and implement them with NestJS.

You can find the code from this article in this repository

The idea behind server-side sessions

At its core, HTTP is stateless, and so are the HTTP requests. Even though that’s the case, we need to implement a mechanism to recognize if a person performing the request is authenticated. So far, we’ve been using JSON Web Tokens for that. We send them to the users when they log in and expect them to send them back when making subsequent requests to our API. This encrypted token contains the user’s id, and thanks to that, we can assume that the request is valid.

If you want to know more about JSON Web Tokens, check outAPI with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies

With the above solution, our application is still stateless. We can’t change the JWT token or make it invalid in a straightforward way. The server-side sessions work differently.

We create a session for the users with server-side sessions when they log in and keep this information in the memory. We send the session’s id to the user and expect them to send it back when making further requests. When that happens, we can compare the received id of the session with the data stored in memory.

The advantages and disadvantages

The above change in approach has a set of consequences. Since we are storing the information about the session server-side, it might become tricky to scale. The more users we have logged in, the more significant strain it puts on our server’s memory. Also, if we have multiple instances of our web server, they don’t share memory. When due to load balancing, the user authenticates through the first instance and then accesses resources through the second instance, the server won’t recognize the user. In this article, we solve this issue with Redis.

Keeping the session in memory has its advantages, too. Since we have easy access to the session data, we can quickly invalidate it. If we know that an attacker stole a particular cookie and can impersonate a user, we can easily remove one session from our memory. Also, if we don’t want the user to log in through multiple devices simultaneously, we can easily prevent that. If a user changes a password, we can also remove the old session from memory. All of the above use-cases are not easily achievable with JWT. We could create a blacklist of tokens to make tokens invalid, but unfortunately, it wouldn’t be straightforward.

Defining the user data

The first thing we do to implement authentication is to register our users. To do that, we need to define an entity for our users.

In this article, we use TypeORM. Another suitable alternative is Prisma. If you want to know more, check out API with NestJS #32. Introduction to Prisma with PostgreSQL
user.entity.ts
1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2import { Exclude } from 'class-transformer';
3 
4@Entity()
5class User {
6  @PrimaryGeneratedColumn()
7  public id?: number;
8 
9  @Column({ unique: true })
10  public email: string;
11 
12  @Column()
13  public name: string;
14 
15  @Column()
16  @Exclude()
17  public password: string;
18}
19 
20export default User;
Above, we use @Exclude() to make sure that we don’t respond with the user’s password. If you want to dive deeper into this, check out API with NestJS #5. Serializing the response with interceptors

We also need to be able to perform a few operations on the collection of users. To that, we create the UsersService.

users.service.ts
1import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5import CreateUserDto from './dto/createUser.dto';
6 
7@Injectable()
8export class UsersService {
9  constructor(
10    @InjectRepository(User)
11    private usersRepository: Repository<User>,
12  ) {}
13 
14  async getByEmail(email: string) {
15    const user = await this.usersRepository.findOne({ email });
16    if (user) {
17      return user;
18    }
19    throw new HttpException('User with this email does not exist', HttpStatus.NOT_FOUND);
20  }
21 
22  async getById(id: number) {
23    const user = await this.usersRepository.findOne({ id });
24    if (user) {
25      return user;
26    }
27    throw new HttpException('User with this id does not exist', HttpStatus.NOT_FOUND);
28  }
29 
30  async create(userData: CreateUserDto) {
31    const newUser = await this.usersRepository.create(userData);
32    await this.usersRepository.save(newUser);
33    return newUser;
34  }
35}
createUser.dto.ts
1export class CreateUserDto {
2  email: string;
3  name: string;
4  password: string;
5}
6 
7export default CreateUserDto;

Managing passwords

The crucial thing about the registration process is that we shouldn’t save the passwords in plain text. If a database breach happened, this would expose the passwords of our users.

To deal with the above issue, we hash the passwords. During this process, the hashing algorithm converts one string into another string. Changing just one character in the passwords completely changes the outcome of hashing.

The above process works only one way and, therefore, can’t be reversed straightforwardly. Thanks to that, we don’t know the exact passwords of our users. When they attempt to log in, we need to perform the hashing operation one more time. By comparing the hash of the provided credentials with the one stored in the database, we can determine if the user provided a valid password.

Using the bcrypt algorithm

One of the most popular hashing algorithms is bcrypt, implemented by the bcrypt npm package. It hashes the strings and compares the plain strings with hashes to validate the credentials.

1npm install @types/bcrypt bcrypt

The bcrypt library is rather straightforward to use. We only need the hash and compare functions.

1const passwordInPlaintext = 'myStrongPassword';
2const hash = await bcrypt.hash(passwordInPlaintext, 10);
3 
4const isPasswordMatching = await bcrypt.compare(passwordInPlaintext, hashedPassword);
5console.log(isPasswordMatching); // true

The authentication service

We now have everything we need to implement the feature of registering users and validating their credentials. Let’s put this logic into the AuthenticationService.

authentication.service.ts
1import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import RegisterDto from './dto/register.dto';
4import * as bcrypt from 'bcrypt';
5import PostgresErrorCode from '../database/postgresErrorCode.enum';
6 
7@Injectable()
8export class AuthenticationService {
9  constructor(
10    private readonly usersService: UsersService
11  ) {}
12 
13  public async register(registrationData: RegisterDto) {
14    const hashedPassword = await bcrypt.hash(registrationData.password, 10);
15    try {
16      return this.usersService.create({
17        ...registrationData,
18        password: hashedPassword
19      });
20    } catch (error) {
21      if (error?.code === PostgresErrorCode.UniqueViolation) {
22        throw new HttpException('User with that email already exists', HttpStatus.BAD_REQUEST);
23      }
24      throw new HttpException('Something went wrong', HttpStatus.INTERNAL_SERVER_ERROR);
25    }
26  }
27 
28  public async getAuthenticatedUser(email: string, plainTextPassword: string) {
29    try {
30      const user = await this.usersService.getByEmail(email);
31      await this.verifyPassword(plainTextPassword, user.password);
32      return user;
33    } catch (error) {
34      throw new HttpException('Wrong credentials provided', HttpStatus.BAD_REQUEST);
35    }
36  }
37 
38  private async verifyPassword(plainTextPassword: string, hashedPassword: string) {
39    const isPasswordMatching = await bcrypt.compare(
40      plainTextPassword,
41      hashedPassword
42    );
43    if (!isPasswordMatching) {
44      throw new HttpException('Wrong credentials provided', HttpStatus.BAD_REQUEST);
45    }
46  }
47}

There are quite a lot of things happening above. We break it down part by part in API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies

Using server-side sessions with NestJS

To implement authentication with server-side sessions, we need a few libraries. The first of them is express-session.

1npm install express-session @types/express-session

In this article, we also use the passport package. It provides an abstraction over the authentication and does quite a bit of the heavy lifting for us.

Different applications need various approaches to authentication. Passport refers to those mechanisms as strategies. The one we need is called passport-local. It allows us to authenticate with a username and a password.

1npm install @nestjs/passport passport @types/passport-local passport-local

When our users authenticate, we respond with a session ID cookie, and for security reasons, we need to encrypt it. To do that, we need a secret key. It is a string used to encrypt and decrypt the session ID.

Changing the secret invalidates all existing sessions.

We should never hardcode the secret key into our codebase. A fitting solution is to add it to environment variables.

app.module.ts
1import { Module } from '@nestjs/common';
2import { ConfigModule } from '@nestjs/config';
3import * as Joi from '@hapi/joi';
4 
5@Module({
6  imports: [
7    ConfigModule.forRoot({
8      validationSchema: Joi.object({
9        POSTGRES_HOST: Joi.string().required(),
10        POSTGRES_PORT: Joi.number().required(),
11        POSTGRES_USER: Joi.string().required(),
12        POSTGRES_PASSWORD: Joi.string().required(),
13        POSTGRES_DB: Joi.string().required(),
14        SESSION_SECRET: Joi.string().required()
15      })
16    })
17    // ...
18  ],
19  controllers: [],
20  providers: [],
21})
22export class AppModule {}
.env
1POSTGRES_HOST=localhost
2POSTGRES_PORT=5432
3POSTGRES_USER=admin
4POSTGRES_PASSWORD=admin
5POSTGRES_DB=nestjs
6 
7SESSION_SECRET=6m1dHJmicq9MfwpD6rra

When we have all of the above set up, we can apply the appropriate middleware to turn on both express-session and passport.

main.ts
1import { NestFactory } from '@nestjs/core';
2import { AppModule } from './app.module';
3import * as session from 'express-session';
4import * as passport from 'passport';
5import { ConfigService } from '@nestjs/config';
6import { ValidationPipe } from '@nestjs/common';
7 
8async function bootstrap() {
9  const app = await NestFactory.create(AppModule);
10 
11  app.useGlobalPipes(new ValidationPipe({
12    transform: true
13  }));
14 
15  const configService = app.get(ConfigService);
16 
17  app.use(
18    session({
19      secret: configService.get('SESSION_SECRET'),
20      resave: false,
21      saveUninitialized: false,
22    }),
23  );
24 
25  app.use(passport.initialize());
26  app.use(passport.session());
27 
28  await app.listen(3000);
29}
30bootstrap();

A significant thing to understand about the above code is that by default, the express-session library stores the session in the memory of our web server. This approach might not scale very well and will not work properly if we have multiple app instances. We will deal with this issue later in this article with Redis.

The official NestJS documentation sets the resave and saveUninitialized flags to false. We can find a very good explanation of this on stackoverflow.

Using passport to log in and authenticate

Since we aim to authenticate our users with a username and a password, we need to use the passport-local strategy. To configure it, we need to extend the PassportStrategy class.

local.strategy.ts
1import { Strategy } from 'passport-local';
2import { PassportStrategy } from '@nestjs/passport';
3import { Injectable } from '@nestjs/common';
4import { AuthenticationService } from './authentication.service';
5import User from '../users/user.entity';
6 
7@Injectable()
8export class LocalStrategy extends PassportStrategy(Strategy) {
9  constructor(private authenticationService: AuthenticationService) {
10    super({
11      usernameField: 'email'
12    });
13  }
14  async validate(email: string, password: string): Promise<User> {
15    return this.authenticationService.getAuthenticatedUser(email, password);
16  }
17}

Passport calls the validate function for every strategy. For the local strategy, Passports requires a username and a password. Our application uses email as the username.

The authentication flow begins when the controller intercepts the request sent by the user.

authentication.controller.ts
1import {
2  Body,
3  Req,
4  Controller,
5  HttpCode,
6  Post,
7  UseGuards,
8  UseInterceptors,
9  ClassSerializerInterceptor,
10  Get
11} from '@nestjs/common';
12import { AuthenticationService } from './authentication.service';
13import RegisterDto from './dto/register.dto';
14import RequestWithUser from './requestWithUser.interface';
15import { CookieAuthenticationGuard } from './cookieAuthentication.guard';
16import { LogInWithCredentialsGuard } from './logInWithCredentials.guard';
17 
18@Controller('authentication')
19@UseInterceptors(ClassSerializerInterceptor)
20export class AuthenticationController {
21  constructor(
22    private readonly authenticationService: AuthenticationService
23  ) {}
24 
25  @Post('register')
26  async register(@Body() registrationData: RegisterDto) {
27    return this.authenticationService.register(registrationData);
28  }
29 
30  @HttpCode(200)
31  @UseGuards(LogInWithCredentialsGuard)
32  @Post('log-in')
33  async logIn(@Req() request: RequestWithUser) {
34    return request.user;
35  }
36 
37  @HttpCode(200)
38  @UseGuards(CookieAuthenticationGuard)
39  @Get()
40  async authenticate(@Req() request: RequestWithUser) {
41    return request.user;
42  }
43}

Quite a few important things are happening there. First, let’s investigate the LogInWithCredentialsGuard.

logInWithCredentialsGuard.ts
1import { ExecutionContext, Injectable } from '@nestjs/common';
2import { AuthGuard } from '@nestjs/passport';
3 
4@Injectable()
5export class LogInWithCredentialsGuard extends AuthGuard('local') {
6  async canActivate(context: ExecutionContext): Promise<boolean> {
7    // check the email and the password
8    await super.canActivate(context);
9    
10    // initialize the session
11    const request = context.switchToHttp().getRequest();
12    await super.logIn(request);
13 
14    // if no exceptions were thrown, allow the access to the route
15    return true;
16  }
17}

Above, we aim to verify the credentials provided by the user. This is something the AuthGuard does out of the box when the canActivate method is called by the user accessing the route.

We also need to call the logIn method to initialize the server-side session. When we look under the hood of NestJS, we can see that this method calls request.logIn. It is a function added to the request object by Passport. It creates the session and saves it in memory. Thanks to that, the Passport middleware can attach the session id cookie to the response.

We need to specify the exact data we want to keep inside the session. To manage it, we need to create a serializer.

local.serializer.ts
1import { UsersService } from '../users/users.service';
2import User from '../users/user.entity';
3import { PassportSerializer } from '@nestjs/passport';
4import { Injectable } from '@nestjs/common';
5 
6@Injectable()
7export class LocalSerializer extends PassportSerializer {
8  constructor(
9    private readonly usersService: UsersService,
10  ) {
11    super();
12  }
13 
14  serializeUser(user: User, done: CallableFunction) {
15    done(null, user.id);
16  }
17 
18  async deserializeUser(userId: string, done: CallableFunction) {
19    const user = await this.usersService.getById(Number(userId))
20    done(null, user);
21  }
22}

The serializeUser function determines the data stored inside of the session. In our case, we only store the id of the user.

The result of the deserializeUser function gets attached to the request object. By calling the usersService.getById function, we get the complete data of the logged-in user, and we can access it through request.user in the controller.

1@HttpCode(200)
2@UseGuards(LogInWithCredentialsGuard)
3@Post('log-in')
4async logIn(@Req() request: RequestWithUser) {
5  return request.user;
6}

Authenticating with the session id cookie

In the above screenshot, we can see that we respond with the connect.sid cookie when the user logs in, set through the Set-Cookie header. We now expect the user to attach this cookie when performing further requests to our API.

An important thing about the above cookie is the HttpOnly flag set to true. Because of that, the browser can’t access it directly through JavaScript. It makes the cookie more secure and resistant to attacks like cross-site scripting.

If you want to know more about cookies, read Cookies: explaining document.cookie and the Set-Cookie header

We now need to create a NestJS guard that verifies the session id cookie. To define a guard, we need to implement the CanActivate interface.

cookieAuthentication.guard.ts
1import { ExecutionContext, Injectable, CanActivate } from '@nestjs/common';
2 
3@Injectable()
4export class CookieAuthenticationGuard implements CanActivate {
5  async canActivate(context: ExecutionContext) {
6    const request = context.switchToHttp().getRequest();
7    return request.isAuthenticated();
8  }
9}

The isAuthenticated function is attached to request object by Passport. Therefore, we don’t need to implement it ourselves. The isAuthenticated returns true only if the user is successfully authenticated.

We now can attach the CookieAuthenticationGuard to a route. By doing so, we specify that a valid session is required to access it.

1@HttpCode(200)
2@UseGuards(CookieAuthenticationGuard)
3@Get()
4async authenticate(@Req() request: RequestWithUser) {
5  return request.user;
6}

Logging the user out

When we’ve implemented authentication with JWT, our way of logging the user out wasn’t perfect. Back then, we’ve just sent a Set-Cookie header that aimed to remove the token from the browser. Unfortunately, this didn’t make the token invalid.

With server-side sessions, logging out is a lot better.

authentication.controller.ts
1import {
2  Req,
3  Controller,
4  HttpCode,
5  UseGuards,
6  UseInterceptors,
7  ClassSerializerInterceptor,
8  Post
9} from '@nestjs/common';
10import RequestWithUser from './requestWithUser.interface';
11import { CookieAuthenticationGuard } from './cookieAuthentication.guard';
12 
13@Controller('authentication')
14@UseInterceptors(ClassSerializerInterceptor)
15export class AuthenticationController {
16  @HttpCode(200)
17  @UseGuards(CookieAuthenticationGuard)
18  @Post('log-out')
19  async logOut(@Req() request: RequestWithUser) {
20    request.logOut();
21    request.session.cookie.maxAge = 0;
22  }
23  
24  // ...
25}

The logOut function is attached to the request object by Passport. Calling it removes the session from the memory of the webserver. Even if someone retrieved the cookie and tried to reuse it, the session is long gone and can’t be accessed. The above provides an additional layer of security compared to JWT.

As an additional step, we can also remove the cookie from the browser of our user. The easiest way to do that is to set request.session.cookie.maxAge to 0.

Improving our sessions with Redis

By default, the express-session library keeps all of the sessions in the memory of the webserver. The more users we’ve got logged in, the more memory our server uses. Restarting the webserver causes all of the sessions to disappear. It might also create issues if we’ve got multiple instances of our app. When the user authenticates the first instance and then accesses the API through the second instance, the server can’t find the session data. We can solve this issue by using Redis instead of storing the sessions directly in the server’s memory.

So far, in this series, we’ve used Docker Compose to set up the architecture for us. This is also a fitting place to set up Redis. By default, it works on port 6379.

docker-compose.yml
1version: "3"
2services:
3  redis:
4    image: "redis:alpine"
5    ports:
6      - "6379:6379"
7# ...

To connect to our Redis instance, we need to add a few environment variables.

app.module.ts
1import { Module } from '@nestjs/common';
2import { ConfigModule } from '@nestjs/config';
3import * as Joi from '@hapi/joi';
4 
5@Module({
6  imports: [
7    ConfigModule.forRoot({
8      validationSchema: Joi.object({
9        REDIS_HOST: Joi.string().required(),
10        REDIS_PORT: Joi.number().required(),
11        // ...
12      })
13    })
14    // ...
15  ],
16  controllers: [],
17  providers: [],
18})
19export class AppModule {}
.env
1REDIS_HOST=localhost
2REDIS_PORT=6379
3# ...

To make the express-session library work with Redies, we need to add a few dependencies.

1npm install redis @types/redis connect-redis @types/connect-redis

The last step is using all of the above in our bootstrap function.

main.ts
1import { NestFactory } from '@nestjs/core';
2import { AppModule } from './app.module';
3import * as session from 'express-session';
4import * as passport from 'passport';
5import { ConfigService } from '@nestjs/config';
6import { ValidationPipe } from '@nestjs/common';
7import { createClient } from 'redis';
8import * as createRedisStore from 'connect-redis';
9 
10async function bootstrap() {
11  const app = await NestFactory.create(AppModule);
12 
13  app.useGlobalPipes(new ValidationPipe({
14    transform: true
15  }));
16 
17  const configService = app.get(ConfigService);
18 
19  const RedisStore = createRedisStore(session)
20  const redisClient = createClient({
21    host: configService.get('REDIS_HOST'),
22    port: configService.get('REDIS_PORT')
23  })
24 
25  app.use(
26    session({
27      store: new RedisStore({ client: redisClient }),
28      secret: configService.get('SESSION_SECRET'),
29      resave: false,
30      saveUninitialized: false,
31    }),
32  );
33 
34  app.use(passport.initialize());
35  app.use(passport.session());
36 
37  await app.listen(3000);
38}
39bootstrap();

Summary

In this article, we’ve gone through the advantages and disadvantages of server-side sessions. We’ve implemented a complete authentication flow using Passport and the express-session library. We’ve also improved it using Redis instead of keeping the sessions directly in the server’s memory. By doing all of the above, we’ve achieved a suitable alternative to JSON Web Tokens.