Build a Robust JWT Auth System in Node.js: Access and Refresh Token Strategy

Smitter - May 30 '23 - - Dev Community

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

  1. Getting started
  2. What we will do
  3. Conclusion

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:

  1. Database integration (in part 2)
  2. Cross Origin Resource Sharing (in part 3)
  3. Express.js error handling (in part 4)

What we will do

An overview outline:

  1. Declare environment variables in .env file that will be loaded into application environment.
  2. Create User database model to represent a user in our application.
  3. Add email service to send emails from our application.
  4. Create validators to validate data coming from client.
  5. Create authentication middleware to check for authentication on protected routes.
  6. Create controller functions to handle HTTP request and process a response for a matched route.
  7. Create routes. So a specific functionality is provided at each route.
  8. 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/
             └── ...
Enter fullscreen mode Exit fullscreen mode

Let's dive in

Child jumps into shallow pool

  1. 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 at server directory root, i.e server/(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 use dotenv 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.

  2. Create User database model

    A 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 defined tokens 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 the User 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 the statics property on the UserSchema. This method simply finds a user by email and password. 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-built new Error().

    In the 5th part(5. ATTACH CUSTOM INSTANCE METHODS), we will add three instance methods that will be invokable on the User 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 create resetTokenValue and resetTokenSecret. A resetToken is obtained by concatenating these parts. We also create a resetTokenHash from these parts. We return plaintext resetToken while resetTokenHash 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 with base64url encoding. The second part(resetTokenSecret) is created with hex encoding. These encodings allow to concatenate the two parts, to form a resultant resetToken. 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 and A-F). Learn more about base64url 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);
    
  3. 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:

    1. Host
    2. Port
    3. Username
    4. 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 at server/ 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's createTransport(...). Emails from our application are forwarded to SMTP server for actual sending using nodemailer's transporter.sendMail(...). An info object is returned containing the delivery information.

    Open/create a second file sendEmail.js. It should be located at server/src/services/email/, beside email.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 it mailOptions to run email sending. We then export the sendEmail() that will be used in our application.

  4. 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 file auth-validators.js located at server/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 at server/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.

    Create index.js at server/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.

  5. 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:

    1. 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 the catch block.
    2. 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 is thrown with a custom error constructor AuthorizationError() which would return an authentication failure response regarding the HTTP specs for returning unauthorized/unauthenticated error.

  6. 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.

    1. /api/users/login - User login.
    2. /api/users/signup - User signup.
    3. /api/users/logout - User logout.
    4. /api/users/master-logout - User logout from all devices.
    5. /api/users/reauth - Refresh Access Token.
    6. /api/users/forgotpass - Forgot password.
    7. /api/users/resetpass - Reset password.
    8. /api/users/me - Get profile of logged in user.
    9. /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 in cookie 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 in cookie property of const REFRESH_TOKEN object. More specifically in the options 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 a SameSite attribute to None will also require you to set Secure attribute, implying a cookie is only sent in an encrypted request over the HTTPS protocol. Hence in cookie configurations above, we also set secure to true. Localhost is not a https protocol but it is treated special i.e cookie should still be sent even with secure=true. You can learn more about cookie sameSite attribute here.

    Another property is httpOnly set to true. In cookie context, a cookie with httponly 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 use next() 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 request object. Validators are route level middlewares added before controllers that need user data validation in a route. They enforce validation rules on data in request object. Validation result including errors are aggregated on the request object, hence validation result is available in request object from our controllers. We throw an error using a CustomError constructor if data validation errors are found in request object.

    Aside from data validation, the exported controller shared above stipulates control flow for a user login:

    1. Identify user by login credentials received.
    2. Generate Access and Refresh Tokens for the identified user.
    3. Set a cookie in response with Refresh Token as its value.
    4. 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:

    1. Save new user with user data from request body.
    2. Generate Access and Refresh Tokens for the new user.
    3. Set a cookie in response with Refresh Token as its value.
    4. 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:

    1. We obtain refresh token from cookie in incoming request.
    2. Create a hash of the retrieved refresh token to match it with the hashed version stored in the database.
    3. Remove the corresponding hashed refresh token from the list of tokens associated with the user’s record in the database.
    4. 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:

    1. Extracts refresh token from the incoming HTTP request's cookies.
    2. Decodes the extracted refresh token to access its payload.
    3. Generates a hash of the original (encoded) refresh token extracted from the cookies.
    4. Query the database to find a user that matches two criteria:
      1. The user's _id matches the one stored in the decoded refresh token payload.
      2. The user's stored refresh token hash matches the hash generated in step 3.
    5. If a matching user is found, generate a new Access Token containing embedded relevant user data retrieved from the database.
    6. 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:

    1. Queries the database to retrieve a user account associated with the email address provided in the incoming HTTP request.
    2. 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:


    sample reset email message


    Password reset email

    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:

    1. Extract the reset token from the incoming HTTP request.
    2. Decode the URI-encoded reset token using decodeURIComponent() to restore its original character representation.
    3. Parse the decoded reset token, which is composed of two parts—a reset token value and a reset token secret—separated by a + symbol.
    4. Generate a reset token hash using the parsed components, as the database stores reset tokens in hashed form for security.
    5. Query the database for a user record that meets two criteria:
      1. The stored reset password token matches the generated reset token hash.
      2. The token's expiry time has not yet passed.
    6. If a matching user is found, update their password with the new password provided in the request body.
    7. 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 one fetchAuthUserProfile, fetches a user by ID gotten from authentication middleware as req.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 location server/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.

  7. 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 location server/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/create index.js, at the location server/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.

  8. 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 at server directory root, i.e server/(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✌.

. . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player