DEV Community

Cover image for Everything you need to know on JWT Authentication
Abdul Ahad Abeer
Abdul Ahad Abeer

Posted on • Originally published at abeer.hashnode.dev

Everything you need to know on JWT Authentication

Before directly jumping into the topic of this post, lets understand how authentication is usually classified. So, authentication can be classified in two kinds - stateful and stateless.

Stateful Authentication

Stateful Authentication

This picture explains how a normal process of getting logged in and authorized data fetching happens.

You login with username and password. If all the credentials match, it creates a session and stores the session id in the database or memory. Then, the session is sent to the browser and the browser saves it as a cookie. Now you are logged in. Now if you fetch any data the server verifies the session before delivering the asked data.

When the server must store information about each logged-in user, this approach is called stateful authentication.

With stateful authentication, the server keeps a session for every user. So if thousands of users are logged in, the server must maintain thousands of active sessions at the same time. If the server stops storing a user’s session, that user would effectively be logged out.

At smaller scales this is fine, but at large scale it becomes challenging. The server needs extra memory or a shared session store (like Redis) to keep track of all user sessions across multiple servers. Managing all these sessions adds complexity and can create performance bottlenecks when many users are logged in simultaneously.

Problems with Storing Server-Side Sessions

Stateful authentication comes with a set of architectural challenges that grow more apparent as an application scales. Some of the key issues include:

1. Sessions vanish when the server restarts: In traditional session-based systems, session data often lives in memory (RAM). This means a simple server restart, whether due to deployment, a crash, or scaling event, it immediately clears all the active sessions. Users who were previously logged in suddenly get logged out. To avoid this inconvenience, teams must introduce persistent stores like Redis or databases, which adds operational overhead and cost.

2. Sticky-session requirements complicate scaling: When a server stores a user’s session data locally, the load balancer (a system that distributes incoming traffic across multiple servers) must always send that user to the same server - this is called a sticky session. It works, but creates problems: if that server crashes, the user’s session is lost, and if traffic isn’t evenly spread, some servers get overloaded. In modern systems, this limits scalability and flexibility.

3. Managing a large session store becomes a burden: As user count grows, so does the volume of session data. Servers must continuously store, update, expire, and clean up thousands or millions of active sessions. This demands robust storage systems, efficient eviction policies, and careful monitoring.

Stateful Authentication requires in-memory cache which is expensive and risky sometimes because of data wiping out issue. Stateless auth is the solution for this problem.

Stateless Authentication

Stateless Authentication

In this case, you login with email/username and password. If your credentials match, server creates a token called ‘jwt’. This token is digitally signed with a secret cryptography key. Then this token can be saved as a cookie or in local storage. Now at this stage, the logged-in state has been created.

Then whenever a request comes to the server, it verifies the token by the crypto key first and then provides the requested data. Mainly it checks expiry date and if the signature isn’t changed.

Unlike stateful auth, stateless doesn’t require to store the token in the server. So, it becomes game-changer in many cases as there is no need to maintain another server or a big chunk of data. Even in horizontal scaling, we just need to put the token in all the horizontal servers.

Dissect JWT

JSON Web Token is an open standard that defines a compact and self-contained way for securely transmitting information between parties as a JON object.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

any jwt token contains 3 parts - header, payload and signature.

The header contains 2 things - type which is jwt and the algorithm it uses for create the signature.

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Here it says the type of this token is ‘jwt’, the algorithm ‘HS256’ is used to sign and verify the the signature with the key given in the server. There are other algorithms as well.

The second part ‘white’ part contains payload.

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022,
  "exp": 1516259022
}
Enter fullscreen mode Exit fullscreen mode

The payload consists of claims—key-value pairs that convey information about the token or the subject (e.g., a user).

sub, iat and exp are registered claims. But name and admin are private claims that user put in.

How signature is created?

