Jun 18, 2026 · 10 min read ·

Understanding Stateless JWT Authentication

Have you ever looked at the authentication of the app you were working on and wondered how it was even implemented, or what was actually happening under the hood?

This article is about authentication. Its purpose is to remove the confusion around it. Even experienced developers often have gaps here, since most apps we work on already have auth implemented and we rarely need to touch it. On top of that, many developers today reach for libraries without fully understanding what's happening underneath. But in order to choose the right auth approach for your app and make good security decisions, you need to understand the tradeoffs.

This article is part of a series on authentication. A single article would be too long and too tedious to get through. In the series we will look at different types of auth, and I decided to start with stateless JWT authentication because it is the most common, easier to reason about, and covers all the fundamentals needed to build robust authentication.

The series is aimed at developers who want to go from beginner to mid-level understanding, though even experienced developers may find something useful here.

The Problem

Before stateless JWT authentication, the common solution was sessions. After login or registration, the server would generate a session ID and store it either in a database or in the server's memory (RAM). As long as the session existed, the user was considered authenticated. But both approaches have their drawbacks.

Storing sessions in the database created a bottleneck. Imagine having several servers where every authenticated request triggers an additional database query just to check if the user is logged in, on top of the actual request, like fetching posts. This increases load on your database and requires more powerful and expensive servers to handle the extra traffic. That shared database also becomes a single point of failure. If it goes down, nobody can authenticate across your entire system.

Storing sessions in RAM had even bigger problems if you wanted to scale horizontally. Horizontal scaling means adding more servers to handle more requests simultaneously, as opposed to vertical scaling, which means increasing the CPU and RAM of a single server. Vertical scaling costs more and hits a limit at some point, which is why horizontal scaling is the preferred approach for growing systems. But with RAM-based sessions, if a user logs in and their session is stored on Server A, the next request might hit Server B, which has no idea who they are and will reject the request. On top of that, if a server restarts, all sessions stored in its RAM are wiped and every user on that server gets logged out. RAM is temporary memory and it does not survive a restart.

Stateless

Now let's understand the stateless part. HTTP requests are stateless by nature. Each request has no memory of the previous one, and the server stores nothing between requests. Stateless JWT authentication takes this further: instead of storing sessions in a database or in memory, it stores nothing at all.

But then how does the server recognize if a user is authenticated, if there is nothing to compare against? In the session-based approach, the server compared the incoming session ID against what was stored. JWT takes a completely different approach. Let's look at the big picture to understand how the flow works.

The Big Picture

The image above shows the big picture of stateless JWT authentication, so let's try to understand what it shows.

It begins with the user logging in or registering. When the frontend sends the user's credentials like email and password, the backend creates two JWT tokens: an access token and a refresh token. We will discuss what a JWT token actually is and its structure in the next part of the series, but what you should understand for now is that they look something like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzA4MzU1MTIzfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

We will implement the full authentication system step by step in a later article. The goal of this part is to understand the big picture so that following the next article will be easier.

Both tokens are sent to the client but in different ways. The access token is sent in the response body:

{
  "token": "eyJhbG...",
  "user": { "id": 1, "name": "John Doe" }
}

The refresh token is sent as an httpOnly cookie via the Set-Cookie header:

res.cookie("refreshToken", refreshToken, {
  httpOnly: true,
  path: "/auth/refresh",
  secure: true,
});

An httpOnly cookie means no JavaScript can read it, not even your own app's code. The browser stores it and sends it automatically whenever a request goes to the /auth/refresh endpoint.

Both tokens have an expiration time, after which they become invalid and the user is considered logged out. The key difference is that the access token expires much sooner, typically 15 minutes, while the refresh token can last several days. We will discuss exactly why shortly.

Once the client receives them, the access token should be stored in a JavaScript variable in memory. Many developers store it in localStorage instead, but this is a serious security vulnerability we will cover in a later article.

From this point on, every request that requires authentication sends the access token in the Authorization header:

fetch("/posts", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

What makes JWT powerful here is that when the backend receives this token, it can verify that it is legitimate and not something a hacker crafted or tampered with. How exactly JWT verifies this is the topic of an upcoming article.

Once the access token is verified, the backend knows the user is authenticated and serves the requested response.

Say the user logs in and starts browsing the app. After 15 minutes they want to fetch their posts. The frontend sends the request as usual:

fetch("/posts", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

The server checks the access token, sees that it has expired, and returns a 401. Instead of redirecting the user to the login page, the frontend silently makes a request to /auth/refresh and the browser automatically sends the refresh token cookie to that endpoint. The backend verifies the refresh token, confirms it was originally issued by our system, and issues a new access token in the response body. The frontend saves it in memory and retries the original request, fetching the posts, as if nothing happened. The user never knows their access token expired.

The user keeps using the app without ever needing to log in again. Every time the access token expires, the frontend silently fetches a new one using the refresh token and the user never notices. This can go on for days. But after 7 days, when the refresh token itself expires, the backend returns a 401 on the /auth/refresh request. This time the frontend knows there is nothing left to refresh and redirects the user to the login page.

You probably still have many questions, just as I did when I first tried to understand stateless JWT authentication. That is exactly why I am writing the upcoming articles: to answer those questions and remove the confusion one piece at a time.