DEV Community

Cover image for JWT Authentication in Typescript with Express
Julie Cherner
Julie Cherner

Posted on

JWT Authentication in Typescript with Express

I started my journey to the world of authentication in Typescript with the need to authenticate users on the front and the backend of the application.

The magic recipe for this was:

Backend part:

  • 2 post routes for user authentication (for sign up and login)
  • 1 controller, 1 service
  • 1 model, 1 collection in MongoDb
  • 1 bcrypt package for hashing and comparing passwords (+ its types)
  • 1 JWT package for creating and verification of tokens (+ its types)
  • 1 middleware for authentication

Frontend part:

  • Getting a token from the backend and storing it
  • Getting a token from storage and putting in headers

We have a plan, so let’s start our journey!

Step 1. Creating routes, controllers, services, models

This project was built according to MVC pattern, such a structure was created for logic division.

Routes

import * as userController from '../controllers/user.controller';

Router.post('/login', userController.loginOne);
Router.post('/register', userController.registerOne);
Enter fullscreen mode Exit fullscreen mode

Controller

import { Request, Response } from 'express';
import { getErrorMessage } from '../utils/errors.util';
import * as userServices from '../services/user.service';
import { CustomRequest } from '../middleware/auth';

export const loginOne = async (req: Request, res: Response) => {
 try {
   const foundUser = await userServices.login(req.body);
   res.status(200).send(foundUser);
 } catch (error) {
   return res.status(500).send(getErrorMessage(error));
 }
};

export const registerOne = async (req: Request, res: Response) => {
 try {
   await userServices.register(req.body);
   res.status(200).send('Inserted successfully');
 } catch (error) {
   return res.status(500).send(getErrorMessage(error));
 }
};

Enter fullscreen mode Exit fullscreen mode

Function getErrorMessage from utils folder includes:

export function getErrorMessage(error: unknown) {
 if (error instanceof Error) return error.message;
 return String(error);
}
Enter fullscreen mode Exit fullscreen mode

Service

import { DocumentDefinition } from 'mongoose';
import UserModel, { I_UserDocument } from '../models/user.model';

export async function register(user: DocumentDefinition<I_UserDocument>): Promise<void> {
 try {
   await UserModel.create(user);
 } catch (error) {
   throw error;
 }
}

export async function login(user: DocumentDefinition<I_UserDocument>) {
 try {
   const foundUser = await UserModel.findOne({ name: user.name, password: user.password });
 } catch (error) {
   throw error;
 }
}
Enter fullscreen mode Exit fullscreen mode

Model

import mongoose from 'mongoose';

export interface I_UserDocument extends mongoose.Document {
 name: string;
 password: string;
}

const UserSchema: mongoose.Schema<I_UserDocument> = new mongoose.Schema({
 name: { type: String, unique: true },
 password: { type: String },
});

