How to protect route in ExpressJS using Passport

Table of contents
- Initial Info
- Writing the code
- Initial Setup
- Testing Express Endpoint
- Loading environment variable
- Instantiating Prisma
- Adding Json Middleware
- Importing Express Async Handler
- Adding Bcrypt
- Making User Registration
- Writing Json Web Token Helper
- Preparing To Use JWT
- Adding Cookie Parser
- Writing Login Code
- Writing the authentication code
- Testing the authentication code
- Summary and other stuff

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
open an empty folder
initialize your packages there
npm init -y
create a
.gitignore
file and write this lines into the filenode_modules .env
create a
.env.example
file and write this lines into the fileJWT_SECRET=
copy
.env.example
file and rename it into.env
edit
.env
file to this (I'll explain this later)JWT_SECRET=ffooaapp**113
install dependency, run this in your terminal
npm i express dotenv passport passport-jwt jsonwebtoken express-async-handler bcrypt cookie-parser
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
generate typescript config file, run this in your terminal
npx tsc --init
in
tsconfig.json
, edit therootDir
and theoutDir
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" ///
initialize prisma for data storage, I'll be using sqlite (to make it simpler)
npx prisma init --datasource-provider sqlite
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
save the
schema.prisma
file and run this on the terminalnpx prisma migrate dev --name init
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
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
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 typesecret
will be the content ofJWT_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
Adding Cookie Parser
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 hourthen 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:
try to decrypt token with
jwtSecret
check if the token is expired
if
the decryption fail (the user either tamper/fake the token, or the user don't have the token)
or the token is expired
then the token is invalid
- 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
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
