DEV Community

Cover image for How to authenticate and protect REST API routes with JWT and refresh tokens
renzhamin
renzhamin

Posted on • Edited on • Originally published at blog.renzhamin.com

How to authenticate and protect REST API routes with JWT and refresh tokens

Overview

After reading this article, you will able to

  1. Authenticate users with their username/email and password
  2. Understand the uses of access token and refresh token
  3. Protect api endpoints from unauthorized clients by validating access token
  4. Allow multi-logins with ability to log out all sessions
  5. Use this as a template to kickstart your next rest api with signup,signin and protected routes

The code is available on github

I suggest to read the full article first and then start coding

The source code structure is as follows

.
├── controllers
│   ├── auth
│   │   ├── login.ts
│   │   ├── logout.ts
│   │   ├── refreshAccessToken.ts
│   │   └── register.js
│   └── users.ts
├── db # functions to make calls to the database
│   ├── connect.ts
│   ├── tokens.ts
│   └── users.ts
├── index.ts
├── middlewares
│   ├── validateRegistrationData.ts
│   └── verifyTokens.ts
├── routes
│   ├── auth.ts
│   ├── index.ts
│   └── users.ts
└── utils
    ├── genToken.ts
    ├── hashString.ts
    └── verifyToken.ts
Enter fullscreen mode Exit fullscreen mode

Table of Contents

Database Schema

  • This project uses prisma ORM which will give you typescript support with excellent auto completion experience
  • The database is using sqlite so you don't have to setup any database in your system
  • You can use any other SQL databases if you have them setup, in that case everything will be the same except the connection url and provider variable in prisma/schema.prisma
  • If you want to use MongoDB, you can read a short guide here on how to set it up for prisma

prisma/schema.prisma:

model User {
    id            String         @id @default(uuid())
    email         String         @unique
    username      String         @unique
    password      String
    refreshTokens RefreshToken[]
}

