React Authentication With JWT, Zustand, and Axios | JavaScript Tools Blog<br>Skip to content<br>Language EN RU ES FR DE IT PT 中文 日本語 AR
BLOG INDEX<br>HTTP does not remember you.
You can log in successfully, open another page one second later, and the next HTTP request still arrives at the server as a new request.
That sounds strange at first because websites clearly do remember logged-in users.
The missing part is authentication state.
A browser usually sends some kind of credential with later requests. That might be a session cookie, an access token, or another authentication mechanism. The server uses that information to work out who made the request.
For this article, we will build the token-based version:
Login<br>Server verifies credentials<br>Server signs JWT<br>React stores authentication state<br>Axios sends the token<br>Server verifies the token<br>Protected data<br>The stack will be:
React<br>React Router<br>Zustand<br>Axios<br>JWT<br>Vite<br>The original lesson follows the same overall architecture, including persistent auth state in Zustand, an Axios interceptor, a protected route, and mock JWT endpoints.
Let’s rebuild it in a cleaner form.
QUICK ANSWER<br>React does not keep a user logged in by itself. The usual token-based flow is a chain: the server signs a JWT, the client stores enough auth state to render the UI, Axios sends the token with protected requests, and the backend verifies the token again before returning data.
Why Login State Exists If HTTP Is Stateless
HTTP is stateless in the sense that one request does not automatically carry knowledge about an earlier one.
Suppose we send:
POST /api/login<br>and then later:
GET /api/profile<br>The second request does not magically know that the first request authenticated a user.
Something has to connect them.
With token authentication, that something is usually the Authorization header:
Authorization: Bearer eyJhbGciOi...<br>So the real flow looks like this:
Request 1<br>POST /login<br>username + password<br>server verifies user<br>JWT returned
Request 2<br>GET /profile<br>Authorization: Bearer<br>server verifies JWT<br>profile returned<br>HTTP itself still remembers nothing.
The client simply proves its identity again with every protected request.
Session Authentication vs JWT
JWT is not the only way to do this.
A traditional session flow looks like:
Browser<br>↓ cookie with session ID<br>Server<br>Session store<br>The browser sends a cookie, and the server looks up the matching session.
A JWT flow can look like:
Browser<br>↓ token<br>Server<br>↓ verify signature<br>User identity<br>One important correction is worth making here.
JWT payloads are generally not encrypted .
A normal signed JWT can be decoded by anyone who has it. The signature prevents an attacker from modifying the payload without detection.
So you should never put secrets into the payload:
// Bad idea<br>password: "super-secret"<br>Basic identity information is more reasonable:
sub: "user_123",<br>role: "user"<br>Think of a signed JWT as a tamper-evident credential rather than a secret container.
SESSION FLOW<br>Server remembers the session<br>+The browser sends a cookie with a session ID.<br>+The server looks up that session in storage.<br>+Revocation can happen by deleting the server-side session.<br>+Works well for many traditional web applications.
JWT FLOW<br>Server verifies a signed credential<br>+The browser sends a bearer token with protected requests.<br>+The server verifies the JWT signature and claims.<br>+Short expiry and refresh strategy matter a lot.<br>+Useful for APIs, SPAs, and distributed services when designed carefully.
Our Small Authentication Flow
We will use these files:
src/<br>├── api/<br>│ ├── client.ts<br>│ └── auth.ts<br>├── components/<br>│ └── RequireAuth.tsx<br>├── pages/<br>│ ├── Home.tsx<br>│ ├── Login.tsx<br>│ └── Account.tsx<br>├── store/<br>│ └── auth.ts<br>└── App.tsx<br>For a real application, JWT signing belongs on the backend.
To keep this article focused, we will first look at the frontend and then add a tiny mock server example.
Step 1: Create the Zustand Auth Store
The application needs one shared place for authentication state.
We care about two things:
token<br>user<br>A small Zustand store is enough:
import { create } from "zustand";
type User = {<br>id: string;<br>username: string;<br>};
type AuthState = {<br>token: string | null;<br>user: User | null;<br>login: (token: string, user: User) => void;<br>logout: () => void;<br>};
export const useAuthStore = createAuthState>((set) => ({<br>token: localStorage.getItem("token"),<br>user: JSON.parse(localStorage.getItem("user") ?? "null"),
login: (token, user) => {<br>localStorage.setItem("token", token);<br>localStorage.setItem("user", JSON.stringify(user));
set({<br>token,<br>user,<br>});<br>},
logout: () => {<br>localStorage.removeItem("token");<br>localStorage.removeItem("user");
set({<br>token: null,<br>user: null,<br>});<br>},<br>}));<br>There are two separate jobs here.
First, Zustand keeps authentication state available to React:
set({<br>token,<br>user,<br>});<br>Second, localStorage keeps it across refreshes:
localStorage.setItem("token", token);<br>Without the first part, React components would not...