In this article, you’ll learn how to implement JWT authentication with access and refresh tokens using Node.js, ExpressJs, Prisma, PostgreSQL, Redis, and Docker-compose.

CRUD API with Node.js and PostgreSQL Series:

  1. API Node.js, TypeScript, Prisma, PostgreSQL: Project Setup
  2. Node.js + Prisma + PostgreSQL: Access & Refresh Tokens
  3. CRUD API with Node.js and PostgreSQL: Send HTML Emails
  4. API with Node.js, Prisma & PostgreSQL: Forget/Reset Password
Node.js + Prisma + PostgreSQL Access & Refresh Tokens

Introduction

In this course, you’ll learn how to build a JSON Web Token authentication and authorization API server with Node.js, ExpressJs, Typescript, Prisma, PostgreSQL, Docker-compose, and Redis.

Technologies required to build the API server:

  • Node.js – a Javascript run-time language built on Google’s V8 engine
  • Prisma – an ORM(Object Relational Mapping) that supports some of the popular databases (PostgreSQL, MySQL, SQLite, etc). Also, at the time of writing this article, it only supports Javascript and Typescript.
  • PostgreSQL – an  object-relational database system
  • Bcryptjs – a hashing package
  • JsonWebToken – for generating and verifying JSON Web Tokens
  • Redis – an in-memory data structure store used as a database
  • Zod – for validating user inputs

What the course will cover

  • How to use Typescript in ExpressJs
  • How to model data with Prisma
  • How to validate the request body against a defined schema
  • How to authenticate users with refresh and access tokens

Prerequisites

Software

  • Node.js – for writing the backend API
  • Docker – To handle containers
  • pgAdmin(optional) – A GUI for querying, mutating, analyzing, and aggregating PostgreSQL data.

VS Code Extensions

  • Prisma – Adds syntax highlighting, auto-completion, formatting, and linting for .prisma files.
  • DotENV – Adds syntax highlighting to.env file
  • Thunder Client – For making HTTP requests to the server. You can also use Postman or REST Client
  • MySQL – for Managing some of the popular databases directly in VS Code.

Assumed Knowledge

The course assumes:

  • You have some knowledge of Node.js and ExpressJs.
  • You have basic Javascript and Typescript knowledge.
  • You have some knowledge of SQL and PostgreSQL.
  • You have some knowledge of Docker and Prisma.

JWT Authentication Example with Node.js, Prisma, and PostgreSQL

With this JWT authentication and authorization API, the user will be able to do the following:

  • Register a new account
  • Login with the registered credentials
  • Refresh the access token when expired
  • Retrieve his profile information only if logged in.
RESOURCEHTTP METHODROUTEDESCRIPTION
usersGET/api/users/meRetrieve user’s information
authPOST/api/auth/registerRegister new user
authPOST/api/auth/loginLogin registered user
authGET/api/auth/refreshRefresh the expired access token
authGET/api/auth/logoutLogout the user

User Login and Register Flow with JWT Authentication

The diagram below illustrates the user registration flow in the Node.js app.

User registration flow with email verification

The user will send a POST request with an email and a password to /api/auth/login. The server then checks if the user exists in the database before validating the password.

Assuming there wasn’t any error the server then generates the access and refresh tokens before sending them as HTTPOnly cookies.

Jwt Authentication flow with React and backend api
Refresh Access Token Flow JWT Authentication

Below is a summary of how the access token will be refreshed:

  • First, the browser sends the cookies along with any request to the server
  • The server then checks if the access token was included in the request before validating it. An error is sent if the token has expired or was manipulated.
  • The frontend application receives the unauthorized error and uses interceptors to refresh the access token.
  • In brief, the frontend app will make a GET request to /api/auth/refresh to get a new access token cookie before re-trying the previous request.

Create the User Model with Prisma

prisma/schema.prisma


model User{
  @@map(name: "users")

  id String  @id @default(uuid())
  name String  @db.VarChar(255)
  email String @unique
  photo String? @default("default.png")
  verified Boolean? @default(false) 
  
  password String
  role RoleEnumType? @default(user)

  verificationCode String? @db.Text @unique

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([email, verificationCode])
  @@index([email, verificationCode])
}

enum RoleEnumType {
  user
  admin
}

In the snippets above, I defined the User model and the @@map(name: "users") informs Prisma to use users as the table name.

Each field in the schema has a name followed by a type and an optional field attribute(s).