model RefreshToken {
    id          String   @id
    hashedToken String
    user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
    userId      String
    createdAt   DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode
  • User and RefreshToken has one to many relationship
  • One user can have multiple refreshToken so that logins from multiple devices can be persisted
  • Run npx prisma generate to generate prisma client code. Then run npx prisma migrate dev to create necessary tables according to the schema
  • Tip : You can use npx prisma studio to interect with the database in a Web GUI

Overview of auth routes

routes/auth.ts:

const router = express.Router();

router.post("/auth/login", login);
router.post("/auth/register", validateRegistrationData, register);
router.get("/auth/refresh", verifyRefreshToken, refreshAccessToken);
router.delete("/auth/logout", logout);
router.delete("/auth/logout_all", logout_all);

export { router as authRouter };
Enter fullscreen mode Exit fullscreen mode

All routes are prefixed with "/api"

Registration

Route : router.post("/auth/register", validateRegistrationData, register)

  • The request body should contain username, email and password
  • We will create a middleware to validate the registration data

middlewares/validateRegistrationData.ts:

export const validateRegistrationData = async (req, res, next) => {
  const { email, username, password } = req.body;

  if (!password) return res.status(400).json({ error: "No password provided" });

  if (!username) return res.status(400).json({ error: "No username provided" });

  if (!email) return res.status(400).json({ error: "No email provided" });

  let user = await findUserByUsernameOrEmail(username, email);
  if (user) {
    let error = "Email already exits";
    if (user.email !== email) error = "Username already exits";
    return res.json({ error });
  }

  req.user = {
    email,
    username,
    password,
  };

  next();
};
Enter fullscreen mode Exit fullscreen mode
  • If an account with the same username or email already exists then return
  • Else attach the user object to req and go to the next function

controllers/auth/register.ts:

export const register = async (req, res) => {
  const user = await createUser(req.user);
  if (!user) return res.json({ error: "Registration Failed" });

  const data = {
    username: user.username,
    email: user.email,
  };

  res.json({ data });
};
Enter fullscreen mode Exit fullscreen mode
  • Hash the password before storing to the database
const createUser = async (user: any) => {
  user.password = await hashString(user.password);

  return db.user.create({
    data: user,
  });
};
Enter fullscreen mode Exit fullscreen mode

Log In

Route : router.post("/auth/login", login)

Sequence Diagram of LogIn

  • The request body should contain { username, password }
  • Here username field can contain email also, providing the option to log in with both username and email
  • Return an error if user doesn't exist or password is incorrect
  • Create accessToken and refreshToken
  • Send the refreshToken to be saved as a httpOnly cookie with 30 days validity
  • Send the accessToken

controllers/auth/login.ts:

export const login = async (req, res) => {
  try {
    if (!req.body.username)
      return res.status(400).json({ error: "No Username provided" });
    if (!req.body.password)
      return res.status(400).json({ error: "No Password provided" });

    const { username, password } = req.body;

    // User can log in with username or email
    const user = await findUserByUsernameOrEmail(username, username);

    if (!user) return res.status(404).json({ error: "User Not Found" });

    const match = await bcrypt.compare(password, user.password);

    if (!match) return res.status(401).json({ error: "Wrong Password" });

    const accessToken = genAccessToken(user);
    const tokenId = randomUUID();
    const refreshToken = genRefreshToken(user, tokenId);

    res.cookie("refreshToken", refreshToken, {
      httpOnly: true,
      maxAge: 24 * 60 * 60 * 30 * 1000, // 30 days
    });

    // add the token to the database
    addRefreshToken(tokenId, user.id, refreshToken);

    return res.json({ accessToken });
  } catch (error) {
    return res.status(500).json({ error: "Internal Error" });
  }
};
Enter fullscreen mode Exit fullscreen mode
  • refreshToken is sensitive data, hence you shouldn't store it in plain text
  • You could either hash it or encrypt it
const addRefreshToken = async (
  id: string,
  userId: number,
  refreshToken: string
) => {
  const hashedToken = await hashString(refreshToken);
  return db.refreshToken.create({
    data: {
      id,
      userId,
      hashedToken,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

Refresh Access Token

Route : router.get("/auth/refresh", verifyRefreshToken, refreshAccessToken)

Sequence Diagram of refreshing accessToken

  • If the token is expired or tampered with then the verification will fail
  • If the verification passes but the token doesn't exist in the db, then you can suspect that someone is trying to use an old token that might be stolen so you return Unauthorized
  • Otherwise, delete the refreshToken that was in the cookie of the request, create new token, save it to the database and send set it as a httpOnly cookie, this practice is called refresh token rotation
  • Send the accessToken
export const refreshAccessToken = async (req, res) => {
  const user = req.user;
  const newTokenId = randomUUID();
  const newRefreshToken = genRefreshToken(user, newTokenId);

  res.cookie("refreshToken", newRefreshToken, {
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000 * 30,
  });

  // refresh token rotation
  deleteRefreshTokenById(user.jwtid);
  addRefreshToken(newTokenId, user.id, newRefreshToken);
  //

  const accessToken = genAccessToken(user);
  return res.json({ accessToken });
};
Enter fullscreen mode Exit fullscreen mode

Access protected resource

Sequence Diagram of accessing protected routes

As an example, /users can be used as a protected endpoint

Route : router.use("/users", verifyAccessToken, listUsers)

middlewares/verifyAccessToken.ts:

export const verifyAccessToken = (req, res, next) => {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1];
  if (token == null) return res.sendStatus(401);
  const user = tokenVerifier.validateAccessToken(token);
  if (user.tokenError)
    return res.status(401).json({
      error: "Invalid Access token",
      tokenError: user.tokenError,
    });

  req.user = user;
  return next();
};
Enter fullscreen mode Exit fullscreen mode
  • The client has to send the token in the authorization header following the format Bearer $token
  • If the token is not valid, the error will be returned (such as TokenExpiredError or JsonWebTokenError if the token is modified)
  • Otherwise the server will query the database and send the list of users to the client

Log Out

Route : router.delete("/auth/logout", logout)

Log out current session

export const logout = async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: "No Refresh Token" });

  // does not check if it exists in the db
  const user = tokenVerifier.verifyRefreshToken(refreshToken);

  if (user.tokenError)
    return res.status(401).json({
      error: "Invalid Refresh token",
      tokenError: user.tokenError,
    });

  res.clearCookie("refreshToken");
  deleteRefreshTokenById(user.jwtid);
  return res.sendStatus(200);
};
Enter fullscreen mode Exit fullscreen mode

Log out from All devices

Route : router.delete("/auth/logout", logout_all)

export const logout_all = async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: "No Refresh Token" });

  res.clearCookie("refreshToken");

  // does not check if it exists in the db
  const user = tokenVerifier.verifyRefreshToken(refreshToken);

  if (user.tokenError)
    return res.status(401).json({
      error: "Invalid Refresh token",
      tokenError: user.tokenError,
    });

  // delete all tokens associated with this user
  deleteAllRefreshTokens(user.id);
  return res.sendStatus(200);
};
Enter fullscreen mode Exit fullscreen mode

Notice 2 things,

  1. There's no check to see if the token exists in db
  2. Access token verification is skipped

Lets consider a scenario where the client's cookie is hijacked, so attacker has the refreshToken

  • Now he is going to use that refreshToken to get new accessToken
  • Which will invalidate the refreshToken of the client from which it was hijacked
  • That client may be the legitimate user
  • And now that client can't get any new accessToken
  • So if accesToken verification or the check to see if token exists was in db was present then that client wouldn't be able to logout

Testing the api

You can use curl to perform all the requests
All the commands listed below is written on tests/api-test-curl.sh

If you use Insomnia, which is an awesome open source api testing tool, you can import all the requests from tests/api-test-insomnia.json

Register

curl --request POST \
  --url http://localhost:5000/api/auth/register \
  --header 'Content-Type: application/json' \
  --data '{
    "username" : "gr523",
    "email" : "gr523@gmail.com",
    "password" : "Pass82G9"
}'
Enter fullscreen mode Exit fullscreen mode

