How to protect route in ExpressJS using Passport

Making certain route to be accessible only to authenticated user is one thing I almost always do in express. However, I usually cobble them together from multiple blog, stack overflow, and and Gen AI. And I forget about it and had to do it all over again (or copy it from my old code and modifying it to suit my need). I make this blog to put the bare minimum code needed for an authentication to work so explaining it will be easier along with using/modifying it to suite my need. The full code is in this repo.

Initial Info

here is some information regarding system I use and package I use, because Javascript ecosystem move so fast (both in feature and security vulnerability), I add this just in case it will get outdated and insecure 5 years later

  • Date of writing the code: Jan 29, 2025 to Jan 29, 2025

  • Operating system: windows 10

  • Node version: 22.13.1

  • Packages:

    • dependency

      • express 4

      • dotenv

      • passport

      • passport-jwt

      • jsonwebtoken

      • express-async-handler

      • bcrypt

      • cookie-parser

      • @prisma/client

    • dev dependency

      • typescript

      • nodemon

      • prisma

      • ts-node

  • The exact version of the package is in the repo package.json

Writing the code

Initial Setup

Let’s Install some stuff along with creating files for the code, If you are used with setting up express + database + env, you can skip this part up until Adding Json Middleware

  1. open an empty folder

  2. initialize your packages there

     npm init -y
    
  3. create a .gitignore file and write this lines into the file

     node_modules
     .env
    
  4. create a .env.example file and write this lines into the file

     JWT_SECRET=
    
  5. copy .env.example file and rename it into .env

  6. edit .env file to this (I'll explain this later)

     JWT_SECRET=ffooaapp**113
    
  7. install dependency, run this in your terminal

     npm i express dotenv passport passport-jwt jsonwebtoken express-async-handler bcrypt cookie-parser
    
  8. install dev dependency, run this in your terminal

     npm i -D @types/bcrypt @types/cookie-parser @types/express @types/jsonwebtoken @types/node @types/passport @types/passport-jwt nodemon prisma ts-node typescript
    
  9. generate typescript config file, run this in your terminal

     npx tsc --init
    
  10. in tsconfig.json, edit the rootDir and the outDir property. Now, this is optional, you don't really have to do it, I just like it, make it easier to manage my source code and also I know where the output code is. If you don't know what you're doing, following me now shouldn't hurt

    ///
    "rootDir": "./src",
    "outDir": "./dist"
    ///
    
  11. initialize prisma for data storage, I'll be using sqlite (to make it simpler)

    npx prisma init --datasource-provider sqlite
    
  12. add this schema in prisma file, which is located at ./prisma/schema.prisma

    model User {
      id Int @id @default(autoincrement())
      email String @unique
      hashedPassword String
    }
    

    this schema is used for storing user data

  13. save the schema.prisma file and run this on the terminal

    npx prisma migrate dev --name init
    
  14. edit the package.json file, add some script alias there

    ///
      "scripts": {
        "dev": "nodemon ./src/main.ts",
        "build": "tsc",
        "start": "node ./dist/main.js"
      },
    ///
    

Testing Express Endpoint

Let’s write express app with hello world endpoint to test the setup. Create main.ts file in src folder and write this:

import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Then, run npm run dev in the terminal to test the endpoint, I’m using curl for this in windows cmd, so the command might differ if you use bash or powershell

running curl on localhost:3000

Loading environment variable

In the .env file, we previously write JWT_SECRET equal to something (refer to step 6 in Initial Setup). This will be used to encrypt the token, so the actual content of the token is not readable by other. It also our way to verify that this token is the token that the server issued (if the secret is different than the process to decrypt it will fail). Update the code to load the content of .env

+ import "dotenv/config";
import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Instantiating Prisma

Now, we need a user in our database that we will verify

import "dotenv/config";
import express from "express";
+ import { PrismaClient } from "@prisma/client";

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

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Adding Json Middleware

we need a json middleware so that if the user send a json request, we can conveniently get the data in req.body. modify the code to look like this

import "dotenv/config";
import express from "express";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();
const app = express();
+ app.use(express.json());

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Importing Express Async Handler

Let's add express async handler. This is a library that help us write express handler as an async function and make error handling easier

import "dotenv/config";
import express from "express";
import { PrismaClient } from "@prisma/client";
+ import asy from "express-async-handler";

const prisma = new PrismaClient();
const app = express();
app.use(express.json());

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Adding Bcrypt

Let's add bcrypt library for password hashing. It’s not a good idea to store plain text password, and this library is easy to use, with bcrypt is “good enough” for password hashing. You can and should use a different password hashing algorithm that suit your need in your own project, but doing that now is beyond the scope of this tutorial

import "dotenv/config";
import express from "express";
import { PrismaClient } from "@prisma/client";
import asy from "express-async-handler";
+ import bcrypt from "bcrypt";

const prisma = new PrismaClient();
const app = express();
app.use(express.json());

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Making User Registration

The database didn’t have a user yet, so let’s add endpoint to register user

// ...

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

+ app.post(
+   "/register",
+   asy(async (req, res) => {
+     try {
+       const hashedPassword = await bcrypt.hash(req.body.password, 10);
+ 
+       const user = await prisma.user.create({
+         data: {
+           email: req.body.email,
+           hashedPassword: hashedPassword,
+         },
+       });
+
+       res.status(201);
+       res.json({ message: "ok" });
+     } catch (error) {
+       console.error(error);
+       res.json({ message: "some error occured, please wait" });
+     }
+   })
+ );

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

Observe how app.get("/") and app.post("/register") have two different second argument. In the /register endpoint, the second argument is an async function that is wrapped with express-async-handler helper. This is so that using async function and try catch that don't feel so awkward (if express-async-handler is not used, then the error need to be passed with a next() call back everytime).

Next, grab the plain text password from the request body. (earlier,express.json() were added as a middleware, This will be used for parsing JSON request that will contain the username and plain text password).

Then, hash the password using bcrypt. After that, use prisma to create new row in database. If everything went well, The message ok should be returned as a response, If not, an error message will be returned

registering user using curl, success

registering user using curl, failed

Writing Json Web Token Helper

At the part of installing dependency, there’s a package called jsonwebtoken that is included (refer to step 7 at Initial Setup). This package is used to create a token to indicate that the user is authenticated (think of it like an ID card for user). Let’s add a helper function that wrap function provided by jsonwebtoken so it can be used in async function

Create a file called jwt.ts inside src

import jsonwebtoken from "jsonwebtoken";

type Payload = {
  sub: number;
};

export function jwtSignAsync(
  payload: Payload,
  secret: jsonwebtoken.Secret,
  options: jsonwebtoken.SignOptions
): Promise<string> {
  return new Promise((resolve, reject) => {
    jsonwebtoken.sign(payload, secret, options, function (err, token) {
      if (err === null && token !== undefined) {
        resolve(token);
      } else {
        reject(err);
      }
    });
  });
}

In this function, there's a type definition Payload as an object with property sub. That will be the content of the token. sub is the user id. The exported jwtSignAsync function is a wrapper for the sign function from jsonwebtoken, sign use a callback style of doing things. This function will make it so that it can be awaited in async function

jwtSignAsync take a payload, secret, and options as an argument

  • the payload content will be a type of Payload type

  • secret will be the content of JWT_SECRET

  • options will be a argument to fine tune the token behavior, like when it's expire

Preparing To Use JWT

return to main.ts to import jwt helper

import "dotenv/config";
import express from "express";
import { PrismaClient } from "@prisma/client";
import asy from "express-async-handler";
import bcrypt from "bcrypt";
+ import { jwtSignAsync } from "./jwt";

const prisma = new PrismaClient();
const app = express();
app.use(express.json());
+ const jwtSecret = process.env.JWT_SECRET as string;
+ if (jwtSecret === null || jwtSecret === undefined) {
+   throw new Error(
+     "jwt secret not found, please fill JWT_SECRET variable in .env or make sure dotenv is loaded correctly"
+   );
+ }

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

//...

also, put JWT_SECRET into a variable and check it’s existence

Let’s make it so that cookie can be used in the web app, this cookie will be one of the place the token can be transmitted between user and web server

import "dotenv/config";
import express from "express";
import { PrismaClient } from "@prisma/client";
import asy from "express-async-handler";
import bcrypt from "bcrypt";
import { jwtSignAsync } from "./jwt";
+ import cookieParser from "cookie-parser"; 

const prisma = new PrismaClient();
const app = express();
+ app.use(cookieParser());
app.use(express.json());

//...

a library called cookie-parser will be used as a middleware to handle cookie. Make sure that it is before app.use(express.json())

Writing Login Code

let's now write a code that logged in user

// ...

app.post(
  "/register",
  asy(async (req, res) => {
    try {
      const hashedPassword = await bcrypt.hash(req.body.password, 10);

      const user = await prisma.user.create({
        data: {
          email: req.body.email,
          hashedPassword: hashedPassword,
        },
      });

      res.status(201);
      res.json({ message: "ok" });
    } catch (error) {
      console.error(error);
      res.json({ message: "some error occured, please wait" });
    }
  })
);

+ app.post(
+   "/login",
+   asy(async (req, res) => {
+     try {
+       const user = await prisma.user.findFirstOrThrow({
+         select: {
+           id: true,
+           hashedPassword: true,
+         },
+         where: {
+           email: req.body.email,
+         },
+       });
+
+      const validPassword = await bcrypt.compare(
+         req.body.password,
+         user.hashedPassword
+       );
+
+       if (!validPassword) {
+         throw new Error("invalid credentials");
+       }
+
+       const token = await jwtSignAsync({ sub: user.id }, jwtSecret, {
+         algorithm: "HS384",
+         expiresIn: "1h",
+       });
+
+       res.cookie("token", token, {
+         maxAge: 3600000,
+       });
+       res.json({ token });
+     } catch (error) {
+       console.error(error);
+       res.json({ message: "error" });
+     }
+   })
+ );

app.listen(3000, () => {
  console.log(`listening at http://localhost:3000`);
});

there’s a few things happening at the /login endpoint

  • try to find the user id and the password based on email, if the user not found, throw an error and return error message as a response

  • if the user exist, get the hashed password from database and validate it with user password from request using bcrypt

  • then, use jwtSignAsync function to create a token, this token will have user id as a payload and be valid for 1 hour

  • then return the token as a response and put it in cookie with max age of 1 hour

Writing the authentication code

now is the time to write code that will authenticate the user

Adding passport

import "dotenv/config";
import express from "express";
import { PrismaClient } from "@prisma/client";
import asy from "express-async-handler";
import bcrypt from "bcrypt";
import { jwtSignAsync } from "./jwt";
import cookieParser from "cookie-parser";
+ import passport from "passport";
+ import { ExtractJwt, Strategy as JwtStrategy } from "passport-jwt";

const prisma = new PrismaClient();
const app = express();
app.use(cookieParser());
app.use(express.json());
+ app.use(passport.initialize());
const jwtSecret = process.env.JWT_SECRET as string;
if (jwtSecret === null || jwtSecret === undefined) {
  throw new Error(
    "jwt secret not found, please fill JWT_SECRET variable in .env or make sure dotenv is loaded correctly"
  );
}

here, passport, Strategy, and ExtractJWT is imported. Strategy is where the code that authenticate the user live, with ExtractJWT is a helper class to get the jwt from request. Also, Strategy is aliased as JwtStrategy for clarity. Then add passport as express middleware with app.use(passport.initialize());. ExtractJWT is an object that contain useful helper method relating to getting json web token from request

Write code that authenticate user request

Let’s implement the authentication part

//...
if (jwtSecret === null || jwtSecret === undefined) {
  throw new Error(
    "jwt secret not found, please fill JWT_SECRET variable in .env or make sure dotenv is loaded correctly"
  );
}

+ const cookieExtractor = function (req: Request) {
+   if (req && req.cookies) {
+     return req.cookies["token"];
+   } else {
+     return null;
+   }
+ };
+
+ passport.use(
+   new JwtStrategy(
+     {
+       jwtFromRequest: ExtractJwt.fromExtractors([
+         cookieExtractor,
+         ExtractJwt.fromAuthHeaderAsBearerToken,
+       ]),
+       secretOrKey: jwtSecret,
+     },
+     function (payload: { sub: number }, done) {
+       done(null, { id: payload.sub });
+     }
+   )
+ );

app.get("/", (req, res) => {
  res.json({ message: "hello" });
});

//...

Let’s break it down. The logic of authentication is inside JwtStrategy, this class take 2 argument, options object and verify function. The options object consist of 2 required property, jwtFromRequest and secretOrKey.

jwtFromRequest will accept function that will do the extracting of json web token. There are 2 place in this codebase where the token can exist, in cookie and in authorization header. Authorization header looked like this Authorization: Bearer <json web token>. In cookie, the token will looked like this ;token=<token>; (refer to Writing Login Code section), To get both of them, ExtractJWT have a helper that can get the token from authorization header and chain multiple extractor. However, there’s no cookie extractor in there, so a custom cookie extractor function is defined there too

secretOrKey is where we put the JWT_SECRET that’s written in .env.

As for the verify function, it is defined as function that take 2 argument, a payload and done callback. The payload will contain data that we put in the token (in our case is the userId). Then, we call the done callback, the callback actually take 3 argument, err, user, info.

err is where pass along anything that might cause error, since our authentication rely on checking the existence and validity of our token, we can safely assume if the token exist, no error occurred, so it's null for no error. user is where we put data that will be attached in express req.user, so in route like:

app.get((req, res) => {
  if (req.user.id) {
    console.log(req.user.id) // this will exist
  } 
})

info is additional data you can pass to req, you can put almost anything to it and access in route via req.authInfo. We won't use it today, maybe sometimes later I can show how to use it to make custom error return.

and then passport.use() will register the strategy so it can be used in route

Ok, so here’s the thing, It’s just definition, there’s no like actual code seen where the authentication is done. Well, it happen internally in passport, but the logic go like this:

  1. try to decrypt token with jwtSecret

  2. check if the token is expired

  3. if

    1. the decryption fail (the user either tamper/fake the token, or the user don't have the token)

    2. or the token is expired

  4. then the token is invalid

    1. the protected route will refuse to give resource to the user

so the "authentication process" is just checking the existence and the validity of the token

Testing the authentication code

now, let's test if the code actually work. Add this protected route for testing

//...

+ app.get(
+   "/protected",
+   passport.authenticate("jwt", { session: false }),
+   asy(async (req, res) => {
+     res.json({ message: `hello user with id: ${(req.user as any).id}` });
+   })
+ );

app.listen(process.env.APP_PORT, () => {
  console.log(`listening at http://localhost:${process.env.APP_PORT}`);
});

the line passport.authenticate is a middleware where the checking is happening, fromStrategy previously defined. it take 2 argument:

  • the first is the name of strategy, since we use jwt strategy, then the name is "jwt", this is by default

  • the second one is a config object. this config object mean that we won't be using express-session get authentication user data from db, as this is a stateless route

let's test it, we will use a correct user example:

and here's if the token is invalid:

Summary and other stuff

From this tutorial, we learn how to

  • set up express

  • write an authentication guard with passport

  • test them

This is the basic of it. The full code in the repo is not meant to be use as it, but modified to suit your need. Here are other thing that can be done

  • writing custom error handling

  • writing multiple jwt strategy

  • role based access control

Maybe later on I’ll write how to do it. But that’s it for now. Hope it’s help

0
Subscribe to my newsletter

Read articles from Alexander Septian Arfeprakosa directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alexander Septian Arfeprakosa
Alexander Septian Arfeprakosa