The @id attribute on the id field marks it as a primary key of the users table. Also, the @default(uuid()) attribute sets a default UUID (Universally Unique Identifier) on the id field.

By default, all fields are required so adding a question mark ? after the field type specifies that it’s optional.

The RoleEnumType denotes whether the user is an admin or not.

The @@unique([email, verificationCode]) specifies that the email and verificationCode fields should have unique constraints.

Finally, the @@index([email, verificationCode]) defines indexes on both the email and verificationCode fields in the database.

Create Zod Schemas to Validate Request Body

Now we need to validate the request body. To do so, we’ll use a popular validation package named Zod to define the schemas.

We could have used Yup which is also another popular validation library used by many developers but I chose Zod because I mostly use it in most of my projects.

Run the following command to install Zod


yarn add zod

src/schemas/user.schema.ts


import { object, string, TypeOf, z } from 'zod';

enum RoleEnumType {
  ADMIN = 'admin',
  USER = 'user',
}

export const createUserSchema = object({
  body: object({
    name: string({
      required_error: 'Name is required',
    }),
    email: string({
      required_error: 'Email address is required',
    }).email('Invalid email address'),
    password: string({
      required_error: 'Password is required',
    })
      .min(8, 'Password must be more than 8 characters')
      .max(32, 'Password must be less than 32 characters'),
    passwordConfirm: string({
      required_error: 'Please confirm your password',
    }),
    role: z.optional(z.nativeEnum(RoleEnumType)),
  }).refine((data) => data.password === data.passwordConfirm, {
    path: ['passwordConfirm'],
    message: 'Passwords do not match',
  }),
});

export const loginUserSchema = object({
  body: object({
    email: string({
      required_error: 'Email address is required',
    }).email('Invalid email address'),
    password: string({
      required_error: 'Password is required',
    }).min(8, 'Invalid email or password'),
  }),
});

export type CreateUserInput = Omit<
  TypeOf<typeof createUserSchema>['body'],
  'passwordConfirm'
>;

export type LoginUserInput = TypeOf<typeof loginUserSchema>['body'];


I exported the inferred Typescript types of the schemas with the TypeOf<> type that comes with Zod.

Create Middleware to Parse Zod Schema

Creating the schemas alone is not enough. We need to define a middleware that will parse and validate the request body against the defined schemas.

The middleware returns an error if any of the schema rules were not satisfied.

src/middleware/validate.ts


import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

export const validate =
  (schema: AnyZodObject) =>
  (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        params: req.params,
        query: req.query,
        body: req.body,
      });

      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          status: 'fail',
          errors: error.errors,
        });
      }
      next(error);
    }
  };


Password Management with Bcryptjs

Another important aspect of authentication is to hash the user’s password before saving it to the database.

To secure the passwords, we’ll transform them into hashed strings with a hashing algorithm. Once the plain password has been hashed it cannot be easily reversed to obtain the original password.

Hashing the same password twice gives the same output so we use salt to ensure that no two users having the same password end up with the same hashed password.


yarn add bcryptjs && yarn add -D @types/bcryptjs

To hash a password with BcryptJs, we use salt rounds or Cost Factor. The Cost Factor is the amount of time needed to calculate a single bcryptjs hash.

The higher the Cost Factor the more difficult it is to brute force. Over the years computers are becoming more powerful so using a cost factor of 12 should be good.

Below is how we hash and validate passwords with Bcryptjs


// Hash password
await bcrypt.hash(req.body.password, 12);

// Verify password
await bcrypt.compare(password, user.password)

Create Services to Interact with the Database

Next, let’s create some services to query and mutate the database.


yarn add jsonwebtoken && yarn add -D @types/jsonwebtoken

src/services/user.service.ts


import { PrismaClient, Prisma, User } from '@prisma/client';
import config from 'config';
import redisClient from '../utils/connectRedis';
import { signJwt } from '../utils/jwt';

export const excludedFields = ['password', 'verified', 'verificationCode'];

const prisma = new PrismaClient();

export const createUser = async (input: Prisma.UserCreateInput) => {
  return (await prisma.user.create({
    data: input,
  })) as User;
};

export const findUniqueUser = async (
  where: Prisma.UserWhereUniqueInput,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.findUnique({
    where,
    select,
  })) as User;
};
// (...)

Authentication with JSON Web Tokens (JWT)

There are different strategies used to implement authentication and authorization but each strategy has its security flaw.

Authenticating a user can be complex and can easily get out of hand.

