Build a Robust JWT Auth System in Node.js: Access and Refresh Token Strategy
Smitter
Posted on May 30, 2023
part 5
Summary: This article walks you through how to implement JSON Web Token(JWT) Authentication to create solid user login feature for web appllications. Tricky concepts on access token and refresh token are demystified on how they add up to securing endpoints.
Note đź””:
You can jump ahead to the final work, the complete API and front-end can be found on Githubâś©.
The Common Misconception
The most common advice is do not waste time implementing login. Now as years go by and you climb the career ladder, it is important to understand how the whole system works to elevate yourself as an architect and make more educated design decisions. Authentication is one of the areas you need to understand how it consolidates into a system. More reasons why you should know authentication is listed in this blog.
When thinking about authentication, the common mental picture people have is a login HTML page submitting data to a backend API that cross checks submitted data with what is in the DB. While that is true for the bare bones of an authentication system, there is finer details that need to be done right which we would discuss in the rest of the blog.
Table of Contents
Getting Started
This is a coding tutorial. And to save length of the article better spent on the "fun stuff", there is a starting repo from Github to base your work on. Change into server
directory which is what we need in this blog.
Note: This article assumes you already have NodeJS installed in your computer.
Project Dependencies
The following third-party dependencies are used:
- Express - A web framework hosted on NodeJS runtime, to handling requests coming to our server.
- Express Validator - To run server-side validation.
- Bcryptjs - To crumble plain text into irreversible hashes.
- Cookie Parser - To parse cookies in incoming HTTP request.
- Cors - To configure our backend to understand CORS protocol.
- Dotenv - To load variables declared in
.env
file into the project's environment. - Jsonwebtoken - To facilitate token-based authentication, i.e access and refresh token.
- Mongoose - An object modelling tool for MongoDB to enforce a specific mongoDB schema.
- Nodemailer - To send emails from NodeJs application.
- Nodemon - To restart server app whenever file changes occur.
These dependencies are already listed in package.json
of the starter repo. To include them in your project, ensure you are in server
directory root and run npm install
from the terminal.
Starter repository has some steps covered:
- Database integration (in part 2)
- Cross Origin Resource Sharing (in part 3)
- Express.js error handling (in part 4)
What we will do
An overview outline:
-
Declare environment variables in
.env
file that will be loaded into application environment. -
Create
User
database model to represent a user in our application. - Add email service to send emails from our application.
- Create validators to validate data coming from client.
- Create authentication middleware to check for authentication on protected routes.
- Create controller functions to handle HTTP request and process a response for a matched route.
- Create routes. So a specific functionality is provided at each route.
- Register routes, So internal routing mechanism can match HTTP request to a route.
Directory structure
Final directory structure we will have:
đź“‚server/
├── 📄.env
└── 📂src/
├── 📄index.js
+ ├── 📂models/ 📄User.js
+ ├── 📂services/
+ │  └── 📂email/
+ │  ├── 📄email.js
+ │  └── 📄sendEmail.js
+ ├── 📂validators/
+ │ ├── 📄index.js
+ │ ├── 📄auth-validators.js
+ │ └── 📄user-validators.js
+ ├── 📂middlewares/ 📄authCheck.js
+ ├── 📂controllers/
+ │  └── 📂user/
+ │  ├── 📄index.js
+ │  ├── 📄auth.controller.js
+ │  └── 📄user.controller.js
+ ├── 📂routes/
+ │  ├── 📄index.js
+ │  └── 📄user.routes.js
├── 📂config/
│  └── ...
└── 📂dbConn/
  └── ...
