DEV Community

Cover image for Enhancing Security in Node.js & TypeScript Applications with JWT Authentication.
Emmanuel Eneche
Emmanuel Eneche

Posted on

Enhancing Security in Node.js & TypeScript Applications with JWT Authentication.

Introduction

Implementing authentication in your application often requires various techniques. In Node.js for example, there are several authentication strategies like;

  • Token-based authentication
  • Session-based authentication &
  • Oauth or OpenID connect.

This article we'll beam the search light on Token-based authentication particularly with the use of JWT.

The Concept of JWT

JWT is JSON Web Token, and it is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. For more details See Docs.

Purpose of JWT

When a user successfully logs into the application, each subsequent request will include a signed token (JWT) which then enables the user access routes, resources and services that requires that signed token. This provides a secure way for transmitting information between parties, as the server can identify who the client is by using the signature that contains the header and payload information.

To get into more details on JWT structure like; signature, header and payload info, please visit the docs here.

Implementation with Express & TypeScript

Let's create a new Node project and have TypeScript set up. First, we initialize a new project by using npm, this way:

npm init -y
Enter fullscreen mode Exit fullscreen mode

We now have our project initialized with the package.json file created. This provides a base for storing our project metadata like dependencies and scripts. Next we create a minimal express server and also install JWT library with the following commands:

npm i express jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

We have installed express & jsonwebtoken. Next, we'll install TypeScript and other required types on the project with the following:

npm i -D typescript @types/express @types/node @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

The -D or using --dev flag always instructs the package manager to install these dependencies as development dependencies in the package.json file. The installed @types prefix will eliminate any type of related errors during TypeScript compilation.

Next, we generate the tsconfig.json file which serves as TypeScript configuration file. It defines the default options with flexibility to modify the compiler settings to suit our project needs. This file is mostly placed in root of applications and can be achieved by:

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

When this command is executed, the tsconfig.json file is created with default compiler options as seen below:
Fig 1.1 TypeScript Compiler Settings

Heading over to the tsconfig.json file, we need to adjust few options within the "compilerOptions" block. Hence, We set the following:

{
  "compilerOptions" :{
     "target" : "ES2020", //Set the JavaScript language version for emitted JavaScript and include compatible library declarations
     "lib": ["DOM", "ESNext"], //
     "module": "NodeNext", //Specify what module code is generated. 
     "outDir": "./dist", //Specify an output folder for all emitted files.
     "allowSyntheticDefaultImports": true, //Allow 'import x from y' when a module doesn't have a default export.
     "esModuleInterop": true, //Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility.
      "forceConsistentCasingInFileNames": true, //Ensure that casing is correct in imports.
      "strict": true, //Enable all strict type-checking options.
      "noUncheckedIndexedAccess": true,  //Add 'undefined' to a type when accessed using an index.
      "skipLibCheck": true //Skip type checking all .d.ts files.

  }
}
Enter fullscreen mode Exit fullscreen mode

The above configuration gives us a better foundation to have TypeScript compiler all setup. Next, we create an src folder to help keep the root directory of the application more organized.

Todos

Inside src folder, we'll create a new file app.ts which will contain code to setup the express server. On the root of the application, We'll create a .env file to store all environment variables, then install dotenv package to enable the application read the contents in .env file.

npm i dotenv
Enter fullscreen mode Exit fullscreen mode

Then .env will have the following:

APP_URL=http://localhost:8000
PORT=8000
Enter fullscreen mode Exit fullscreen mode

Hence, app.ts will contain the bare minimum like below code:

import express, { Express, Request, Response } from "express";
import dotenv from "dotenv";

dotenv.config();
const app: Express = express();
app.use(express.json());
app.use(express.urlencoded({extended:true}));

app.listen(process.env.PORT, () => {
    console.log(`[server]: Server is running at ${process.env.APP_URL}`);
});

export default app;
Enter fullscreen mode Exit fullscreen mode

And file structure will be same like this:

Initial Node and TypeScript setup

Starting the Express Application

Before we proceed, we want our application to automatically restart whenever there are new changes in the project so we need to install nodemon to help watch the application for any changes, we achieve this by typing the command below on the terminal:

npm i --save-dev nodemon
Enter fullscreen mode Exit fullscreen mode

The above command has now installed and saved nodemon under the devDependencies section in the package.json file. In the package.json file, within the "scripts" section, we add the following line:

 "start": "nodemon dist/app.js"
Enter fullscreen mode Exit fullscreen mode

