— Node.js, JavaScript, Express, Passport.js, JWT, intermediate, tutorial, intro-to-node-js — 8 min read
Welcome to the fifth part of the six-part Introduction to Node.js tutorial series. By the end of this tutorial, you will be able to:
Express Recipes
APIusers
Serviceusers
Controllerusers
Routerrecipes
ResourceFrom Passport.js:
"Passport is authentication middleware for Node. It is designed to serve a singular purpose: authenticate requests. When writing modules, encapsulation is a virtue, so Passport delegates all other functionality to the application. This separation of concerns keeps code clean and maintainable, and makes Passport extremely easy to integrate into an application.
In modern web applications, authentication can take a variety of forms. Traditionally, users log in by providing a username and password. With the rise of social networking, single sign-on using an OAuth provider such as Facebook or Twitter has become a popular authentication method. Services that expose an API often require token-based credentials to protect access."
Passport.js offers a wide variety of authentication mechanisms (known as "strategies") as individually-packaged modules. Currently, more than 500 authentication strategies exist in the Passport.js ecosystem. Strategies range from verifying a username and password, delegated authentication using OAuth or federated authentication using OpenID.
In this section, you'll be using the Passport.js authentication strategy based on JSON Web Tokens to add authentication to your Express Recipes
API.
You can work off of the repo that you used in the previous tutorial. Or you can fork and clone this repo.
A Git branch with the complete code for this tutorial is available here.
In REST architectures, client-server interactions are typically stateless. A stateless server does not store any history or state about the client session. After all, server-based sessions are often costly to implement and don't scale well.
Instead, session state is often stored on the client (e.g., in a browser's cookie), so the client is responsible for transferring all the information needed to execute a request to the server.
JWT (JSON Web Tokens) is a lightweight and secure approach to transfer state from the client to the server in a REST framework. JWT (pronounced "JOT") is an open-standard authentication strategy that relies on the exchange of encoded and crytopgraphically signed JSON strings between client and server.
JWTs allow you to delegate authentication logic to an authorization server. In fact, you can delegate login/signup of a cluster of applications to a single Authorization server.
To understand how JWT works, let's consider a typical login flow that uses JWT-based authentication:
The client makes a HTTP request to the authorization server, sending along the user's login credentials (e.g., email and password).
The authorization server validates the user's credentials. If authentication is successful, the server sends a JSON response to the client that includes a JWT access token.
The client receives the JWT and stores it in the browser (e.g., cookie).
The next time the client makes a HTTP request to a route that requires authentication, it will first attach the JWT to the request's Authorization headers before sending the request to the application server that's hosting the protected API.
On receiving the request, the application server extracts the JWT from the request and verifies the embedded signature. If the verification is successful, the server fulfills the request. Otherwise, the request is blocked and the user will receive an error denying their request to access the resource on the application server.
As you can see, the application server does not have to keep access tokens in-memory in between requests, so it can be stateless
.
JWTs function like temporary user credentials so the user does not have to repeatedly provide their password to gain access to resources on a server.
Later on in the tutorial, you'll take a look at what a JWT access token actually looks like.
Let's authenticate our users using JWT before allowing them access to privileged routes in the Express Recipes
API! To support user authentication, you will need to implement routes to handle user signup and login.
As part of the signup process, the user will typically provide a password that the user can use to log into the application going forward. As a best practice, the user's password will need to be encrypted before it is stored in the database. You can use the bcrypt
library to do that.
Then, during the login process, the server will need to validate the user's credentials and generate as well as validate JWTs accordingly. You can use the popular jsonwebtoken
library to do that.
The passport
and passport-jwt
libraries will allow you to implement a Passport.js authentication strategy based on JSON Web Tokens.
For simplicity, you will implement the JWT authentication mechanism directly in the Express Recipes
application server, rather than in a separate authorization server.
Navigate to your project folder.
Install the following dependencies:
1$ npm install --save bcrypt express jsonwebtoken passport passport-jwt dotenv
In the previous tutorial, you set up a router, controller, and service for the recipes
resource in the Express Recipes
API. Let's follow a similar pattern to organize our user authentication logic:
1$ touch src/routers/users.js src/services/users.js db/users.json src/controllers/users.js
Create a new folder to store middleware functions and within it, create a file called auth.js
:
1$ mkdir src/middleware2$ touch src/middleware/auth.js
Later on, you will configure the Passport.js strategy inside src/middleware/auth.js
.
You will be storing the users data in db/users.json
. Note that this is not a robust database solution since there's no built-in data validation, but it is sufficient for demonstration purposes.
Add a user to the file to serve as an example of what an entry in the file might look like:
1[2 {3 "id": 1,4 "name": "Hou",5 "email": "hou@mail.com",6 "password": "$2b$10$u2r3EK7.p4l9bzQd74nGNepvIHw5gF84j4Dy6/kJmtNR4OC.A5t5O"7 }8]
Notice that the user's password
is hashed, for security reasons. Later on, you will learn how to use bcrypt
to hash a user's password before storing it in the database.
Create a .env
file in the project root folder to store the JWT-related configuration variables:
1JWT_SECRET=##%%MyS3cr3tK3Y%%##
JWT_SECRET
will be used to create a signature for signing and validating the JWTs.
Keep in mind that since the .env
file typically contains sensitive information (e.g., API secret keys), you will not want to commit it to Git, so it should be added to the .gitignore
file.
You will use the JWT_SECRET
variable later on to configure the Passport.js strategy.
Before Passport.js can authenticate a request, you must set up the Passport.js authentication middleware and configure Passport.js to use the JWT authentication strategy. Add the following code to src/middleware/auth.js
:
1// import the libraries and classes we need2require("dotenv").config(); // loads the environment variables into process.env3const passport = require("passport");4const { Strategy, ExtractJwt } = require("passport-jwt");5
6const { find: findUser } = require("../services/users");7
8const { JWT_SECRET } = process.env;9
10// Create a new instance of the JWT Passport.js strategy11const strategy = new Strategy(12 // Pass an object literal as the first argument13 // to new Strategy to control how a token is extracted from the request14 {15 // Extract the 'bearer' token from the authorization header,16 // where the encoded JWT string is stored17 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),18 // A string for verifying the token's signature19 secretOrKey: JWT_SECRET,20 },21 async (jwtPayload, done) => {22 try {23 // jwtPayload contains the decoded JWT payload,24 // which includes the user's id25 // Find that user in the database26 const user = await findUser({ id: jwtPayload.id });27
28 if (!user) {29 const err = new Error("User not found");30 err.statusCode = 404;31 throw err;32 }33
34 // done is an error-first callback with signature done(error, user, info)35 // pass the found user to the route handler36 done(null, user);37 } catch (error) {38 done(error);39 }40 }41);42
43// Register the strategy configured above so that Passport.js can use it authentication44passport.use(strategy);45
46// A middleware for initializing passport47const initialize = () => {48 return passport.initialize();49};50
51// Add this middleware to privileged routes later on52const authenticate = () => {53 // Since you are using JWTs, you don't need to create a session, so set it to false here.54 // Otherwise, Passport.js will attempt to create a session.55 return passport.authenticate("jwt", { session: false });56};57
58module.exports = {59 initialize,60 authenticate,61};
In src/index.js
, import and load the auth
middleware when starting the server, like this:
1const express = require("express");2const path = require("path");3const cors = require("cors");4
5const recipesRouter = require("./routers/recipes");6const { handleError } = require("./utils/error");7const auth = require('./middleware/auth.js');8
9const app = express();10
11app.use(cors());12
13app.use((req, res, next) => {14 const { method, path } = req;15 console.log(16 `New request to: ${method} ${path} at ${new Date().toISOString()}`17 );18 next();19});20
21app.use(express.json());22app.use(express.urlencoded({ extended: true }));23
24app.use(auth.initialize());25
26app.get("/", (req, res) => {27 res.redirect("/api/v1/recipes");28});29
30app.use("/api/v1/recipes", recipesRouter);31
32app.use(handleError);33
34const port = process.env.PORT || 8080;35
36app.listen(port, () => {37 console.log(`Server is up on port ${port}.`);38});
users
ServiceInside src/services/users
, create three functions: authenticate()
and create()
, which authenticate and create users with JWT authorization tokens respectively, and find()
which retrieves a user by id
or email
:
1require("dotenv").config();2const bcrypt = require("bcrypt");3const fs = require("fs").promises;4const jwt = require("jsonwebtoken");5const path = require("path");6
7const { JWT_SECRET } = process.env;8const usersFilePath = path.join(__dirname, "./users.json");9
10// Authenticate the user and return an authorization token for the user.11// Use this function to authenticate a user who's logging in.12const authenticate = async ({ id, email, password }) => {13 const user = await find({ email });14 // Hash the user's password and compare the result with the hash15 // saved in the database to see if the password is correct.16 const isPasswordValid = await bcrypt.compare(password, user.password);17
18 // Call jwt.sign(), which returns an authentication token.19 // The first argument is an object that contains the data to20 // be embedded in the token. You can pass in a unique identifier for21 // the user, such as the user's id stored in the database.22 // The second argument is a string, which could be any random series23 // of characters used to sign the token to ensure the token has not been tampered with24 // when it is sent back to the server later on.25 // The third argument is a configuration object for the token.26 const token = jwt.sign({ id: user.id }, JWT_SECRET, {27 expiresIn: 24 * 60 * 60, // Expire tokens after a certain amount of time so users can't stay logged in forever28 });29
30 return { token };31};32
33// Save the new user to the database and return an authorization token for the user34const create = async ({ email, name, password }) => {35 const users = JSON.parse(await fs.readFile(usersFilePath));36
37 const newUser = {38 id: users.length + 1, // Not a robust database incrementor; don't use in production39 email,40 name,41 // Here, pass the user's password to bcrypt's hash function to create a hash,42 // which is stored in the database instead of the user's original password.43 // Hashing is a one-way operation, so the hash cannot be reversed to its original form.44 // The first argument is the password to be encrypted, and the second argument45 // is the number of salt rounds. The higher the number of salt rounds used,46 // the stronger the resulting hashed password becomes47 password: await bcrypt.hash(password, 10),48 };49
50 // Generate the JWT with jwt.sign. The return value is an51 // authentication token52 const token = jwt.sign({ id: newUser.id }, JWT_SECRET, {53 expiresIn: 24 * 60 * 60, // Expire tokens after a certain amount of time so users can't stay logged in forever54 });55
56 users.push(newUser);57
58 // save the new user to our database59 await fs.writeFile(usersFilePath, JSON.stringify(users));60
61 return { token };62};63
64const find = async ({ id, email }) => {65 const users = JSON.parse(await fs.readFile(usersFilePath));66 return users.find((user) => user.id === parseInt(id) || user.email === email);67};68
69module.exports = {70 authenticate,71 create,72 find,73};
users
ControllerImagine if the user is required to provide their login credentials every time they want to access our coveted recipes. This would not be an ideal user experience. Instead, let's have the server issue a JWT to the client when a user signs up for the first time or logs in successfully.
Inside src/controllers/users.js
, add route handlers to handle user signup and login:
1const { create, authenticate } = require("../services/users");2
3const handleSignup = async (req, res, next) => {4 try {5 const { name, email, password } = req.body;6 const user = await find({ email });7
8 if (user) {9 throw new Error("Email already exists!");10 }11 // Create a token for the user12 const { token } = await create({ name, email, password });13
14 // Send a token to the client when a user signs up15 res.json({ token });16 } catch (error) {17 next(error);18 }19};20
21const handleLogin = async (req, res, next) => {22 try {23 const { email, password } = req.body;24 const user = await find({ email });25
26 if (!user) {27 throw new Error("Unable to login");28 }29
30 // Create a token for the user, if successfully authenticated31 const { token } = await authenticate({ email, password });32 res.json({ token });33 } catch (error) {34 next(error);35 }36};37
38module.exports = {39 handleSignup,40 handleLogin,41};
After the user signs up initially or logs in successfully, the server returns a token to the client that the client can attach to any future requests that need to be authenticated, so there is no need for the user to provide their login credentials anymore until the token expires.
users
RouterInside src/routers/users.js
, route the requests to the appropriate controllers:
1const express = require("express");2const { handleSignup, handleLogin } = require("../controllers/users");3const router = express.Router();4
5router.post("/signup", handleSignup);6router.post("/login", handleLogin);7
8module.exports = router;
Inside src/index.js
, mount the user authentication /api/v1/users
routes, like this:
1const express = require("express");2const path = require("path");3const cors = require("cors");4
5const recipesRouter = require("./routers/recipes");6const usersRouter = require('./routers/users');7const { handleError } = require("./utils/error");8const auth = require('./middleware/auth.js');9
10const app = express();11
12app.use(cors());13
14app.use((req, res, next) => {15 const { method, path } = req;16 console.log(17 `New request to: ${method} ${path} at ${new Date().toISOString()}`18 );19 next();20});21
22app.use(express.json());23app.use(express.urlencoded({ extended: true }));24
25app.use(auth.initialize());26
27app.get("/", (req, res) => {28 res.redirect("/api/v1/recipes");29});30
31app.use("/api/v1/recipes", recipesRouter);32app.use("/api/v1/users", usersRouter);33
34app.use(handleError);35
36const port = process.env.PORT || 8080;37
38app.listen(port, () => {39 console.log(`Server is up on port ${port}.`);40});
recipes
ResourceYou can now make use of the authenticate()
middleware to restrict access to privileged API operations. For example, to allow only authenticated users to create and save a new recipe to the database, you can pass auth.authenticate()
as the first argument to the POST
handler:
1+ const auth = require('../middleware/auth');2...3- router.route('/').get(getAll).post(save);4+ router.route('/').get(getAll).post(auth.authenticate(), save);
CHALLENGE: Restrict any PUT
and DELETE
routes to authenticated users only.
Test your API in Postman to ensure only authorized users can perform mutative database operations!
Send a request to the POST /api/v1/recipes
route (remember to set the Content-Type
in the Headers tab to application/json
):
You should see an Unauthorized
message if you tried to send the request above.
Because POST /api/v1/recipes
is now a protected route, you'd have to sign up for an account first before you can use this route!
Send a request to the POST /api/v1/users/signup
route (remember to set the Content-Type
in the Headers tab to application/json
):
In the image above, you can see an access token is included in the response. That's our JWT!
1{2 "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNTk0NTIxMDIwLCJleHAiOjE1OTQ2MDc0MjB9.pcdjNFwezYNdZTYjwWndGowLDkhjcl-debci5BLS8AY"3}
If you look carefully, you can see that a JWT is made up of three distinct parts separated by a period (.).
The first part of the JWT (i.e., eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
) represents the header, which is a base64-encoded JSON string. The string contains information about the type of token it is (i.e., JWT) and the algorithm that was used to generate it (i.e., HS256
)
The second part of the JWT (i.e., eyJpZCI6MiwiaWF0IjoxNTk0NTIxMDIwLCJleHAiOjE1OTQ2MDc0MjB9
) represents the payload or claims, which is also a base64-encoded JSON string. The string contains the data that you provided to create the token (i.e., user.id
, expiry time)
The third part of the JWT (i.e., pcdjNFwezYNdZTYjwWndGowLDkhjcl-debci5BLS8AY
) represents the signature, which is used to securely validate the token and is calculated using the encoded header, encoded payload, secret, and the algorithm specified in the header; this signature can only be created by somebody in possession of all four pieces of information.
Head over to base64decode.org to decode the token you received from the server:
As you can see, a JWT is not encrypted, so anybody who steals the token can still read its contents, so be careful not to put sensitive information in the payload.
It's hard to tamper with a JWT, as long as the secret used for the signature is kept private. You use the exact same secret that the token was created with to validate it.
Check the db/users.json
file. You should see your data, including the hashed password, in the file.
Send a request to the POST /api/v1/recipes
route again, this time attaching the token to the Authorization headers as a Bearer Token, like this:
You should see a 201 Created
message indicating successful recipe creation!
Test the PUT
and DELETE
routes, to make sure that you are able to perform these operations as long as your request contains the token.
A detailed discussion of token invalidation is outside the scope of this course. Take a look at this article if you're interested in learning more!
Take a moment to reflect on what you’ve learned in this tutorial and answer the following questions:
What is Passport.js?
What is a JWT? Why are JWTs useful for implementing RESTful architectures?
How can you secure user passwords that are stored in the database?
Passport.js is an authentication middleware for Node.js. It offers a wide variety of authentication mechanisms, known as "strategies", as individually-packaged modules.
In REST architectures, client-server interactions are typically stateless. The server does not store any history or state about the client session. JWT (JSON Web Tokens) is a lightweight and secure approach to transferring state from the client to the server in a REST framework. JWT (pronounced "JOT") relies on the exchange of encoded and crytopgraphically signed JSON strings between client and server.
A user's password is typically hashed (using a library like bcrypt
) before being stored in the database, for security reasons.
In Part 6, you’ll learn how to build a live game using Express and Socket.io.
What did you like or didn't like about this post? Let me know what worked well and what can be improved. Your feedback is much appreciated!