const UserModel = mongoose.model<I_UserDocument>('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

I didn’t include _id in interface I_UserDocument because he extends mongoose.Document and already includes _id.

Use Postman to check the results.

Step 2: Hashing passwords

Hashing is different from encrypting in that it is a one-way action: we get the password and salt to it and get a line of letters, numbers and symbols.

The crucial difference is that there is no way to get the initial password. So each time that user sets his password this password will be hashed the same way and hashed result will be the same.

Example of hashed password: $2b$08$LSAG/cRp.tSlvTWzp1pwoe50bDWEDjLfK7Psy5ORzf4C.PxJYZeau

While this step we keep in mind 2 aims:

  1. Hash the password right after the signing up
  2. While logging in check if the hashed version of the password is the same as stored in Mongo

Install Bcrypt and its types:

npm i bcrypt @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

Hashing the password while signing up

Here we use the option of schema to use middleware. We check the password and change it bcrypt and its salt.

A plain password is hashed with salt (a random string) that has an unpredictable result. The salt gets automatically included with the hash, so you do not need to store it in a database.

In this case, number 8 means salt rounds, the minimum that is recommended is 8.

Model

import mongoose from 'mongoose';
import bcrypt from 'bcrypt';

const saltRounds = 8

UserSchema.pre('save', async function (next) {
 const user = this;
 if (user.isModified('password')) {
   user.password = await bcrypt.hash(user.password, saltRounds);
 }
 next();
});
Enter fullscreen mode Exit fullscreen mode

As a result of using middleware on the model we hash the password and store it hashed in the database.

Comparing the received password and hashed one

Service

export async function login(user: DocumentDefinition<I_UserDocument>) {
 try {
   const foundUser = await UserModel.findOne({ name: user.name });

   if (!foundUser) {
     throw new Error('Name of user is not correct');
   }

   const isMatch = bcrypt.compareSync(user.password, foundUser.password);

   if (isMatch) {
return foundUser 
   } else {
     throw new Error('Password is not correct');
   }
 } catch (error) {
   throw error;
 }
}
Enter fullscreen mode Exit fullscreen mode

We search user by name and if the user with such name exists in the database we start comparing the received password from the user and the hashed password stored in the database with: bcrypt.compareSync(password-from-user, password-from-database)
If 2 passwords are the same we return the user.

Step 3. Tokens Implementation

Our aims for this step:

  1. Create a token while logging in
  2. Verify token while logging
  3. Send token to the frontend

What is it token?

It is a safe means that includes header, payload and signature.

How a token may look like?
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.vaYmi2wAFIP-RGn6jvfY_MUYwghZd8rZzeDeZ4xiQmk

The first part before the dot is a header that includes the algorithm and token type. The second is a payload that includes all data you want to set into a token and timestamps that indicate token’s expiration time
The third is a signature that you choose by yourself.

You can check your JWT on https://jwt.io/

An example of a decoded token:
Image description

Important! We don’t need to store JWT tokens in the database.

Creating tokens while logging in

Install JWT and its types:

npm i jsonwebtoken @types/jsonwebt
Enter fullscreen mode Exit fullscreen mode

Service

if (isMatch) {
     const token = jwt.sign({ _id: foundUser._id?.toString(), name: foundUser.name }, SECRET_KEY, {
       expiresIn: '2 days',
     });

     return { user: { _id, name }, token: token };
   } else {
     throw new Error('Password is not correct');
   }
Enter fullscreen mode Exit fullscreen mode

In the part with isMatch conditions I created a token and return it with the user.
In the token’s payload I put the user’s id and user’s name and didn’t send also the password. SECRET_KEY is a plain text that also is my personal signature for token that I imported.

Verifying (decoding) tokens

For this we need middleware that happens between controllers and service.
I created file auth.ts in folder middleware.

import jwt, { Secret, JwtPayload } from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

export const SECRET_KEY: Secret = 'your-secret-key-here';

export interface CustomRequest extends Request {
 token: string | JwtPayload;
}

export const auth = async (req: Request, res: Response, next: NextFunction) => {
 try {
   const token = req.header('Authorization')?.replace('Bearer ', '');

   if (!token) {
     throw new Error();
   }

   const decoded = jwt.verify(token, SECRET_KEY);
   (req as CustomRequest).token = decoded;

   next();
 } catch (err) {
   res.status(401).send('Please authenticate');
 }
};
Enter fullscreen mode Exit fullscreen mode

We get a token from a header by deleting “Bearer “ from the string, decode the token and add to the user decoded (signatured) token.

So we come back to controllers to function LoginOne

Controllers

export const loginOne = async (req: Request, res: Response) => {
 try {
   const foundUser = await userServices.login(req.body);
   //console.log('found user', foundUser.token);
   res.status(200).send(foundUser);
 } catch (error) {
   return res.status(500).send(getErrorMessage(error));
 }
};
Enter fullscreen mode Exit fullscreen mode

Now due to the middleware, we get not only the user but also the user.token (with signatured token).

Important!
We import auth and set it on all routes that we want to be authenticated.
2 routes that couldn’t be authenticated they are routes for signing in and signing up.

An example of another roots with required authentication:

Router.get('/all', auth, searchController.getAll);
Router.post('/', auth, searchController.addOne);
Router.delete('/:id', auth, searchController.deleteOne);
Enter fullscreen mode Exit fullscreen mode

We finished with JWT Authentication on the backend so let’s move to the front.

Step 4. Move to front

Our steps on the frontend:

  1. Get a token from the backend
  2. Store a token
  3. Extract token from storage and add it to the header for chosen axios requests (excluding signing up and signing)
  4. Change UI

We won’t go throw all steps in detail, I will give only a general description how it can be implemented.

On the frontend I used React.js and axios package.

Get a token from the backend with axios request - done :)

Storing the token
Options for storing :

  1. Global State (Redux, Context)
  2. Cookies
  3. Local or session storage

Getting the token from storage and puting it in the Header

I stored the token in cookies so I created and imported the function that gets the token from cookies. I_AuthHeader is a custom interface.

export const authHeader = (): I_AuthHeader => {
 const token = getTokenFromCookies();
 return {
   headers: {
     Authorization: "Bearer " + token,
   },
 };
};

Enter fullscreen mode Exit fullscreen mode

An example of adding header

import axios from "axios";
let baseUrl = "http://localhost:8080/";
const ApiHeader = axios.create({
 baseURL: baseUrl,
});
Enter fullscreen mode Exit fullscreen mode
export const getSearchWords = async (): Promise<I_Search[]> => {
 try {
   const { data } = await ApiHeader.get("api/search/all", authHeader());
   return data;
 } catch (error) {
   console.error(error);
   throw error;
 }
};
Enter fullscreen mode Exit fullscreen mode

Enjoy improving UI !

I would love to get your feedback in comments :)

Discussion (0)