Because we'll be running TypeScript in watch mode, using npx tsc -w, TypeScript will then compile all .ts files into JavaScript .js and put them inside the dist/ folder. This is important because it automatically recompiles them to produce the corresponding JavaScript output file quite helpful in development mode.

Now, lets ensure the script section in our package.json file looks like this:

   "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon dist/app.js"
  },
Enter fullscreen mode Exit fullscreen mode

Also, lets open 2 terminals. In the first, we type in:

 npx tsc -w
Enter fullscreen mode Exit fullscreen mode

And in the second, we type in:

 npm start
Enter fullscreen mode Exit fullscreen mode

At this point, the stage is clear and if there are no issues so far, your first terminal now runs the TypeScript compiler in watch mode and should look like this:

TypeScript in watch mode

Also, the second terminal should look like this:
Nodemon watching for changes

Setting up MongoDB

To seamlessly generate tokens for authentication, we need a single User collection to verify a registered user upon login, and attach a signed token which can be used to access any protected route in the application.

Mongoose is an Object Data Modelling (ODM) Library that provides relationship between data and also provides schema validation.

To install mongoose, on the console, we type:

npm i mongoose
Enter fullscreen mode Exit fullscreen mode

Next, we setup the database configuration file. Inside the src/ directory, we'll create a file called database.ts and add the following lines of code:

import mongoose, {ConnectOptions} from "mongoose";
mongoose
  .connect("mongodb://localhost:27017/jwt-tutorial", {
    autoIndex: true,
  } as ConnectOptions)
  .then( async (db) => {
    console.log("Database Server Connected Successfuly.");
  })
  .catch((err) => {
    console.log("Error Connectiong to the Database::", err);
  });
Enter fullscreen mode Exit fullscreen mode

To invoke this database connection, all that needs to be done is to import this file in the app.ts by adding below code at the very top of the file:

import "./database";
Enter fullscreen mode Exit fullscreen mode

So this now looks like:
database setup
And if the database configuration is all good, whenever you start the application, you will see two items on the console like:

successful database setup

Next, inside the src/ directory, we'll create a folder called models and inside it, we'll create a file called userModel.ts. In userModel.ts, we add the following code:

import mongoose from "mongoose";
const userSchema = new mongoose.Schema(
    {
        fullName: { type: String, required: true },
        email: { type: String, required: true },
        password: { type: String, required: true },
    },
    {
        timestamps: true,
    }
);
const Users = mongoose.model("Users", userSchema);
export default Users;
Enter fullscreen mode Exit fullscreen mode

The database is ready, next we need to setup an endpoint to enable user registration. Registration will require user password, so we need to encrypt the password before saving the records. For password encryption, we'll install bcrypt library using the following:

npm i bcryptjs
Enter fullscreen mode Exit fullscreen mode

&

npm i --save-dev @types/bcryptjs
Enter fullscreen mode Exit fullscreen mode

Setting up JWT Authentication Handler

Inside the src/ directory, we'll create a folder called utils and add a file called authentication.ts In authentication.ts, we then add the following code:

import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { ObjectId } from 'mongodb';

interface userDetails {
  _id: ObjectId;
  fullName: string;
  email: string;

}

export type MiddlewareFunction = (
  req: Request,
  res: Response,
  next: NextFunction
) => void;

declare global {
  namespace Express {
    interface Request {
      user?: any;
    }
  }
}

export const generateToken = (user: userDetails) => {
  const { _id, fullName, email } = user;
  return jwt.sign(
    { _id, fullName, email},
    process.env.JWT_SECRET || "somethingsecret",
    {
      expiresIn: "30d",
    }
  );
};

export const isAuthenticated: MiddlewareFunction = (req, res, next) => {
  const authorization = req.headers.authorization;
  if (authorization) {
    const token = authorization.slice(7, authorization.length);
    jwt.verify(
      token,
      process.env.JWT_SECRET || "averysecretkey",
      (err, decode) => {
        if (err) {
          res.status(401).send({ message: "Invalid token supplied" });
        } else {
          req.user = decode;
          next();
        }
      }
    );
  } else {
    res.status(401).send({ message: "Unauthorized operation to protected resource" });
  }
};
Enter fullscreen mode Exit fullscreen mode

From the foregoing, you'll notice that the user object supplied as argument to the generateToken method, contains the valid user information as contained in the database, which is then used to generate a signed token which can be active for 30 days.

At this point, we have JWT ready for use and we can start creating new valid signed tokens and restrict any route/endpoint we so deem fit. Just to catch up with the flow of the application structure, please confirm that your folder structure is same as mine, so we don't endup firing in opposite directions 😄.

Application folder structure

Great! Now, we are both in sync! Let's setup the registration endpoint so we can enable users login and have the client retrieve the signed token.

