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);
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));
}
};
Function getErrorMessage from utils folder includes:
export function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}
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;
}
}
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);
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:
- Hash the password right after the signing up
- 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
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();
});
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;
}
}
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:
- Create a token while logging in
- Verify token while logging
- 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:
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
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');
}
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');
}
};
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));
}
};
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);
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:
- Get a token from the backend
- Store a token
- Extract token from storage and add it to the header for chosen axios requests (excluding signing up and signing)
- 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 :
- Global State (Redux, Context)
- Cookies
- 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,
},
};
};
An example of adding header
import axios from "axios";
let baseUrl = "http://localhost:8080/";
const ApiHeader = axios.create({
baseURL: baseUrl,
});
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;
}
};
Enjoy improving UI !
I would love to get your feedback in comments :)
Top comments (8)
There is no package name
@types/jsonwebt
instead use this@types/jsonwebtoken
Hello, @juliecherner
Your tutorial is excellent and I appreciate your attention to detail!
You also shared many noteworthy security tips, such as storing only one-way hashes of the actual password in the server database, not persisting the decoded JWT token, in addition to taking time to explain the concepts.
Very well done! Thank you for sharing this.
Super useful ! I'm applying these concepts and functions in one of my projects, thanks for taking your time to explain in detail !
Hi Julie,
Thanks for the tutorial.
How can I extract the user_id from the decoded payload and use it in the controller?
Hey, Allen:
If you use a breakpoint during debugging immediately after the line where the constant "decoded" is assigned (in the auth() middleware), you'll see that the fields from the original token (such as "_id") are present in the JSON value for decoded.
Extracting them can be tricky in strict mode TypeScript, but you can look up fields via a string look-up with a function such as the following - where the first parameter you pass in (in this case) is "decoded" and the second is the string version of a field you're interested in (eg. say the string "_id").
same problem for me right now
This is a really good tutorial, thank you!
my account is confused following your way, may I contact you to learn jwt nodejs express typescript well