To create the signature part you have to take the encoded header, the encoded payload, a secret, the algorithm specified in the header, and sign that.

In HMAC SHA256 algorithm, the signature will be created by HMACSHA256 function:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
Enter fullscreen mode Exit fullscreen mode

When the signature gets created, it depends on the header and payload. so if someone deliberately changes the payload, eventually it will change the signature.

But there is a catch. if someone steals the whole token and uses in some other machine/browser, then you can get into trouble.

One solution could be setting a minimum expiry duration. But this wont’ be viable in most cases. because most of the time you can’t bother the user login again and again in a very short time. So, in this case → you set the access token’s expiry duration short with a refresh token. This way, the moment access token gets expired and server sends 401 status code, it requests server to create a new access token with the help of refresh token. then the user gets logged-in again. All of these things happen behind the scenes without the user getting to realize.

What if the refresh token gets stolen? In the situation of refresh token getting compromised, developer can programmatically logout the user. This way the refresh token gets removed. This way no one cannot use refresh token to get a new access token. We can get one step ahead and increase the security level by something called ‘Token Rotation’. Token rotation is like when a new access token gets created by the help of refresh token, it generates a new refresh token.

There is something to remember - there is no bullet-proof method for authentication. You can just increase the level of security.

How to create JWT Authentication

Access Token: API sends and receives access token as JSON data. To avoid security risks like XSS or CSRF attack, it is recommended to store the access-token in the memory instead of local storage or cookie.

Essentially if you store the access token somewhere in the browser with javascript, the hacker can retrieve it with javascript. It is recommended to keep it in the memory, so that they automatically be lost when the app is closed. Memory can also be referred as current application state.

Refresh Token: Our API will issue Refresh token as httpOnly cookie. This type of cookie is not accessible with javascript. Refresh tokens do need to have expiration, which will require the user login again. Refresh token should not have the ability to issue a new refresh token, because this grants indefinite access if a refresh token falls into wrong hands.

Code - Project-wise

You can create your own authentication project. Here I am just demonstrating how jwt tokens get integrated and help in this process.

We need to add 2 env variables in .env file. They are access token secret and refresh token secret.

ACCESS_TOKEN_SECRET=9574b4057abe6e85c3dcd986afbf83f1ffe074c22e2cac22446b5cf6f438857596fc4b983f78a713ed191cfac22557d19ce15458e70a235a4fb07c6b8f1c98d7
REFRESH_TOKEN_SECRET=b57f1cf16c2efd417d226d4dc62b2c9eed5b226dc599cd356c0b72e001d53548e98ffd5e81f4818171fce4700d392f78a478f23b4e25a040e29fd616fce70672
Enter fullscreen mode Exit fullscreen mode

Make sure you add .env in the .gitignore file.

Now generate accessToken and refreshToken in the login controller with the help of their respective secrets:

const jwt = require("jsonwebtoken")
require("dotenv").config()

const accessToken = jwt.sign(
      { username: foundUser.username },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: "30s" }
    )
    const refreshToken = jwt.sign(
      { username: foundUser.username },
      process.env.REFRESH_TOKEN_SECRET,
      { expiresIn: "1d" }
    )
Enter fullscreen mode Exit fullscreen mode

After that, save the refresh Token in the respective user’s document and send the access token to the browser as json object. Also send refreshToken to the browser as cookie with httpOnly setting true - this way refreshToken won’t be accessible with javascript:

......
...
const currentUser = { ...otherUsers, currentUser }
    usersDB.setUsers([...otherUsers, currentUser])
    await fsPromises.writeFile(
      path.join(__dirname, "..", "model", "users.json"),
      JSON.stringify(usersDB.users)
    )
    res.cookie("jwt", refreshToken, {
      httpOnly: true,
      maxAge: 24 * 60 * 60 * 1000, // 1 day
    })
    res.json({ accessToken })
Enter fullscreen mode Exit fullscreen mode

Authorization

