15 min read

Using server-side sessions instead of JSON Web Tokens

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…

June 7, 2021

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.

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.

user.entity.ts#
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
import { Exclude } from "class-transformer"
@Entity()
class User {
  @PrimaryGeneratedColumn()
  public id?: number
  @Column({ unique: true })
  public email: string
  @Column()
  public name: string
  @Column()
  @Exclude()
  public password: string
}
export default User

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#
import { HttpException, HttpStatus, Injectable } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import User from "./user.entity"
import CreateUserDto from "./dto/createUser.dto"
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}
  async getByEmail(email: string) {
    const user = await this.usersRepository.findOne({ email })
    if (user) {
      return user
    }
    throw new HttpException("User with this email does not exist", HttpStatus.NOT_FOUND)
  }
  async getById(id: number) {
    const user = await this.usersRepository.findOne({ id })
    if (user) {
      return user
    }
    throw new HttpException("User with this id does not exist", HttpStatus.NOT_FOUND)
  }
  async create(userData: CreateUserDto) {
    const newUser = await this.usersRepository.create(userData)
    await this.usersRepository.save(newUser)
    return newUser
  }
}
createUser.dto.ts#
export class CreateUserDto {
  email: string
  name: string
  password: string
}
 
export 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.

npm install @types/bcrypt bcrypt

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

const passwordInPlaintext = 'myStrongPassword';
const hash = await bcrypt.hash(passwordInPlaintext, 10);
 
const isPasswordMatching = await bcrypt.compare(passwordInPlaintext, hashedPassword);
console.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#
import { HttpException, HttpStatus, Injectable } from "@nestjs/common"
import { UsersService } from "../users/users.service"
import RegisterDto from "./dto/register.dto"
import * as bcrypt from "bcrypt"
import PostgresErrorCode from "../database/postgresErrorCode.enum"
@Injectable()
export class AuthenticationService {
  constructor(private readonly usersService: UsersService) {}
  public async register(registrationData: RegisterDto) {
    const hashedPassword = await bcrypt.hash(registrationData.password, 10)
    try {
      return this.usersService.create({
        ...registrationData,
        password: hashedPassword,
      })
    } catch (error) {
      if (error?.code === PostgresErrorCode.UniqueViolation) {
        throw new HttpException("User with that email already exists", HttpStatus.BAD_REQUEST)
      }
      throw new HttpException("Something went wrong", HttpStatus.INTERNAL_SERVER_ERROR)
    }
  }
  public async getAuthenticatedUser(email: string, plainTextPassword: string) {
    try {
      const user = await this.usersService.getByEmail(email)
      await this.verifyPassword(plainTextPassword, user.password)
      return user
    } catch (error) {
      throw new HttpException("Wrong credentials provided", HttpStatus.BAD_REQUEST)
    }
  }
  private async verifyPassword(plainTextPassword: string, hashedPassword: string) {
    const isPasswordMatching = await bcrypt.compare(plainTextPassword, hashedPassword)
    if (!isPasswordMatching) {
      throw new HttpException("Wrong credentials provided", HttpStatus.BAD_REQUEST)
    }
  }
}

There are quite a lot of things happening above.

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.

npm 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.

npm 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#
import { Module } from "@nestjs/common"
import { ConfigModule } from "@nestjs/config"
import * as Joi from "@hapi/joi"
@Module({
  imports: [
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        POSTGRES_HOST: Joi.string().required(),
        POSTGRES_PORT: Joi.number().required(),
        POSTGRES_USER: Joi.string().required(),
        POSTGRES_PASSWORD: Joi.string().required(),
        POSTGRES_DB: Joi.string().required(),
        SESSION_SECRET: Joi.string().required(),
      }),
    }), // ...
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}
.env#
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=admin
POSTGRES_PASSWORD=admin
POSTGRES_DB=nestjs
 
SESSION_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#
import { ValidationPipe } from "@nestjs/common"
import { ConfigService } from "@nestjs/config"
import { NestFactory } from "@nestjs/core"
import * as session from "express-session"
import * as passport from "passport"
 