-
Declare environment variables
Environment variables are key-value pairs that the operating system provides to applications at runtime. They store configuration data, such as database credentials or API keys, allowing applications to adapt to different environments (e.g., development, testing, production) without altering the code. These variables are kept in the system's memory (RAM) and away from application code so running applications can access them securely and efficiently.
We'll make use of environment variables to store configuration variables that are sensitive or personal or both.
Let's declare environment variables.
Create.env
file atserver
directory root, i.eserver/
(refer above). Declare the variables as follows:
AUTH_REFRESH_TOKEN_SECRET=2Kp6BjSKl3boT6+Zf3D0tw AUTH_REFRESH_TOKEN_EXPIRY=1d AUTH_ACCESS_TOKEN_SECRET=bkZaWfqssqzE0fg1TMCHqQ AUTH_ACCESS_TOKEN_EXPIRY=120s RESET_PASSWORD_TOKEN_EXPIRY_MINS=15
For the "xxx_TOKEN_SECRET"s, you can input any value. Random characters is ideal. NodeJs can help generate random characters on the fly; In your terminal run:
node -e "console.log(crypto.randomBytes(32).toString('base64'))"
.Realize how we set expiration time of our authentication tokens i.e Refresh Token expiration time(
AUTH_REFRESH_TOKEN_EXPIRY
) is longer than Access Token's (AUTH_ACCESS_TOKEN_EXPIRY
). Also, the Access Token expiration time is reasonably short.AUTH_REFRESH_TOKEN_SECRET
- Holds value of the secret to sign JWT Refresh Token.
AUTH_REFRESH_TOKEN_EXPIRY
- Holds value of the expiration time of the JWT Refresh Token.
AUTH_ACCESS_TOKEN_SECRET
- Holds value of the secret to sign JWT Access Token.
AUTH_ACCESS_TOKEN_EXPIRY
- Holds value of the expiration time of the JWT Access Token.
RESET_PASSWORD_TOKEN_EXPIRY_MINS
- Expiry time of the password reset link.Note:
.env
file declares environment variables in plain text(it does not load them into OS environment). We will usedotenv
library to read variables in.env
file and actually load them into our application's environment(OS). Also be sure to add.env
file to your.gitignore
to avoid sharing your.env
variables. -
Create
User
database modelA database model, is an interface for interacting with the database to query for operations like reading, creating, updating and deleting records.
User
model will represent a user of our application. This is the only model we will need for this tutorial. We'll use Mongoose to create this model.Create a file
User.js
at the path:server/src/models/
(refer). Paste the following code in this file:
const mongoose = require("mongoose"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); // 👇 Created in part 4 const CustomError = require("../config/errors/CustomError"); // Pull in Environment variables const ACCESS_TOKEN = { secret: process.env.AUTH_ACCESS_TOKEN_SECRET, expiry: process.env.AUTH_ACCESS_TOKEN_EXPIRY, }; const REFRESH_TOKEN = { secret: process.env.AUTH_REFRESH_TOKEN_SECRET, expiry: process.env.AUTH_REFRESH_TOKEN_EXPIRY, }; const RESET_PASSWORD_TOKEN = { expiry: process.env.RESET_PASSWORD_TOKEN_EXPIRY_MINS, }; /* 1. CREATE USER SCHEMA */ /* 2. SET SCHEMA OPTION */ /* 3. ATTACH MIDDLEWARE */ /* 4. ATTACH CUSTOM STATIC METHODS */ /* 5. ATTACH CUSTOM INSTANCE METHODS */ /* 6. COMPILE MODEL FROM SCHEMA */ module.exports = UserModel;
The snippet above imports dependencies we will use. We have pulled in environment variables, available in
process.env
and assigned them to constants for ease of access. We have 6 parts(commented) in the code snippet above.Under the 1st part(
1. CREATE USER SCHEMA
), add the following code:
const User = mongoose.Schema; const UserSchema = new User({ firstName: { type: String, required: [true, "First name is required"] }, lastName: { type: String, required: [true, "Last name is required"] }, email: { type: String, required: [true, "Email is required"], unique: true, }, password: { type: String, required: true, }, tokens: [ { token: { required: true, type: String }, }, ], resetpasswordtoken: String, resetpasswordtokenexpiry: Date, });
We have defined the schema for a user in our application. In other words, the attributes a user entity will have. Hence users stored in the database will be expected to each have these attributes. Some are optional while others are required.
Note that we have definedtokens
in the database schema where we will store refresh tokens generated for a user.In the 2nd part(
2. SET SCHEMA OPTION
), add the following code:
UserSchema.set("toJSON", { virtuals: true, transform: function (doc, ret, options) { const { firstName, lastName, email } = ret; return { firstName, lastName, email }; // return fields we need }, });
This ensures that every time a document is retrieved from the
User
model, only the relevant data intended for the API response is returned.In the 3rd part(
3. ATTACH MIDDLEWARE
), add the following code:
UserSchema.pre("save", async function (next) { try { if (this.isModified("password")) { const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); } next(); } catch (error) { next(error); } });
This is a mongoose middleware that runs before a document is saved to the database. And it ensures that password attribute of a user is hashed if it was modified. Therefore, when a user is newly created, password of the user is hashed. The next time it is hashed once again is only when a user's password attribute is changed. Probably when a user has updated their password.
In the following parts, we have custom methods we are setting on the
UserSchema
. They will be available on theUser
model and are helpful in avoiding redundant code from the controllers.Under the 4th part(
4. ATTACH CUSTOM STATIC METHODS
), add the following code:
UserSchema.statics.findByCredentials = async (email, password) => { const user = await UserModel.findOne({ email }); if (!user) throw new CustomError( "Wrong credentials!", 400, "Email or password is wrong!" ); const passwdMatch = await bcrypt.compare(password, user.password); if (!passwdMatch) throw new CustomError( "Wrong credentials!!", 400, "Email or password is wrong!" ); return user; };
We have created a static method that is invokable directly on
User
model rather than its instance, enabled by thestatics
property on theUserSchema
. This method simply finds a user byemail
andpassword
. It throws an exception if the find is not successful.Note how we generate user-defined exception using custom error constructor(
throw new CustomError(...)
). That is explained in Express.js error handling. Otherwise you can use the in-builtnew Error()
.In the 5th part(
5. ATTACH CUSTOM INSTANCE METHODS
), we will add three instance methods that will be invokable on theUser
model instance:Add the first instance method:
UserSchema.methods.generateAcessToken = function () { const user = this; // Create signed access token const accessToken = jwt.sign( { _id: user._id.toString(), fullName: `${user.firstName} ${user.lastName}`, email: user.email, }, ACCESS_TOKEN.secret, { expiresIn: ACCESS_TOKEN.expiry, } ); return accessToken; };
This instance method generates an access token. The access token is a signed jwt embedded with user instance data.
Note: We use traditional functions because they have
this
binding. Using arrow functions will not produce expected results.Add the second instance method:
UserSchema.methods.generateRefreshToken = async function () { const user = this; // Create signed refresh token const refreshToken = jwt.sign( { _id: user._id.toString(), }, REFRESH_TOKEN.secret, { expiresIn: REFRESH_TOKEN.expiry, } ); // Create a 'refresh token hash' from 'refresh token' const rTknHash = crypto .createHmac("sha256", REFRESH_TOKEN.secret) .update(refreshToken) .digest("hex"); // Save 'refresh token hash' to database user.tokens.push({ token: rTknHash }); await user.save(); return refreshToken; };
Similar to first instance method, this method generates a refresh token which is a signed jwt embedded with user instance data. We store the refresh token in the DB. It will be useful in implementing a log out from all devices feature as seen later in the blog.
Note: We store a hashed version of the refresh token in the database which is a security practice to prevent changing users' password should the database be compromised.Add the third instance method:
UserSchema.methods.generateResetToken = async function () { const resetTokenValue = crypto.randomBytes(20).toString("base64url"); const resetTokenSecret = crypto.randomBytes(10).toString("hex"); const user = this; // Separator of `+` because generated base64url characters doesn't include this character const resetToken = `${resetTokenValue}+${resetTokenSecret}`; // Create a hash const resetTokenHash = crypto .createHmac("sha256", resetTokenSecret) .update(resetTokenValue) .digest("hex"); user.resetpasswordtoken = resetTokenHash; user.resetpasswordtokenexpiry = Date.now() + (RESET_PASSWORD_TOKEN.expiry || 5) * 60 * 1000; // Sets expiration age await user.save(); return resetToken; };
This instance method creates a reset token and a reset token hash. We use NodeJs native
crypto
module to createresetTokenValue
andresetTokenSecret
. AresetToken
is obtained by concatenating these parts. We also create aresetTokenHash
from these parts. We return plaintextresetToken
whileresetTokenHash
is saved to the database. In the database, we also set an expiry to the reset token so that it is expired after a duration or after use.The first part(
resetTokenValue
) is created withbase64url
encoding. The second part(resetTokenSecret
) is created withhex
encoding. These encodings allow to concatenate the two parts, to form a resultantresetToken
. A plus(+
) separator is specially used; since both of these encodings will never generate the+
character.
hex
encoding may be straight forward, i.e represented by only 16 symbols(0-9
andA-F
). Learn more aboutbase64url
encoding here.In the 6th part(
6. COMPILE MODEL FROM SCHEMA
): we compile a model from the schema integrated with additional definitions outlined in previous parts:
const UserModel = mongoose.model("User", UserSchema);
-
Add email service
Our application needs to send emails, an example usecase is sending password reset link to a user.
We will use nodemailer as a Mail User Agent(MUA) to send emails from our NodeJs application; by connecting to an SMTP server.We will use Mailtrap as our SMTP server, responsible to deliver emails.
Mailtrap can be used for email delivery. Apart from that, it offers a feature great for testing, that is, it can be used as fake SMTP server. This means we can use Mailtrap's SMTP server to receive emails from our NodeJs application and emulate sending without actual delivery. This service is ideal to test and view emails we send without spamming real recipients or flooding your own inboxes.
When you're ready to send emails to real inboxes, you can upgrade your Mailtrap account to enable actual email delivery. Alternatively, you can configure Nodemailer to work with a different provider such as Sendgrid or Brevo.You will need to get credentials from mailtrap to use their SMTP service. Follow this guide to get necessary credentials for SMTP integration.
Once you've completed the guide, you should have the following credentials ready:
- Host
- Port
- Username
- Password
We'll directly add the Host and Port to the Nodemailer configuration options. To protect sensitive information, we'll store the Username and Password in environment variables.
Open.env
file located atserver/
directory root(refer above). Append new variables:
AUTH_EMAIL_USERNAME=<Username> AUTH_EMAIL_PASSWORD=<Password> EMAIL_FROM=<Email sender>
EMAIL_FROM
is the sender in an email delivery. You can fill with your own email.Create the file:
email.js
. According to the directory structure for this tutorial(refer above), it should be at the path:server/src/services/email/
.Paste in the following code:
const nodemailer = require("nodemailer"); // Pull in Environments variables const EMAIL = { authUser: process.env.AUTH_EMAIL_USERNAME, authPass: process.env.AUTH_EMAIL_PASSWORD, }; async function main(mailOptions) { // Create reusable transporter object using the default SMTP transport const transporter = nodemailer.createTransport({ host: "smtp.mailtrap.io", port: 2525, auth: { user: EMAIL.authUser, pass: EMAIL.authPass, }, }); // Send mail with defined transport object const info = await transporter.sendMail({ from: mailOptions?.from, to: mailOptions?.to, subject: mailOptions?.subject, text: mailOptions?.text, html: mailOptions?.html, }); return info; } module.exports = main;
In above snippet we have exported a
main()
function that connects our application to Mailtrap SMTP server facilitated with nodemailer'screateTransport(...)
. Emails from our application are forwarded to SMTP server for actual sending using nodemailer'stransporter.sendMail(...)
. Aninfo
object is returned containing the delivery information.Open/create a second file
sendEmail.js
. It should be located atserver/src/services/email/
, besideemail.js
(refer above).Paste in the following code:
const main = require("./email.js"); const fixedMailOptions = { from: process.env.EMAIL_FROM, }; function sendEmail(options = {}) { const mailOptions = Object.assign({}, options, fixedMailOptions); return main(mailOptions); } module.exports.sendEmail = sendEmail;
Short and sweet âś”. We are calling imported
main()
function and passing to itmailOptions
to run email sending. We then export thesendEmail()
that will be used in our application. -
Create validators
Form data submitted from client to server cannot be trusted. We need to enforce validation rules for these data before we can start processing them in our server.
For server-side validation, we'll use express-validator which according to the docs:
express-validator is a set of express.js middlewares that wraps validator.js validator and sanitizer functions.
Being wrapped in express.js middlewares, means it would be easy to work with in an express.js application.
Let's create our first set of validators.
Create a fileauth-validators.js
located atserver/src/validators/
(refer above). Add the following code in it:
const { body, param } = require("express-validator"); const User = require("../models/User"); module.exports.loginValidator = [ body("email") .trim() .notEmpty() .withMessage("Email CANNOT be empty") .bail() .isEmail() .withMessage("Email is invalid"), body("password").notEmpty().withMessage("Password CANNOT be empty"), ]; module.exports.signupValidator = [ body("firstName") .trim() .notEmpty() .withMessage("Firstname CANNOT be empty"), body("lastName") .trim() .notEmpty() .withMessage("Lastname CANNOT be empty"), body("email") .trim() .notEmpty() .withMessage("Email CANNOT be empty") .bail() .isEmail() .withMessage("Email is invalid") .bail() .custom(async (email) => { // Finding if email exists in Database const emailExists = await User.findOne({ email }); if (emailExists) { throw new Error("E-mail already in use"); } }), body("password") .notEmpty() .withMessage("Password CANNOT be empty") .bail() .isLength({ min: 4 }) .withMessage("Password MUST be at least 4 characters long"), ]; module.exports.forgotPasswordValidator = [ body("email") .trim() .notEmpty() .withMessage("Email CANNOT be empty") .bail() .isEmail() .withMessage("Email is invalid"), ]; module.exports.resetPasswordValidator = [ param("resetToken").notEmpty().withMessage("Reset token missing"), body("password") .notEmpty() .withMessage("Password CANNOT be empty") .bail() .isLength({ min: 4 }) .withMessage("Password MUST be at least 4 characters long"), body("passwordConfirm").custom((value, { req }) => { if (value !== req.body.password) { throw new Error("Passwords DO NOT match"); } return true; }), ];
The code snippet above exports validation rules to be used on requests carrying data received from client-side forms. These are rules about a user's authentication.
For the second set of validators, create
user-validators.js
located atserver/src/validators/
(refer above).And paste in the following code:
const { param } = require("express-validator"); module.exports.fetchUserProfileValidator = [ param("id").notEmpty().withMessage("User id missing"), ];
The above snippet exports validation rule(s) that act on user object in our application.
Lastly, we would like to export all the validation rules from a single file.
Createindex.js
atserver/src/validators/
(refer above). And paste in:
const authValidators = require("./auth-validators"); const userValidators = require("./user-validators"); module.exports = { ...authValidators, ...userValidators, };
Simply, we are combining all validation rules and exporting as a single object.
-
Create authentication middleware
An authentication middleware will intercept HTTP requests to check for authentication.
A request is allowed to proceed to a resource after it has passed authentication check.Let's create this middleware. So create
authCheck.js
. According to directory structure(refer above), it should be at the path:server/src/middlewares/
.Paste in the following code:
const jwt = require("jsonwebtoken"); const AuthorizationError = require("../config/errors/AuthorizationError.js"); // Pull in Environment variables const ACCESS_TOKEN = { secret: process.env.AUTH_ACCESS_TOKEN_SECRET, }; module.exports.requireAuthentication = async (req, res, next) => { try { const authHeader = req.header("Authorization"); if (!authHeader?.startsWith("Bearer ")) throw new AuthorizationError( "Authentication Error", undefined, "You are unauthenticated!", { error: "invalid_access_token", error_description: "unknown authentication scheme", } ); const accessTokenParts = authHeader.split(" "); const aTkn = accessTokenParts[1]; const decoded = jwt.verify(aTkn, ACCESS_TOKEN.secret); // Attach authenticated user and Access Token to request object req.userId = decoded._id; req.token = aTkn; next(); } catch (err) { // Authentication check didn't go well console.log(err); const expParams = { error: "expired_access_token", error_description: "access token is expired", }; if (err.name === "TokenExpiredError") return next( new AuthorizationError( "Authentication Error", undefined, "Token lifetime exceeded!", expParams ) ); next(err); } };
Above snippet is an express middleware that checks if a request is authenticated in the following steps:
- The request has an access token present in the
Authorization
header. A Bearer token is expected, so we use the split function to get everything after the space. Any errors thrown here will end up in thecatch
block. - Verify if the access token is valid.
A request is considered authenticated if all goes well in the above steps. It would then be allowed to proceed to a protected resource.
Otherwise, an exception isthrow
n with a custom error constructorAuthorizationError()
which would return an authentication failure response regarding the HTTP specs for returning unauthorized/unauthenticated error. - The request has an access token present in the
-
Create controller functions
In this part we write controllers; the program logic for routes in our application. In express, they are also known as route handlers.
Typically in a routing mechanism, a controller registered for a particular path/route will be run when that route matches. Below are routes in our application. We will write controllers for these routes.
-
/api/users/login
- User login. -
/api/users/signup
- User signup. -
/api/users/logout
- User logout. -
/api/users/master-logout
- User logout from all devices. -
/api/users/reauth
- Refresh Access Token. -
/api/users/forgotpass
- Forgot password. -
/api/users/resetpass
- Reset password. -
/api/users/me
- Get profile of logged in user. -
/api/users/:id
- Get profile of user by Id.
So let's get started with creating controllers.
Create
auth.controller.js
. It should be at the path:server/src/controllers/user/
(refer above).
This file will contain controllers that alter user's authentication.Paste the following code inside the file:
const { validationResult } = require("express-validator"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const User = require("../../models/User"); const { sendEmail } = require("../../services/email/sendEmail"); // 👇Were created in part 4 const CustomError = require("../../config/errors/CustomError"); const AuthorizationError = require("../../config/errors/AuthorizationError"); // Top-level constants const REFRESH_TOKEN = { secret: process.env.AUTH_REFRESH_TOKEN_SECRET, cookie: { name: "refreshTkn", options: { sameSite: "None", secure: true, httpOnly: true, maxAge: 24 * 60 * 60 * 1000, }, }, }; const ACCESS_TOKEN = { secret: process.env.AUTH_ACCESS_TOKEN_SECRET, }; const RESET_PASSWORD_TOKEN = { expiry: process.env.RESET_PASSWORD_TOKEN_EXPIRY_MINS, }; /* 1. LOGIN USER */ /* 2. SIGN UP USER */ /* 3. LOGOUT USER */ /* 4. LOGOUT USER FROM ALL DEVICES */ /* 5. REGENERATE NEW ACCESS TOKEN */ /* 6. FORGOT PASSWORD */ /* 7. RESET PASSWORD */
The snippet above has imported modules/dependencies to aid writing logic in controller functions. Global scoped constants which are objects, are also declared. They store environment variables.
Moreover,const REFRESH_TOKEN
constant holds cookie configurations incookie
property we will use to set cookie(s) on a HTTP response.WHAT YOU NEED TO KNOW ABOUT COOKIES in decoupled applications communicating to each other via an API:
By default, browsers will not send cookies in cross-origin requests. Since we are building an API that we intend to consume from client-side sitting on a different origin, we have to set some configurations on the cookie to store in a user's browser. These configurations will allow the cookie to be sent back when cross-site request to our server is made.
These configurations are what are contained incookie
property ofconst REFRESH_TOKEN
object. More specifically in theoptions
property:
const REFRESH_TOKEN = { // ... cookie: { name: "refreshTkn", options: { sameSite: "None", secure: true, httpOnly: true, maxAge: 24 * 60 * 60 * 1000, }, }, };
We have set
sameSite
property to"None"
which in a cookie context means that the browser can send the cookie with cross site requests.
However, setting aSameSite
attribute toNone
will also require you to setSecure
attribute, implying a cookie is only sent in an encrypted request over the HTTPS protocol. Hence in cookie configurations above, we also setsecure
totrue
. Localhost is not a https protocol but it is treated special i.e cookie should still be sent even withsecure=true
. You can learn more about cookie sameSite attribute here.
Another property ishttpOnly
set totrue
. In cookie context, a cookie withhttponly
attribute stored in the browser is inaccessible to scripts running on the browser.
maxAge
property sets the age of the cookie to expire in browser.In the snippet above; where we shared code for
auth.controller.js
, we have 7 parts numbered each in a multiline comment. We will write controller functions in parts.In the 1st part(
1. LOGIN USER
), Add the following code:
module.exports.login = async (req, res, next) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { throw new CustomError( errors.array(), 422, errors.array()[0]?.msg ); } const { email, password } = req.body; /* Custom methods on user are defined in User model */ const user = await User.findByCredentials(email, password); // Identify and retrieve user by credentials const accessToken = await user.generateAcessToken(); // Create Access Token const refreshToken = await user.generateRefreshToken(); // Create Refresh Token // SET refresh Token cookie in response res.cookie( REFRESH_TOKEN.cookie.name, refreshToken, REFRESH_TOKEN.cookie.options ); // Send Response on successful Login res.json({ success: true, user, accessToken, }); } catch (error) { console.log(error); next(error); } };
Notice🕵 that we create controllers with the signature of an express middleware, i.e
(req, res, next) => {...}
. Creating this way will allow us to usenext()
to pass error encountered here to the error handling midleware configured earlier on.You'll see this piece of code frequently:
// ... const errors = validationResult(req); if (!errors.isEmpty()) { throw new CustomError(errors.array(), 422, errors.array()[0]?.msg); } // ...
It captures validation errors on the
req
uest object. Validators are route level middlewares added before controllers that need user data validation in a route. They enforce validation rules on data inreq
uest object. Validation result including errors are aggregated on the request object, hence validation result is available inreq
uest object from our controllers. Wethrow
an error using aCustomError
constructor if data validation errors are found inreq
uest object.Aside from data validation, the exported controller shared above stipulates control flow for a user login:
- Identify user by login credentials received.
- Generate Access and Refresh Tokens for the identified user.
- Set a cookie in response with Refresh Token as its value.
- Send response with Access Token in the body.
In the 2nd part(
2. SIGN UP USER
), Add the following code:
module.exports.signup = async (req, res, next) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { throw new CustomError( errors.array(), 422, errors.array()[0]?.msg ); } const { firstName, lastName, email, password } = req.body; /* Custom methods on newUser are defined in User model */ const newUser = new User({ firstName, lastName, email, password }); await newUser.save(); // Save new User to DB const accessToken = await newUser.generateAcessToken(); // Create Access Token const refreshToken = await newUser.generateRefreshToken(); // Create Refresh Token // SET refresh Token cookie in response res.cookie( REFRESH_TOKEN.cookie.name, refreshToken, REFRESH_TOKEN.cookie.options ); // Send Response on successful Sign Up res.status(201).json({ success: true, user: newUser, accessToken, }); } catch (error) { console.log(error); next(error); } };
The flow of user sign up:
- Save new user with user data from
req
uest body. - Generate Access and Refresh Tokens for the new user.
- Set a cookie in response with Refresh Token as its value.
- Send response with Access Token included in the body.
We include authentication tokens in response because presumably after a successful sign up, the frontend would want to automatically login a user.
In the 3rd part(
3. LOGOUT USER
), Add the following code:
module.exports.logout = async (req, res, next) => { try { // Authenticated user ID attached on `req` by authentication middleware const userId = req.userId; const user = await User.findById(userId); const cookies = req.cookies; const refreshToken = cookies[REFRESH_TOKEN.cookie.name]; // Create a refresh token hash const rTknHash = crypto .createHmac("sha256", REFRESH_TOKEN.secret) .update(refreshToken) .digest("hex"); user.tokens = user.tokens.filter( (tokenObj) => tokenObj.token !== rTknHash ); await user.save(); // Set cookie expiry option to past date so it is destroyed const expireCookieOptions = Object.assign( {}, REFRESH_TOKEN.cookie.options, { expires: new Date(1), } ); // Destroy refresh token cookie with `expireCookieOptions` containing a past date res.cookie(REFRESH_TOKEN.cookie.name, "", expireCookieOptions); res.status(205).json({ success: true, }); } catch (error) { console.log(error); next(error); } };
To logout a user:
- We obtain refresh token from cookie in incoming request.
- Create a hash of the retrieved refresh token to match it with the hashed version stored in the database.
- Remove the corresponding hashed refresh token from the list of tokens associated with the user’s record in the database.
- Set the refresh token's cookie expiration date to a past time, ensuring that browsers delete it immediately upon receipt.
Note đź””:
How we have handled logout is designed to preserve the stateless nature of JWTs. By destroying refresh token, it prevents a user from refreshing(renewing) their access token.
However, we do not implement a blocklist to immediately invalidate the current access token, meaning it remains valid until it expires.
While a blocklist could be integrated into the authentication middleware to invalidate Access Tokens instantly, maintaining such a list would contradict the stateless principles of JWTs, even though it effectively ensures immediate Access Token invalidation upon logout.In the 4th part(
4. LOGOUT USER FROM ALL DEVICES
), Add the following code:
module.exports.logoutAllDevices = async (req, res, next) => { try { // Authenticated user ID attached on `req` by authentication middleware const userId = req.userId; const user = await User.findById(userId); user.tokens = undefined; await user.save(); // Set cookie expiry to past date to mark for destruction const expireCookieOptions = Object.assign( {}, REFRESH_TOKEN.cookie.options, { expires: new Date(1), } ); // Destroy refresh token cookie res.cookie(REFRESH_TOKEN.cookie.name, "", expireCookieOptions); res.status(205).json({ success: true, }); } catch (error) { console.log(error); next(error); } };
It is the same concept as
logout
controller. Only that this time, we destroy all tokens in a user's record, with the line of code:user.tokens = undefined;
.In the 5th part(
5. REGENERATE NEW ACCESS TOKEN
), Add the following code:
module.exports.refreshAccessToken = async (req, res, next) => { try { const cookies = req.cookies; const refreshToken = cookies[REFRESH_TOKEN.cookie.name]; if (!refreshToken) { throw new AuthorizationError( "Authentication error!", undefined, "You are unauthenticated", { realm: "Obtain new Access Token", error: "no_rft", error_description: "Refresh Token is missing!", } ); } const decodedRefreshTkn = jwt.verify( refreshToken, REFRESH_TOKEN.secret ); const rTknHash = crypto .createHmac("sha256", REFRESH_TOKEN.secret) .update(refreshToken) .digest("hex"); const userWithRefreshTkn = await User.findOne({ _id: decodedRefreshTkn._id, "tokens.token": rTknHash, }); if (!userWithRefreshTkn) throw new AuthorizationError( "Authentication Error", undefined, "You are unauthenticated!", { realm: "Obtain new Access Token", } ); // GENERATE NEW ACCESSTOKEN const newAtkn = await userWithRefreshTkn.generateAcessToken(); res.status(201); res.set({ "Cache-Control": "no-store", Pragma: "no-cache" }); // Send response with NEW accessToken res.json({ success: true, accessToken: newAtkn, }); } catch (error) { if (error?.name === "JsonWebTokenError") { next( new AuthorizationError( error, undefined, "You are unauthenticated", { realm: "Obtain new Access Token", error_description: "token error", } ) ); return; } next(error); } };
The
refreshAccessToken
controller:- Extracts refresh token from the incoming HTTP request's cookies.
- Decodes the extracted refresh token to access its payload.
- Generates a hash of the original (encoded) refresh token extracted from the cookies.
- Query the database to find a user that matches two criteria:
- The user's
_id
matches the one stored in the decoded refresh token payload. - The user's stored refresh token hash matches the hash generated in step 3.
- The user's
- If a matching user is found, generate a new Access Token containing embedded relevant user data retrieved from the database.
- Send HTTP response containing the newly generated Access Token.
In the 6th part(
6. FORGOT PASSWORD
), Add the following code:
module.exports.forgotPassword = async (req, res, next) => { const MSG = `If ${ req.body?.email || "__" } is found with us, we've sent an email to it with instructions to reset your password.`; try { const errors = validationResult(req); if (!errors.isEmpty()) { throw new CustomError(errors.array(), 422); } const email = req.body.email; const user = await User.findOne({ email }); // If email is not found, we throw an exception BUT with 200 status code // because it is a security vulnerability to inform users // that the Email is not found. // To avoid username enumeration attacks, no extra response data is provided when an email is successfully sent. (The same response is provided when the username is invalid.) if (!user) throw new CustomError("Reset link sent", 200, MSG); let resetToken = await user.generateResetToken(); resetToken = encodeURIComponent(resetToken); const resetPath = req.header("X-reset-base"); const origin = req.header("Origin"); const resetUrl = resetPath ? `${resetPath}/${resetToken}` : `${origin}/resetpass/${resetToken}`; console.log("Password reset URL: %s", resetUrl); const emailMessage = ` <h1>You have requested to change your password</h1> <p>You are receiving this because someone(hopefully you) has requested to reset password for your account.<br/> Please click on the following link, or paste in your browser to complete the password reset. </p> <p> <a href=${resetUrl} clicktracking=off>${resetUrl}</a> </p> <p> <em> If you did not request this, you can safely ignore this email and your password will remain unchanged. </em> </p> <p> <strong>DO NOT share this link with anyone else!</strong><br /> <small> <em> This password reset link will <strong>expire after ${ RESET_PASSWORD_TOKEN.expiry || 5 } minutes.</strong> </em> <small/> </p> `; try { await sendEmail({ to: user.email, html: emailMessage, subject: "Reset password", }); res.json({ message: "Reset link sent", feedback: MSG, success: true, }); } catch (error) { user.resetpasswordtoken = undefined; user.resetpasswordtokenexpiry = undefined; await user.save(); console.log(error.message); throw new CustomError( "Internal issues standing in the way", 500 ); } } catch (err) { next(err); } };
The code above implements a forgot password handler that:
- Queries the database to retrieve a user account associated with the email address provided in the incoming HTTP request.
- Generates and sends an email to the identified user, containing a secure link for password reset.
This is a sample email sent to a user:
In the 7th part(
7. RESET PASSWORD
), Add the following code:
module.exports.resetPassword = async (req, res, next) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { throw new CustomError(errors.array(), 422); } const resetToken = new String(req.params.resetToken); const [tokenValue, tokenSecret] = decodeURIComponent(resetToken).split("+"); // Recreate the reset Token hash const resetTokenHash = crypto .createHmac("sha256", tokenSecret) .update(tokenValue) .digest("hex"); const user = await User.findOne({ resetpasswordtoken: resetTokenHash, resetpasswordtokenexpiry: { $gt: Date.now() }, }); if (!user) throw new CustomError("The reset link is invalid", 400); user.password = req.body.password; // Will be hashed by mongoose middleware user.resetpasswordtoken = undefined; user.resetpasswordtokenexpiry = undefined; await user.save(); // Email to notify owner of the account const message = `<h3>This is a confirmation that you have changed Password for your account.</h3>`; // No need to await sendEmail({ to: user.email, html: message, subject: "Password changed", }); res.json({ message: "Password reset successful", success: true, }); } catch (error) { console.log(error); next(error); } };
The code snippet implements a password reset handler that follows these steps:
- Extract the reset token from the incoming HTTP request.
- Decode the URI-encoded reset token using decodeURIComponent() to restore its original character representation.
- Parse the decoded reset token, which is composed of two parts—a reset token value and a reset token secret—separated by a
+
symbol. - Generate a reset token hash using the parsed components, as the database stores reset tokens in hashed form for security.
- Query the database for a user record that meets two criteria:
- The stored reset password token matches the generated reset token hash.
- The token's expiry time has not yet passed.
- If a matching user is found, update their password with the new password provided in the request body.
- Asynchronously send an email notification to the user confirming the password change, while simultaneously returning a success HTTP response to the client.
So we have created the first set of controller functions that control user's auth in
auth.controller.js
.
Next we need to create the second set of controller functions that act on user object in our application.Open/create
user.controller.js
. According to the directory structure for this tutorial, it should be at the path:server/src/controllers/user/
(refer above). Create the constituent directories if you do not have.
This file will contain controllers that act on user object in our application.Paste in the below code inside this file:
const { validationResult } = require("express-validator"); const CustomError = require("../../config/errors/CustomError"); const User = require("../../models/User"); /* 1. FETCH USER PROFILE BY ID */ module.exports.fetchUserProfile = async (req, res, next) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { throw new CustomError( errors.array(), 422, errors.array()[0]?.msg ); } const userId = req.params.id; const retrievedUser = await User.findById(userId); res.json({ success: true, user: retrievedUser, }); } catch (error) { console.log(error); next(error); } }; /* 2. FETCH PROFILE OF AUTHENTICATED USER */ module.exports.fetchAuthUserProfile = async (req, res, next) => { try { const userId = req.userId; const user = await User.findById(userId); res.json({ success: true, user, }); } catch (error) { console.log(error); next(error); } };
This is straight forward. We have two functions that fetch user profile. The first one
fetchUserProfile()
, fetches a user by ID provided in URL. And the second onefetchAuthUserProfile
, fetches a user by ID gotten from authentication middleware asreq.userId
.We have so far created and exported controller functions in separate files. Let's export all these controllers from a single file so that we could import them in other modules with one import statement.
For this, open/create a third file
index.js
, at the locationserver/src/controllers/user/
(refer above).
Add the code below in this file:
module.exports = { ...require("./auth.controller"), ...require("./user.controller"), };
We export one object that includes all exports from the imported files.
-
-
Create application routes
So far, we have written models and controllers. Now we need to create routes in our application. Basically, we will specify route paths and the controller to execute program logic at that path.
For this, open/create
user.routes.js
, at the locationserver/src/routes/
(refer above). Create the constituent directories if you do not have.
Add the code below in this file:
const express = require("express"); const validators = require("../validators"); const userControllers = require("../controllers/user"); const { requireAuthentication } = require("../middlewares/authCheck"); const router = express.Router(); // User Login router.post("/login", validators.loginValidator, userControllers.login); // User Signup router.post("/signup", validators.signupValidator, userControllers.signup); // User Logout router.post("/logout", requireAuthentication, userControllers.logout); // User Logout from all devices router.post( "/master-logout", requireAuthentication, userControllers.logoutAllDevices ); // Refresh Access Token router.post("/reauth", userControllers.refreshAccessToken); // Forgot password router.post( "/forgotpass", validators.forgotPasswordValidator, userControllers.forgotPassword ); // Reset password router.patch( "/resetpass/:resetToken", validators.resetPasswordValidator, userControllers.resetPassword ); // Authenticated user profile router.get( "/me", requireAuthentication, userControllers.fetchAuthUserProfile ); // Get user by ID router.get( "/:id", requireAuthentication, validators.fetchUserProfileValidator, userControllers.fetchUserProfile ); module.exports = router;
The code above contains the routes our application will have. We used
express.Router()
to organize application routes into a separate file.Using the classic
router
object, we define routes in the order of HTTP Method, route path and controller, i.e router.METHOD
("PATH"
,CONTROLLER
). In between route path and controller, we can add middleware(s) that intercept a request for earlier processing.For instance, from the snippet above, Authentication middleware(
requireAuthentication
) and validators(validators.x
) are middlewares that intercept a request before it is processed in controller(userControllers.x
). Authentication middleware ensures request is authenticated and validators apply validation rules on data within a request.Since the routes we have defined are all concerned with the user object in our application, let's define a base path for these routes.
Open/createindex.js
, at the locationserver/src/routes/
(refer above).
Add the code below in this file:
const express = require("express"); const router = express.Router(); const userRoutes = require("./user.routes"); router.use("/users", userRoutes); module.exports = router;
The snippet above makes
userRoutes
available on/users
base path. With this pattern, if your application has another user type e.g admin, you can create another base path for it.We then export
router
object created here. -
Register routes
We need to register routes we have created so that web traffic can be chanelled to them.
Therefore we need to include these routes in the entry file of our application. Open this file,
index.js
located atserver
directory root, i.eserver/
(refer above).Edit the 3rd part of this file like shown below:
const routes = require("./routes"); // import the modular routes at the top // ... /* 3. APPLICATION ROUTES 🛣️ */ app.use("/api", routes); // register modular routes // ...
We have imported the routes and regisered them on a base path,
"/api"
. Therefore in the long run, user routes will be available on a path/api/users/...
. For example to access login route, the path is/api/users/login
.
Conclusion
Cheers🥂, we have reached the end. This article has been an extensive exploration, packed with valuable information🤯 to elevate your technical prowess; perhaps the divine touch you needed. Through this process, we've successfully engineered a backend system complete with robust user registration and authentication features.
It's often observed that newcomers to web development struggle to grasp the intricacies of implementing JWT-based authentication correctly. Moreover, many find it challenging to incorporate essential security practices when working with JWTs.
Security is an expansive and mission-critical topic. In this article we have have implemented crucial security practices to consider when using JWTs for authentication, i.e access tokens and refresh tokens. For example:
- An access token is short-lived unlike the refresh token.
- Access token can be included in HTTP response body while refresh token is set in httpOnly response cookie.
- Secure token storage in databases, i.e in hashed form, crucial to minimize security risk should the database be compromised.
- A secure process is implemented to refresh Access Tokens using Refresh Tokens.
- Token clean-up on user logout:
- Clear specific token to destroy a specific login session(single log-out)
- Clear all tokens to destroy all login sessions(log out from all devices)
Building an authentication API is a superb feat. However, I understand that integrating it into a frontend can still feel overwhelming for many. If you're unsure how to piece it all together to create a seamless authentication experience, don’t worry. Let me know in the comments if you would like to see the next sections, where we walk through building the frontend with React, making the process clear and straightforward.
Let's connect @x. Till then...peace✌.
Posted on May 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.