Create Functions to Sign and Verify Access and Refresh Tokens

A JSON Web Token is simply a sequence of characters comprising a header, payload, and signature.

JWT shines when it comes to stateless authentication but that alone isn’t enough so we’ll combine it with Redis to add an extra layer of security.

You can read more about the flaws of JSON Web Tokens in this article.

To make it difficult for hackers to brute force our application, we’ll store both refresh and access tokens in HTTPOnly cookies.

Sending the tokens in HTTPOnly cookies will prevent hackers from using Javascript to access and manipulate them.

To read more about JWT Authentication with React, check out React + Redux Toolkit: JWT Authentication and Authorization

To read more about Refresh Tokens with React, check out React.js + Redux Toolkit: Refresh Tokens Authentication

Sign Access and Refresh Tokens

Next, let’s create two utility functions to generate and verify the access and refresh tokens.

src/utils/jwt.ts


import jwt, { SignOptions } from 'jsonwebtoken';
import config from 'config';

// ? Sign Access or Refresh Token
export const signJwt = (
  payload: Object,
  keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
  options: SignOptions
) => {
  const privateKey = Buffer.from(
    config.get<string>(keyName),
    'base64'
  ).toString('ascii');
  return jwt.sign(payload, privateKey, {
    ...(options && options),
    algorithm: 'RS256',
  });
};

// ? Verify Access or Refresh Token

Here is a brief explanation of what I did above:

  • First, I created a signJwt function that accepts three parameters (payload, keyName, and token options). The keyName is either access or refresh token private key.
  • I then used the Buffer module from Node.js to decode the encoded private keys to an ASCII string.
  • Finally, I signed the token with an RS256 algorithm and returned it.

Verify Access or Refresh Tokens

The verifyJwt() function is similar to the signJwt() function with some minor changes.

src/utils/jwt.ts


// ? Verify Access or Refresh Token
export const verifyJwt = <T>(
  token: string,
  keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
): T | null => {
  try {
    const publicKey = Buffer.from(
      config.get<string>(keyName),
      'base64'
    ).toString('ascii');
    const decoded = jwt.verify(token, publicKey) as T;

    return decoded;
  } catch (error) {
    return null;
  }
};

Create Authentication Controllers

The authentication controllers will allow us to register a new user, log in the registered user, refresh an access token, send a verification email, log out the user, and many more.

Define Access and Refresh Token Cookie Options

To send the access and refresh tokens in HTTPOnly cookies, let’s customize the cookies options with the required settings.

Setting secure: true in development mode will prevent the server from sending the cookies to the browser or client.

The fields required in the cookies options depend on the project you are working on but feel free to play around with some of the options.

src/controllers/auth.controller.ts


const cookiesOptions: CookieOptions = {
  httpOnly: true,
  sameSite: 'lax',
};

if (process.env.NODE_ENV === 'production') cookiesOptions.secure = true;

const accessTokenCookieOptions: CookieOptions = {
  ...cookiesOptions,
  expires: new Date(
    Date.now() + config.get<number>('accessTokenExpiresIn') * 60 * 1000
  ),
  maxAge: config.get<number>('accessTokenExpiresIn') * 60 * 1000,
};

const refreshTokenCookieOptions: CookieOptions = {
  ...cookiesOptions,
  expires: new Date(
    Date.now() + config.get<number>('refreshTokenExpiresIn') * 60 * 1000
  ),
  maxAge: config.get<number>('refreshTokenExpiresIn') * 60 * 1000,
};

I made the access token expire after every 15 minutes whilst the refresh token expires in 60 minutes.

Register User Controller

Now it’s time to write the logic to register a new user.

src/controllers/auth.controller.ts


import crypto from 'crypto';
import { CookieOptions, NextFunction, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { LoginUserInput, RegisterUserInput } from '../schemas/user.schema';
import {
  createUser,
  findUniqueUser,
  signTokens,
} from '../services/user.service';
import { Prisma } from '@prisma/client';
import config from 'config';
import AppError from '../utils/appError';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';

// ? Cookie Options Here

export const registerUserHandler = async (
  req: Request<{}, {}, RegisterUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const hashedPassword = await bcrypt.hash(req.body.password, 12);

    const verifyCode = crypto.randomBytes(32).toString('hex');
    const verificationCode = crypto
      .createHash('sha256')
      .update(verifyCode)
      .digest('hex');

    const user = await createUser({
      name: req.body.name,
      email: req.body.email.toLowerCase(),
      password: hashedPassword,
      verificationCode,
    });

    res.status(201).json({
      status: 'success',
      data: {
        user,
      },
    });
  } catch (err: any) {
    if (err instanceof Prisma.PrismaClientKnownRequestError) {
      if (err.code === 'P2002') {
        return res.status(409).json({
          status: 'fail',
          message: 'Email already exist, please use another email address',
        });
      }
    }
    next(err);
  }
};

