Repository created in this article: jwt-tutorial
When I started to develop backend projects, I was very concerned about the security of my endpoints and a question came to my mind: if anyone can make requests to my backend application, how do I protect my Rest API against malicious people? I spent months searching about it and I would like to share what I found.
The problem
To demonstrate the problem of not securing a sensitive endpoint, let's look at the code below:
router.get("/users", async (req, res) => {
try {
let users = await prisma.user.findMany();
res.status(200).json({ data: users });
} catch (error) {
next(error);
}
});
router.delete("/users/:id", async (req, res) => {
try {
const { id } = req.params;
user = await prisma.user.delete({
where: { id: Number(id) }
});
res.status(200).json({
message: "Successfully Deleted",
data: user
});
} catch (error) {
next(error);
}
});
In these code blocks, we have a couple of endpoints. One to get all the users from the database, and the other to remove any user by their ID.
These endpoints can be a good combination for an admin to manage their users. However, it can also be a huge problem if an average user knows about them. Because the same way an admin can remove a specific user, an average user can remove anyone they want. And that's why we need to double the attention on codes like this.
What an average user should see as a response:
❌ -> [200] All users deleted
✔️ -> [401] You are not authorized to perform this action
The Solution
When I was taking my first steps into security, my first suggestion was to change the method of the request from GET (or DELETE) to POST, ask the user email in the body of the request, and check user permission on the database.
As you can see, this solution worked as expected, but it still has a big concern. The idea of using user email and checking their permission will not prevent malicious people from using an admin email, which can be easy to get with social engineering, and use programs like Insominia or Postman to get the same response as an unprotected endpoint.
So, what can we do?
The previous solution was going the right way to solve this security problem. The issue with that solution is that we used public information (email) as our “token”.
We need a unique token that dynamically changes according to the user, where no one could decorate it like an email. And this is where JWT joins the party. 🎉
How does JWT work?
JWT, or JSON Web Token, is an open standard used to share information between two parties securely — a client and a server. JWT will encode and & sign our JSON data (payload), ensuring the authenticity of the data sent to the backend.
Before showing some examples, let me explain about JWT string structure.
Structure of JWT
A JWT is a string made up of three parts, separating header
, payload
, and signature
by dots (.), and serialized using base64.
Examples
All we need to encode our data is our JSON payload and a secret password. So, let's use the information below:
Payload:
{
"email": "adminuser@gmail.com",
"userID": "58f904a2-7240-4548-abe2-19c87d5e5201"
}
Secret Key:
JWT_TUTORIAL_TEST
Encoding with JWT using the default header value, we get this token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkbWludXNlckBnbWFpbC5jb20iLCJ1c2VySUQiOiI1OGY5MDRhMi03MjQwLTQ1NDgtYWJlMi0xOWM4N2Q1ZTUyMDEifQ.YEtbgL8EzCdi2qbFgHN3GDxCZbwMFFubwPZbJc6j0go
Decoding with JWT using JWT_TUTORIAL_TEST
secret key, we get this JSON:
{
"email": "adminuser@gmail.com",
"userID": "58f904a2-7240-4548-abe2-19c87d5e5201"
}
But, how can we use this token in our backend?
As we saw before introducing JWT, we need a unique token capable to distinguish one user from another. With JSON Web Token, we can send an encoded email as a token to the backend and decode it with our secret key stored in the environment variables of our backend application. Preventing people to manipulate the data of the request like my first solution.
Step by step
The Authentication Flow is easy:
Note: We never encode or decode JWT Tokens in the frontend
When the frontend makes a request to the login endpoint in our backend, besides checking email + password in the database like a normal login endpoint, the backend will be also responsible to generate a JWT Token (with user email or user ID as the JSON payload as shown in Structure of JWT section). The token will be sent as the response of the login endpoint.
/api/auth/login
response example:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkbWludXNlckBnbWFpbC5jb20iLCJ1c2VySUQiOiI1OGY5MDRhMi03MjQwLTQ1NDgtYWJlMi0xOWM4N2Q1ZTUyMDEifQ.YEtbgL8EzCdi2qbFgHN3GDxCZbwMFFubwPZbJc6j0go",
"tokenType": "Bearer"
}
Once getting the JWT Token, we have to store them in the frontend using cookies to use in future requests to sensitive endpoints.
When making a request to a sensitive endpoint, we use the JWT Token stored in the frontend as the Authorization Header (Bearer Token) of the request.
In the sensitive endpoint, the backend will decode the JWT Token using the secret key (stored in the environment variables of the backend application) and check user permission in the database using the email or user ID of the decoded JWT Token. The endpoint will send
401 Unauthorized
if the user doesn't have enough permission, or200 OK
if the user has enough permission.
With this four steps, you can easily implement JWT security in any backend framework! 🥳
Let's implement JWT in Node.js Rest API
I won't delve into how to make a Rest API from zero, if you don't know how to make one or don't know about Rest API concepts like middlewares, feel free to search online while following the tutorial.
So, this is my file structure:
> node_modules
> prisma
> src
> controllers
auth.controller.js
user.controller.js
> middleware
> provider
> routes
api.route.js
app.js
.env
.gitignore
package.json
yarn.lock
For this tutorial, I will be using Express.js + Bcrypt + Prisma to create a simple SQLite database to manage the users.
This is my prisma schema:
model User {
id Int @id @default(autoincrement())
userId String @unique
name String
username String @unique
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
permissions String @default("USER")
}
Before implementing JWT, I created the login endpoint function below and two other endpoints, one to add a new user and another to list all users in the database:
// ./src/controllers/auth.controller.js
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
const bcrypt = require("bcrypt");
// This function will check the user email and password in the database and will return status 200 if find a user.
exports.login = async function (req, res, next) {
try {
// Return code 401 if miss parameters
const { email, password } = req.body;
if (!email || !password) {
return res.status(401).json(
{ message: "Missing parameters"}
);
}
// Return code 401 if not found a valid email or password
let user = await prisma.user.findUnique({ where: { email }});
const isValid = await bcrypt.compare(password, user.password);
if (!isValid || !user) {
return res.status(401).json(
{ message: "Invalid email or password" }
);
}
// Return 200 if email and password match with database info
res.status(200).json({
status: "OK",
});
} catch (error) {
next(error);
}
}
These is all the routes of my API:
// ./src/routes/api.route.js
const router = require("express").Router();
const UserController = require("../controllers/user.controller");
const AuthController = require("../controllers/auth.controller");
//! User routes
router.get(
"/AllUsers",
UserController.getAllUsers
);
router.post("/users", UserController.addUser);
//! Auth routes
router.post("/login", AuthController.login);
module.exports = router;
As we saw in the Step by Step section, our JWT Token needs to be the response of our login endpoint. So, before making the Token Provider file, we need to install JSON Web Token in our project:
npm install jsonwebtoken
or if you use yarn:
yarn add jsonwebtoken
With JWT installed, we are going to create a new JavaScript file inside provider folder called generateToken.provider.js
> provider
generateToken.provider.js
The main idea of this file is to create a class to sign a JSON with userID using the sign
function from the jsonwebtoken
library. In the end, we will have something like this:
// ./src/provider/generateToken.provider.js
var jwt = require("jsonwebtoken");
// * This class is responsible for creating and returning a new encoded and signed access token with user userID.
class GenerateToken {
async execute(userId) {
const token = jwt.sign(
{ userId: userId },
`${process.env.JWT_SECRET}`,
});
return token;
}
}
module.exports = GenerateToken;
The next step is to implement this class in our login endpoint to generate the token after checking if the email and password are valid:
// ./src/controllers/auth.controller.js
// Importing GenerateToken module in our auth controller
const GenerateToken = require("../provider/GenerateTokenProvider");
// ./src/controllers/auth.controller.js
...
// Generate token and return as response if login is valid
const generateToken = new GenerateToken();
const token = await generateToken.execute(user.userId);
res.status(200).json({
status: "OK",
data: { AccessToken: token },
});
Using the endpoint I created to add an account easily and making a request to the login endpoint using Insominia, we get:
As you can see, each user will have their token right now. This token should be stored in an httpOnly cookie
to be better in terms of exposure to XSS attacks.
The final step is to create a middleware inside the middleware folder to verify user tokens and allow them to get the response of the sensitive endpoints:
> middleware
ensureAuthenticated.middleware.js
Before start editing this file, we need to understand how the backend recives our token:
The type of JWT Token is the Bearer Token
, which means that when we send a request with this token in the Authorization Header, the backend will get a string like this:
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkbWludXNlckBnbWFpbC5jb20iLCJ1c2VySUQiOiI1OGY5MDRhMi03MjQwLTQ1NDgtYWJlMi0xOWM4N2Q1ZTUyMDEifQ.YEtbgL8EzCdi2qbFgHN3GDxCZbwMFFubwPZbJc6j0go
As we only need the token and not "Bearer ", our first step is to split this string:
// ./src/middleware/ensureAuthenticated.middleware.js
const { verify } = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
exports.ensureAuthenticated = async function (req, res, next) {
// Get the token from the Authorization header
const authToken = req.headers.authorization;
// Return code 401 if no token is provided
if (!authToken) {
return res.status(401).json({ message: "Unauthorized" });
}
// Slit the token to remove the "Bearer " part
const token = authToken.split(" ")[1];
Now, all we have to do is to use the verify
function from the jsonwebtoken
library and get an object with the user userId:
// ./src/middleware/ensureAuthenticated.middleware.js
...
const token = authToken.split(" ")[1];
// Verify the token and check if the user exists. Any error will return code 401
try {
// the user const contains a object with the userId
const user = verify(token, process.env.JWT_SECRET);
// Check if a user with this userId exists in the database.
const isUserValid = await prisma.user.findUnique({
where: { userId: user.userId },
});
if (!isUserValid) {
return res.status(401).json({ message: "This Token is Invalid" });
}
next();
} catch (error) {
return res.status(401).json({ message: "This Token is Invalid" });
}
Before starting making some tests, we have to implement our middleware in our sensitive endpoints. I'll be using a simple endpoint that lists all users in the database as an example:
const {
ensureAuthenticated,
} = require("../middleware/ensureAuthenticated.middleware");
//! User routes
router.get(
"/AllUsers",
ensureAuthenticated,
UserController.getAllUsers
);
The full file will loke like this:
// ./src/routes/api.route.js
const router = require("express").Router();
const UserController = require("../controllers/user.controller");
const AuthController = require("../controllers/auth.controller");
const {
ensureAuthenticated,
} = require("../middleware/ensureAuthenticated.middleware");
//! User routes
router.get(
"/allUsers",
ensureAuthenticated,
UserController.getAllUsers
);
router.post("/users", UserController.addUser);
//! Auth routes
router.post("/login", AuthController.login);
module.exports = router;
Now, if we try to make a request to /allUsers
endpoint without a token, we will get:
But adding our token in the Insominia Authorization Header and making a request to /allUsers
, we get:
And finally, you have a secure REST API with JWT Authentication 🥳🥳🎉
Remember to see the GitHub repo to see all code. Github repo link
What's next?
JWT Access Token Authentication is only a grain of sand compared with the infinity of other security methods you can implement in your backend code. If you're interested in continuing to studying about security, you must see refresh tokens and how to protect tokens, this will further complement the lessons learned in this article.
Ending
So, this is my first article and it took me three long days to write. If you have a question, feel free to send me a DM on Twitter or talk with me on Discord (Jão#6140). I hope to have time in the future to continue writing to you all! See you in the next article! 👋🏾
Top comments (1)
Great explanation @joaolima .