To authorize every secure api endpoint, we need to verify the jwt token in a middleware:

// middleware/verifyJWT.js
const jwt = require("jsonwebtoken")
require("dotenv").config()

const verifyJWT = (req, res, next) => {
  const authHeader = req.headers["authorization"]
  if (!authHeader) return res.sendStatus(401)
  console.log(authHeader) // Bearer token
  const token = authHeader.split(" ")[1]

  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded) => {
    if (err) return res.sendStatus(403)
    req.user = decoded.username
    next()
  })
}

module.exports = verifyJWT
Enter fullscreen mode Exit fullscreen mode

Basically it collects the token from headers. Then, it verifies the token with the provided secret. Then, we can get decoded data - payload. Now just place the middleware before controllers.

How authorization works from the frontend perspective?

Once the user requests for logging in, it sends back an access token within the response. This access token is supposed to be stored securely. But when it comes to authorization, you pass bearer within headers like the following:

const res = await fetch("https://api.example.com/profile", {
  method: "GET",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${accessToken}`,
  },
});
Enter fullscreen mode Exit fullscreen mode

How to make refreshToken work along?

For that we need to create a new route for refreshing. Let’s first get through the refresh-token-controller - so create a new refresh token controller:

const usersDB = {
  users: require("../model/users.json"),
  setUsers: function (data) {
    this.users = data
  },
}
const jwt = require("jsonwebtoken")
require("dotenv").config()

const handleRefreshToken = (req, res) => {
  const cookies = req.cookies
  if (!cookies?.jwt) return res.sendStatus(401)
  console.log(cookies.jwt)
  const refreshToken = cookies.jwt

  const foundUser = usersDB.users.find(
    (person) => person.refreshToken === refreshToken
  )
  if (!foundUser) return res.sendStatus(403) //Forbidden
  // evaluate jwt
  jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
    if (err || foundUser.username !== decoded.username)
      return res.sendStatus(403)
    const accessToken = jwt.sign(
      { username: decoded.username },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: "30s" }
    )
    res.json({ accessToken })
  })
}

module.exports = { handleRefreshToken }
Enter fullscreen mode Exit fullscreen mode

Can you remember that we set refreshToken as a cookie with httpOnly property? That cookie was set named jwt. So, we need to get the jwt cookie - if it doesn’t exist, that means the user didn’t even log in. So, that makes it return 401 error.

That jwt cookie is the refreshToken for this program. Then, find the user which contains this refreshToken - here named foundUser. The same way to handle foundUser error.

At this point this refreshToken needs to be verified with the set REFRESH_TOKEN_SECRET on the server’s env variables. If it’s found verified, then create a new access token with the ACCESS_TOKEN_SECRET. The same way it will be sent to the frontend code as json data.

Then this controller will be assigned to /refresh endpoint in the main file along with other endpoints.

Note: ✅ httpOnly and secure are NOT alternatives

They are two separate flags, and both can (and SHOULD) be used together in production.

httpOnly: true: Prevents JavaScript access.

  • Prevents JavaScript from reading the cookie. This protects against XSS attacks.

  • Works on both HTTP and HTTPS

  • Does not depend on environment (dev or prod)

secure: true: Restricts cookie sending to HTTPS only.

  • Only sends the cookie over HTTPS.

  • This protects against network sniffing / MITM attacks.

Now the logout controller should look like this:

const usersDB = {
  users: require("../model/users.json"),
  setUsers: function (data) {
    this.users = data
  },
}
const fsPromises = require("fs").promises
const path = require("path")

const handleLogout = async (req, res) => {
  // On client, also delete the accessToken

  const cookies = req.cookies
  if (!cookies?.jwt) return res.sendStatus(204)
  const refreshToken = cookies.jwt

  // Is refreshToken in db?
  const foundUser = usersDB.users.find(
    (person) => person.refreshToken === refreshToken
  )
  if (!foundUser) {
    res.clearCookie("jwt", { httpOnly: true })
    return res.sendStatus(404)
  }

  // Delete refreshToken in db
  const otherUsers = usersDB.users.filter(
    (person) => person.refreshToken !== foundUser.refreshToken
  )
  const currentUser = { ...foundUser, refreshToken: "" }
  usersDB.setUsers([...otherUsers, currentUser])
  await fsPromises.writeFile(
    path.join(__dirname, "..", "model", "user.json"),
    JSON.stringify(usersDB.users)
  )

  res.clearCookie("jwt", { httpOnly: true }) // secure: true - only serves on https
  res.sendStatus(204)
}

module.exports = { handleLogout }
Enter fullscreen mode Exit fullscreen mode

How would the frontend (React) send refresh request behind the scene?

Credentials need to be enabled to get cookies in the response.

export const axiosPrivate = axios.create({
  baseURL: BASE_URL,
  headers: { "Content-Type": "application/json" },
  withCredentials: true,
})
Enter fullscreen mode Exit fullscreen mode

Now we need to add interceptors with this one — axiosPrivate.

So, the better is to create a custom hook where it will have interceptors added with it (the explanation is below):

import { axiosPrivate } from "../api/axios"
import { useEffect } from "react"
import useRefreshToken from "./useRefreshToken"
import useAuth from "./useAuth"

const useAxiosPrivate = () => {
  const refresh = useRefreshToken()
  const { auth } = useAuth()

  useEffect(() => {
    const requestIntercept = axiosPrivate.interceptors.request.use(
      (config) => {
        if (!config.headers["Authorization"]) {
          config.headers["Authorization"] = `Bearer ${auth?.accessToken}`
        }
        return config
      },
      (error) => Promise.reject(error)
    )

    const responseIntercept = axiosPrivate.interceptors.response.use(
      (response) => response,
      async (error) => {
        const prevRequest = error?.config
        if (error?.response?.status === 403 && !prevRequest.sent) {
          prevRequest.sent = true
          const newAccessToken = await refresh()
          prevRequest.headers["Authorization"] = `Bearer ${newAccessToken}`
          return axiosPrivate(prevRequest)
        }
        return Promise.reject(error)
      }
    )

    return () => {
      axiosPrivate.interceptors.request.eject(requestIntercept)
      axiosPrivate.interceptors.response.eject(responseIntercept)
    }
  }, [auth, refresh])

  return axiosPrivate
}

export default useAxiosPrivate
Enter fullscreen mode Exit fullscreen mode
  • axiosPrivate is in a separate module. So, importing it here in the custom hook.

  • refresh is just a function that refreshes the accessToken by /refresh endpoint of the backend. useRefreshToken hook’s code will be below.

  • In useEffect the first thing to do is adding request interceptor. In this interceptor Bearer is being integrated in the headers for authorization. This one has been assigned to requestIntercept.

  • In the second part of useEffect response response interceptor is being integrated. When any request fails, the response interceptor’s error handling callback runs.

  • The refresh function gets called when a 403 error happens. This refresh function returns the accessToken, what we include in the request header and send another request with that accessToken. This request is done by the axiosPrivate.

  • This process could lead to a loop. To get rid of looping, we add sent property and set its value to true, so that it gets checked while checking the error status.

  • In the end when you get out of the component, the interceptors get removed as it’s specified by eject for both of them.

// useRefreshToken
import axios from "../api/axios"
import useAuth from "./useAuth"

export default function useRefreshToken() {
  const { setAuth } = useAuth()

  const refresh = async () => {
    const response = await axios.get("/refresh", {
      withCredentials: true,
    })
    setAuth((prev) => {
      console.log(JSON.stringify(prev))
      console.log(response.data.accessToken)
      return { ...prev, accessToken: response.data.accessToken }
    })
    return response.data.accessToken
  }
  return refresh
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)