Things to note about the above code snippets:

  • The Request interface of ExpressJs is generic so I provided it with the inferred type of the registerUserSchema to assist Typescript to give us better IntelliSense.
  • Next, I called the createUser service to add the new user to the database with the credentials provided.

    Also, you need to convert the email to lowercase since PostgreSQL is not case-sensitive. When a user registers with either Johndoe@gmail.com and johndoe@gmail.com , PostgreSQL will consider them to be two different users which violate the unique constraints we used on the email field.
  • When a user already exists in the database, Prisma returns an error that has a code of 23505 which we need to catch with a try(..)catch(..) statement.

Login User Controller

Next, let’s create a controller to log in the registered user by sending back the refresh and access tokens as cookies.

src/controllers/auth.controller.ts


// ? Cookie Options Here

// ? Register User Controller

export const loginUserHandler = async (
  req: Request<{}, {}, LoginUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const { email, password } = req.body;

    const user = await findUniqueUser(
      { email: email.toLowerCase() },
      { id: true, email: true, verified: true, password: true }
    );

    if (!user || !(await bcrypt.compare(password, user.password))) {
      return next(new AppError(400, 'Invalid email or password'));
    }

    // Sign Tokens
    const { access_token, refresh_token } = await signTokens(user);
    res.cookie('access_token', access_token, accessTokenCookieOptions);
    res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
    res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    res.status(200).json({
      status: 'success',
      access_token,
    });
  } catch (err: any) {
    next(err);
  }
};

Here are some of the notable things you should be aware of in the above snippets:

  • I called the findUserByEmail service with the user’s email to check if that user exists in the database before validating the password.
  • I then called the signTokens service with the user document to generate both the access and refresh tokens.
  • Next, I sent the tokens as HTTPOnly cookies. I also included a logged_in cookie that is not HTTPOnly to make the frontend aware if the user is logged in.
  • Another crucial thing to note is we send a generic message Invalid email or password if the user doesn’t exist or the password is invalid.

Refresh Access Token Controller

The access token can be refreshed after every 15 minutes when expired as long as the refresh token is valid.

Also, the access token can be refreshed only if the user has a valid session in the Redis database.

src/controllers/auth.controller.ts


// ? Cookie Options Here

// ? Register User Controller

// ? Login User Controller

export const refreshAccessTokenHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const refresh_token = req.cookies.refresh_token;

    const message = 'Could not refresh access token';

    if (!refresh_token) {
      return next(new AppError(403, message));
    }

    // Validate refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    if (!decoded) {
      return next(new AppError(403, message));
    }

    // Check if user has a valid session
    const session = await redisClient.get(decoded.sub);

    if (!session) {
      return next(new AppError(403, message));
    }

    // Check if user still exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      return next(new AppError(403, message));
    }

    // Sign new access token
    const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
      expiresIn: `${config.get<number>('accessTokenExpiresIn')}m`,
    });

    // 4. Add Cookies
    res.cookie('access_token', access_token, accessTokenCookieOptions);
    res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // 5. Send response
    res.status(200).json({
      status: 'success',
      access_token,
    });
  } catch (err: any) {
    next(err);
  }
};

Logout User Controller

JSON Web Tokens are stateless and there is no straightforward way to invalidate a token except when it expires.

To clear the cookies from the user’s browser when they log out, we’ll send expired cookies with the same key names which will immediately remove them from the browser.

Also, remember to delete the user’s session from the Redis database when they log out.

src/controllers/auth.controller.ts


// ? Cookie Options Here

// ? Register User Controller

// ? Login User Controller

// ? Refresh Access Token

function logout(res: Response) {
  res.cookie('access_token', '', { maxAge: -1 });
  res.cookie('refresh_token', '', { maxAge: -1 });
  res.cookie('logged_in', '', { maxAge: -1 });
}

export const logoutUserHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    await redisClient.del(res.locals.user.id);
    logout(res);

    res.status(200).json({
      status: 'success',
    });
  } catch (err: any) {
    next(err);
  }
};