import { AppModule } from "./app.module"
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
    }),
  )
  const configService = app.get(ConfigService)
  app.use(
    session({
      secret: configService.get("SESSION_SECRET"),
      resave: false,
      saveUninitialized: false,
    }),
  )
  app.use(passport.initialize())
  app.use(passport.session())
  await app.listen(3000)
}
bootstrap()

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#
import { Strategy } from "passport-local"
import { PassportStrategy } from "@nestjs/passport"
import { Injectable } from "@nestjs/common"
import { AuthenticationService } from "./authentication.service"
import User from "../users/user.entity"
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authenticationService: AuthenticationService) {
    super({
      usernameField: "email",
    })
  }
  async validate(email: string, password: string): Promise<User> {
    return this.authenticationService.getAuthenticatedUser(email, password)
  }
}

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#
import {
  Body,
  Req,
  Controller,
  HttpCode,
  Post,
  UseGuards,
  UseInterceptors,
  ClassSerializerInterceptor,
  Get,
} from "@nestjs/common"
import { AuthenticationService } from "./authentication.service"
import RegisterDto from "./dto/register.dto"
import RequestWithUser from "./requestWithUser.interface"
import { CookieAuthenticationGuard } from "./cookieAuthentication.guard"
import { LogInWithCredentialsGuard } from "./logInWithCredentials.guard"
@Controller("authentication")
@UseInterceptors(ClassSerializerInterceptor)
export class AuthenticationController {
  constructor(private readonly authenticationService: AuthenticationService) {}
  @Post("register")
  async register(@Body() registrationData: RegisterDto) {
    return this.authenticationService.register(registrationData)
  }
  @HttpCode(200)
  @UseGuards(LogInWithCredentialsGuard)
  @Post("log-in")
  async logIn(@Req() request: RequestWithUser) {
    return request.user
  }
  @HttpCode(200)
  @UseGuards(CookieAuthenticationGuard)
  @Get()
  async authenticate(@Req() request: RequestWithUser) {
    return request.user
  }
}

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

logInWithCredentialsGuard.ts#
import { ExecutionContext, Injectable } from "@nestjs/common"
import { AuthGuard } from "@nestjs/passport"
@Injectable()
export class LogInWithCredentialsGuard extends AuthGuard("local") {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // check the email and the password
    await super.canActivate(context) // initialize the session
    const request = context.switchToHttp().getRequest()
    await super.logIn(request) // if no exceptions were thrown, allow the access to the route
    return true
  }
}

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#
import { UsersService } from "../users/users.service"
import User from "../users/user.entity"
import { PassportSerializer } from "@nestjs/passport"
import { Injectable } from "@nestjs/common"
@Injectable()
export class LocalSerializer extends PassportSerializer {
  constructor(private readonly usersService: UsersService) {
    super()
  }
  serializeUser(user: User, done: CallableFunction) {
    done(null, user.id)
  }
  async deserializeUser(userId: string, done: CallableFunction) {
    const user = await this.usersService.getById(Number(userId))
    done(null, user)
  }
}

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.

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

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.

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#
import { ExecutionContext, Injectable, CanActivate } from "@nestjs/common"
@Injectable()
export class CookieAuthenticationGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest()
    return request.isAuthenticated()
  }
}

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.

@HttpCode(200)
@UseGuards(CookieAuthenticationGuard)
@Get()
async authenticate(@Req() request: RequestWithUser) {
  return request.user;
}

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#
import {
  Req,
  Controller,
  HttpCode,
  UseGuards,
  UseInterceptors,
  ClassSerializerInterceptor,
  Post,
} from "@nestjs/common"
import RequestWithUser from "./requestWithUser.interface"
import { CookieAuthenticationGuard } from "./cookieAuthentication.guard"
@Controller("authentication")
@UseInterceptors(ClassSerializerInterceptor)
export class AuthenticationController {
  @HttpCode(200)
  @UseGuards(CookieAuthenticationGuard)
  @Post("log-out")
  async logOut(@Req() request: RequestWithUser) {
    request.logOut()
    request.session.cookie.maxAge = 0
  } // ...
}

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#
version: "3"
services:
  redis:
    image: "redis:alpine"
    ports:
      - "6379:6379"
# ...

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

app.module.ts#
import { Module } from "@nestjs/common"
import { ConfigModule } from "@nestjs/config"
import * as Joi from "@hapi/joi"
@Module({
  imports: [
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        REDIS_HOST: Joi.string().required(),
        REDIS_PORT: Joi.number().required(), // ...
      }),
    }), // ...
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}
.env#
REDIS_HOST=localhost
REDIS_PORT=6379
# ...

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

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

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

main.ts#
import { ValidationPipe } from "@nestjs/common"
import { ConfigService } from "@nestjs/config"
import { NestFactory } from "@nestjs/core"
import * as createRedisStore from "connect-redis"
import * as session from "express-session"
import * as passport from "passport"
import { createClient } from "redis"
 
import { AppModule } from "./app.module"
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
    }),
  )
  const configService = app.get(ConfigService)
  const RedisStore = createRedisStore(session)
  const redisClient = createClient({
    host: configService.get("REDIS_HOST"),
    port: configService.get("REDIS_PORT"),
  })
  app.use(
    session({
      store: new RedisStore({ client: redisClient }),
      secret: configService.get("SESSION_SECRET"),
      resave: false,
      saveUninitialized: false,
    }),
  )
  app.use(passport.initialize())
  app.use(passport.session())
  await app.listen(3000)
}
bootstrap()

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.

Using server-side sessions instead of JSON Web Tokens | NestJS.io