Login

curl --request POST \
  --url http://localhost:5000/api/auth/login \
  --header 'Content-Type: application/json' \
  --cookie-jar "cookie.txt" \
  --data '{
    "username" : "gr523",
    "password" : "Pass82G9"
}'
Enter fullscreen mode Exit fullscreen mode

Output: {"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo"}

  • The cookie will be saved in cookie.txt
  • Copy the value of accessToken to your clipboard

Accessing protected endpoint

  • Paste the accessToken after Bearer
curl --request GET \
  --url http://localhost:5000/api/api/users \
  --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo'
Enter fullscreen mode Exit fullscreen mode

Output: {"users":[{"id":"3ccd6f3a-d132-443f-95c4-4f02cbe7d4e1","username":"gr523","email":"gr523@gmail.com"}]}

  • Modify the value of the accessToken
curl --request GET \
  --url http://localhost:5000/api/api/users \
  --header 'Authorization: Bearer xxxxxxxxxxxxxxxxNiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo'
Enter fullscreen mode Exit fullscreen mode

Output {"error":"Invalid Access token","tokenError":"JsonWebTokenError"}

The validity duration of the accessToken is set to 5 minutes, after that you can't use that token to access protected resources

The response will be {"error":"Invalid Access token","tokenError":"TokenExpiredError"}

You can change the validity duration in utils/genToken.ts

Refresh Access Token

  • Use the value of refreshToken from cookie.txt
curl --request GET \
  --url http://localhost:5000/api/auth/refresh \
  --cookie refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqd3RpZCI6IjQwY2VkYmRjLWM2NmQtNGJlYy1hNjc4LTg0MWJkZDhlMTBkMyIsImlkIjoiM2NjZDZmM2EtZDEzMi00NDNmLTk1YzQtNGYwMmNiZTdkNGUxIiwidXNlcm5hbWUiOiJncjUyMyIsImVtYWlsIjoiZ3I1MjNAZ21haWwuY29tIiwiaWF0IjoxNjgxOTI4MzgwLCJleHAiOjE2ODQ1MjAzODB9.WDk-YbqxX7_yCr8ATbDxbCV-W6EUNzxZPchPaHnuZAI
Enter fullscreen mode Exit fullscreen mode

Or in linux you can use sed,

curl --request GET \
  --url http://localhost:5000/api/auth/refresh \
  --cookie refreshToken="$(sed -En '/refreshToken/s/.*refreshToken\s*(.*)/\1/p' cookie.txt)"
Enter fullscreen mode Exit fullscreen mode

If you request refresh with an old value of refreshToken, the respone will be {"error":"Invalid Refresh Token","tokenError":"OldToken"}

If you have read up to this point, you are ready to jump into your next rest api project. Would appreciate any feedback. For the sake of completeness, I have written another guide to implement password reset using jwt. Do you like this style of long form tutorial ? Any suggestions to what could be changed to make it better ?

Find me on
💻 Github
🔘 LinkedIn
Twitter

Top comments (10)

Collapse
 
cryxto profile image
Yohanes Bagas Ari Widatama • Edited

if you encounter problem in refresh access token where the cookie not rewrite the existing refreshToken using curl, use this :

curl -b cookie.txt -c cookie.txt --request GET \
--url http://localhost:5000/api/auth/refresh \
--cookie refreshToken="$(sed -En '/refreshToken/s/.*refreshToken\s*(.*)/\1/p' cookie.txt)"

In this command, -b cookie.txt specifies that curl should read cookies from the cookie.txt file, and -c cookie.txt specifies that curl should write cookies to the same cookie.txt file.

By placing the -b and -c options before the --url option, you ensure that curl reads the cookies from the file, sends them with the request, and writes the updated cookies back to the file after the request is completed.

Collapse
 
cryxto profile image
Yohanes Bagas Ari Widatama

by the way, thx alot for this article

 
renzhamin profile image
renzhamin

Thanks for clarifying
I leave options to defaults whenever it comes to something that has to do with security. The defaults most times provide good options for general use but I can see why this case is an exception, it should be explicit

Collapse
 
artydev profile image
artydev

Great,
Thank you :-)

 
renzhamin profile image
renzhamin

The protected route /users go through the verifyAccessToken middleware which verifies the token before giving the list of users, and return Unauthorized if the token wasn't valid
I am not sure what you mean by "avoid that being a user's decision". You mean the client making the request right ? In that case theres no way to bypass the verification if thats your concern

Collapse
 
aratinau profile image
Aymeric Ratinaud

"refersh" token ? 🤓

Collapse
 
renzhamin profile image
renzhamin

oops, thanks for pointing out

Collapse
 
aratinau profile image
Aymeric Ratinaud

😉

Collapse
 
renzhamin profile image
renzhamin • Edited

I used the function tokenVerifier.validateAccessToken defined in utils/verifyToken.ts which uses jwt.verify().

Collapse
 
Sloan, the sloth mascot
Comment deleted