Create User Controller

To test the authentication and authorization flow, let’s define a getMeHandler to return the currently logged-in user’s information.

The getMeHandler controller will just read the user object we stored in res.locals.user and send it back as a response.

The purpose of the getMeHandler controller is to return the user we stored in res.locals.user .

src/controllers/user.controller.ts


import { NextFunction, Request, Response } from 'express';

export const getMeHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = res.locals.user;

    res.status(200).status(200).json({
      status: 'success',
      data: {
        user,
      },
    });
  } catch (err: any) {
    next(err);
  }
};

Create Authentication Middleware

To parse the cookies in the request, we need the cookie-parser package. The Cookie-parser middleware will parse the cookies and attach them to req.cookies .


yarn add cookie-parser && yarn add -D @types/cookie-parser

Deserialize User Middleware

Now, let’s create a middleware to serve as an authentication guard for all the protected routes.

src/middleware/deserializeUser.ts


import { NextFunction, Request, Response } from 'express';
import { omit } from 'lodash';
import { excludedFields, findUniqueUser } from '../services/user.service';
import AppError from '../utils/appError';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';

export const deserializeUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    let access_token;

    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith('Bearer')
    ) {
      access_token = req.headers.authorization.split(' ')[1];
    } else if (req.cookies.access_token) {
      access_token = req.cookies.access_token;
    }

    if (!access_token) {
      return next(new AppError(401, 'You are not logged in'));
    }

    // Validate the access token
    const decoded = verifyJwt<{ sub: string }>(
      access_token,
      'accessTokenPublicKey'
    );

    if (!decoded) {
      return next(new AppError(401, `Invalid token or user doesn't exist`));
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded.sub);

    if (!session) {
      return next(new AppError(401, `Invalid token or session has expired`));
    }

    // Check if the user still exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      return next(new AppError(401, `Invalid token or session has expired`));
    }

    // Add user to res.locals
    res.locals.user = omit(user, excludedFields);

    next();
  } catch (err: any) {
    next(err);
  }
};

Here is a breakdown of what happened in the above snippets:

  • I first checked if the access token exists in the Authorization header or req.cookies object.
  • Next, I validated the access token and retrieved the payload we stored in it.
  • Then I checked if the user still exists in the database and has a valid session in the Redis database.
  • Finally, I attached the user document to res.locals.user to make it available in the other controllers.

Require Authenticated User

Next, let’s create a middleware to return an error message if the res.locals.user object doesn’t exist.

src/middleware/requireUser.ts


import { NextFunction, Request, Response } from 'express';
import AppError from '../utils/appError';

export const requireUser = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = res.locals.user;

    if (!user) {
      return next(
        new AppError(401, `Session has expired or user doesn't exist`)
      );
    }

    next();
  } catch (err: any) {
    next(err);
  }
};

Create Authentication Routes

Now we are ready to define the routes for the authentication controllers.

The validate() middleware will be included in the middleware stack to validate the req.body against the defined schemas before passing the request to our controllers.

src/routes/auth.routes.ts


import express from 'express';
import {
  loginUserHandler,
  logoutUserHandler,
  refreshAccessTokenHandler,
  registerUserHandler,
} from '../controllers/auth.controller';
import { deserializeUser } from '../middleware/deserializeUser';
import { requireUser } from '../middleware/requireUser';
import { validate } from '../middleware/validate';
import { loginUserSchema, registerUserSchema } from '../schemas/user.schema';

const router = express.Router();

router.post('/register', validate(registerUserSchema), registerUserHandler);

router.post('/login', validate(loginUserSchema), loginUserHandler);

router.get('/refresh', refreshAccessTokenHandler);

router.get('/logout', deserializeUser, requireUser, logoutUserHandler);

export default router;


Create User Routes

Next, let’s create a /api/users/me route to get the currently logged-in user’s profile.

src/routes/user.routes.ts


import express from 'express';
import { getMeHandler } from '../controllers/user.controller';
import { deserializeUser } from '../middleware/deserializeUser';
import { requireUser } from '../middleware/requireUser';

const router = express.Router();

router.use(deserializeUser, requireUser);

router.get('/me', getMeHandler);

export default router;


Update App.ts

We need to install the cors package to enable us to run both the frontend and backend on different domains.

Also, installed the morgan package to log the different requests in the terminal.


yarn add cors && yarn add -D morgan @types/morgan @types/cors

src/app.ts