We'll Create a folder inside the src/ directory called routes. Inside routes, we'll create a file called userRoute.ts and add the following code:

import express, { Request, Response } from "express";
import bcrypt from "bcryptjs";
import User from "../model/userModel";

const userRoute = express.Router();

userRoute.post("/register", async (req: Request, res: Response) => {
  try {
    const { fullName, email, password } = req.body;
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.json({
        message: "Already registered. Please Signin to Your Account",
        success: true
      }).status(400);
    }
    const user = new User({ fullName, email, password: bcrypt.hashSync(password, 8), });
    const createdUser = await user.save();
    if (createdUser) {
      const { _id, fullName, email, password, createdAt } = user;
      return res.status(201).json({
        _id, fullName, email, password, createdAt,
        message: `Registration was successful`,
        success: true,
      });
    }
    return res.status(400).json({ message: "Failed to create user data" })

  } catch (error: any) {
    res.status(500).send({ error: error.message });
  }
}
);
export default userRoute;
Enter fullscreen mode Exit fullscreen mode

The registration endpoint is complete. We need to register this route in the app.ts file and start testing new registration requests like;

import userRouter from "./routes/userRoute";
Enter fullscreen mode Exit fullscreen mode

We add the import statement firstly, and lastly, we add the the middleware like;

app.use("/api/v1/users", userRouter);
Enter fullscreen mode Exit fullscreen mode

We can test this endpoint using postman or any API client available as seen below:

User register endpoint test

Alright, registration was successfull as seen in postman above. Next, we setup the login endpoint. Inside the userRoute.ts file, we'll add a new route to handle login request like;

import { generateToken } from "../utils/authentication";

userRoute.post("/login", async (req: Request, res: Response) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({email});
    if (user) {
      const { _id, fullName, email, createdAt} = user;
      if (bcrypt.compareSync(password, user.password)) {
        return res.status(200).send({
          _id, fullName, createdAt,
          token: generateToken({ _id, fullName, email}),
          message: "successfully loggedin",
          success: true
        })
      }
      return res.status(401).json({ message: "Invalid email or password", success: false })
    }
    return res.status(401).json({ message: "Invalid email or password", success: false })
  } catch (error: any) {
    return res.status(500).send({ error: error.message })
  }
});
Enter fullscreen mode Exit fullscreen mode

Notice that we have imported the generateToken method and used it in the middleware above to generate a new signed token. We can see the newly generated token ("token") as part of the response object returned below:

Login signed token

Finally, to test the generated token, we'll add a new protected route to enable only logged in users retrieve products. In the userRoute.ts file, we add the code below:

import { generateToken, isAuthenticated } from "../utils/authentication";
Enter fullscreen mode Exit fullscreen mode

We add the isAuthenticated middleware to the import statement and next, we create a new endpoint to test. This will be a GET request, so we can place it just before the /register route like:

userRoute.get("/products", isAuthenticated, async (req: Request, res: Response) => {
  const products = [{productName: "HD Monitor", price: "$1,000", rating: 5, reviews: 200}];
  if (products) {
      return res.status(200).send(products);
  }
  res.status(202).send({ message: "Products not available" });
})
Enter fullscreen mode Exit fullscreen mode

The endpoint above is a good example of a protected route. The current user has not been authenticated yet so when we attempt to visit the /products route, a 401 and error message we defined earlier will be returned as seen below:

Restricted access to route

To properly authenticate the logged in user, we'll head over to postman and extract the token value that is returned when user logs in, then under the Headers tab, we'll enter the key "Authorization" and set value as:

Bearer longtokenstrings
Enter fullscreen mode Exit fullscreen mode

as seen below:

Authenticated user

Summarizing

This article so far seeks to provide a simplified and down to earth techniques for implementing JWT in your Node and TypeScript project. We covered TypeScript configurations to speedily get your project up and running without hassle and also created a bare minimum server for your express application. We went on to established connections to the MongoDB server for data storage as it would be the case in a real world scenario.

One key takeaway on JWT is that; it is a stateless authentication mechanism whereby once a user is authenticated and receives a JWT token, all necessary information (such as user identity and permissions) is encoded within the token itself. This means that the server does not need to store any session state, making it easier to scale horizontally by adding or removing servers without affecting the authentication process. I personally love this authentication technique and always do recommend. Let me know what your thoughts are on this.

Please do let me know in the comment section if you encountered any challenge in the course of following thru and I will always wade in and resolve. Also, please share this material with anyone who may need it. Thank you 🙏 and see you soon. 👋

Top comments (0)