require('dotenv').config();
import express, { NextFunction, Request, Response, response } from 'express';
import config from 'config';
import cors from 'cors';
import morgan from 'morgan';
import cookieParser from 'cookie-parser';
import validateEnv from './utils/validateEnv';
import { PrismaClient } from '@prisma/client';
import authRouter from './routes/auth.routes';
import userRouter from './routes/user.routes';
import AppError from './utils/appError';

validateEnv();

const prisma = new PrismaClient();
const app = express();

async function bootstrap() {
  // TEMPLATE ENGINE
  app.set('view engine', 'pug');
  app.set('views', `${__dirname}/views`);

  // MIDDLEWARE

  // 1.Body Parser
  app.use(express.json({ limit: '10kb' }));

  // 2. Cookie Parser
  app.use(cookieParser());

  // 2. Cors
  app.use(
    cors({
      origin: [config.get<string>('origin')],
      credentials: true,
    })
  );

  // 3. Logger
  if (process.env.NODE_ENV === 'development') app.use(morgan('dev'));

  // ROUTES
  app.use('/api/auth', authRouter);
  app.use('/api/users', userRouter);

  // Testing
  app.get('/api/healthchecker', (_, res: Response) => {
    res.status(200).json({
      status: 'success',
      message: 'Welcome to NodeJs with Prisma and PostgreSQL',
    });
  });

  // UNHANDLED ROUTES
  app.all('*', (req: Request, res: Response, next: NextFunction) => {
    next(new AppError(404, `Route ${req.originalUrl} not found`));
  });

  // GLOBAL ERROR HANDLER
  app.use((err: AppError, req: Request, res: Response, next: NextFunction) => {
    err.status = err.status || 'error';
    err.statusCode = err.statusCode || 500;

    res.status(err.statusCode).json({
      status: err.status,
      message: err.message,
    });
  });

  const port = config.get<number>('port');
  app.listen(port, () => {
    console.log(`Server on port: ${port}`);
  });
}

bootstrap()
  .catch((err) => {
    throw err;
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

A couple of things to note in the above snippets:

  • First, I called the cookie-parser middleware to parse the cookie in the request headers.
  • I used the CORS middleware and provided it with the domain the frontend will be running on. Also, make sure you set credentials: true to allow CORS to accept cookies from the cross-origin request.

Database Migration with Prisma

Run the following command to create a new migration.

Make sure the PostgreSQL docker container is running for this to work.


npx prisma migrate dev --name user-entity --create-only

The --name flag specifies the migration name and the --create-only tells Prisma to create the migration without applying it.

Run this command to install the Prisma Client.


yarn add @prisma/client

Run this command to create the users table in the database.


npx prisma db push

Testing the JSON Web Token Authentication REST API

Run this command to start the PostgreSQL and Redis Docker containers:


docker-compose up -d

You should see the following logs:

$ docker-compose up -d
Creating network "node_prisma_default" with the default driver
Creating postgres ... done
Creating redis    ... done

Run yarn start to start the server

$ yarn start
yarn run v1.22.18
$ ts-node-dev --respawn --transpile-only --exit-child src/app.ts
[INFO] 16:16:27 ts-node-dev ver. 1.1.8 (using ts-node ver. 9.1.1, typescript ver. 4.6.4)
Server on port: 8000
Redis client connect successfully

Register users

Let’s register some users. I decided to use the HTTP client VS Code extension but you can use any API testing tool you are comfortable with like Postman.

To register a user you need to make a POST request to http://localhost:8000/api/auth/register with the required credentials.

jwt authentication register new user

Below is the response you’ll get assuming the registration was successful.

jwt authentication response after registering a user

Open the MySQL VS Code extension then click on the users table and you should see the registered users.

postgresql database

Login user

Now login with the credentials of any of the registered users.

jwt authentication register new user

You should get a JSON response with an access token.

login postgresql prisma response

Next, connect to the Redis Docker container with the MySQL VS Code extension to see the user’s session.

Connect To Redis On Vs Code

Get Currently Logged in User’s Credentials

Copy the access token and add the authorization header with the token attached to the Bearer string value.

jwt authentication get logged in users credentials

You should get the users’ information as a JSON response assuming the access token is valid

postgres prisma get currently login user information

Conclusion

Congrats for reaching the end. In this article, you learned how to implement JSON Web Token authentication with access and refresh tokens using Node.js, Prisma, PostgreSQL, Redis, and Docker-compose.

Check out the source code on Github.