











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
↓
Server verifies credentials
↓
Server signs JWT
↓
React stores authentication state
↓
Axios sends the token
↓
Server verifies the token
↓
Protected data
The stack will be:
React
React Router
Zustand
Axios
JWT
Vite
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.
HTTP is stateless in the sense that one request does not automatically carry knowledge about an earlier one.
Suppose we send:
POST /api/login
and then later:
GET /api/profile
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...
So the real flow looks like this:
Request 1
POST /login
username + password
↓
server verifies user
↓
JWT returned
Request 2
GET /profile
Authorization: Bearer <token>
↓
server verifies JWT
↓
profile returned
HTTP itself still remembers nothing.
The client simply proves its identity again with every protected request.
JWT is not the only way to do this.
A traditional session flow looks like:
Browser
↓ cookie with session ID
Server
↓
Session store
The browser sends a cookie, and the server looks up the matching session.
A JWT flow can look like:
Browser
↓ token
Server
↓ verify signature
User identity
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
{
password: "super-secret"
}
Basic identity information is more reasonable:
{
sub: "user_123",
role: "user"
}
Think of a signed JWT as a tamper-evident credential rather than a secret container.
SESSION FLOW
JWT FLOW
We will use these files:
src/
├── api/
│ ├── client.ts
│ └── auth.ts
├── components/
│ └── RequireAuth.tsx
├── pages/
│ ├── Home.tsx
│ ├── Login.tsx
│ └── Account.tsx
├── store/
│ └── auth.ts
└── App.tsx
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.
The application needs one shared place for authentication state.
We care about two things:
token
user
A small Zustand store is enough:
import { create } from "zustand";
type User = {
id: string;
username: string;
};
type AuthState = {
token: string | null;
user: User | null;
login: (token: string, user: User) => void;
logout: () => void;
};
export const useAuthStore = create<AuthState>((set) => ({
token: localStorage.getItem("token"),
user: JSON.parse(localStorage.getItem("user") ?? "null"),
login: (token, user) => {
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
set({
token,
user,
});
},
logout: () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
set({
token: null,
user: null,
});
},
}));
There are two separate jobs here.
First, Zustand keeps authentication state available to React:
set({
token,
user,
});
Second, localStorage keeps it across refreshes:
localStorage.setItem("token", token);
Without the first part, React components would not immediately respond to login changes.
Without the second part, refreshing the browser would rebuild the store from scratch and the user would appear logged out.
The source article uses the same double-write idea: update both Zustand and localStorage, then remove both on logout.
localStorageFor a tutorial, this approach is easy to understand.
For production authentication, storing long-lived sensitive tokens in localStorage deserves more thought because JavaScript running on the page can read them. A successful XSS attack could therefore steal the token.
A common production architecture is:
short-lived access token
+
HttpOnly Secure refresh cookie
or a cookie-based session.
We will keep localStorage here because it makes the entire frontend flow visible, but do not automatically treat it as the best choice for every authentication system.
Instead of importing raw Axios everywhere, create a shared client:
import axios from "axios";
export const api = axios.create({
baseURL: "/api",
timeout: 10_000,
});
Now all requests share the same base URL and configuration.
Next, attach the token automatically:
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Now this:
api.get("/account");
can automatically become:
GET /api/account
Authorization: Bearer eyJhbGciOi...
The component making the request does not need to know how authentication headers are constructed.
The source uses exactly this idea, with an Axios request interceptor reading the token and attaching Bearer ${token} before requests leave the browser.
Request interceptors are only half of the story.
If the server rejects a token, we should deal with that consistently too.
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("user");
useAuthStore.getState().logout();
}
return Promise.reject(error);
},
);
Now an expired or invalid token can clear the local authentication state.
One thing to avoid is redirecting from every API function independently. Authentication failures are easier to maintain when they have one central policy.
The API layer should say what endpoint we want to call, not repeat Axios configuration.
For login:
import { api } from "./client";
type LoginInput = {
username: string;
password: string;
};
type LoginResponse = {
token: string;
user: {
id: string;
username: string;
};
};
export async function login(input: LoginInput) {
const response = await api.post<LoginResponse>("/login", input);
return response.data;
}
For a protected endpoint:
import { api } from "./client";
type AccountResponse = {
username: string;
role: string;
};
export async function getAccount() {
const response = await api.get<AccountResponse>("/account");
return response.data;
}
There is no token logic here.
That belongs to the Axios client.
Let’s keep the form simple.
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../api/auth";
import { useAuthStore } from "../store/auth";
export function Login() {
const navigate = useNavigate();
const saveLogin = useAuthStore((state) => state.login);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (submitting) {
return;
}
setSubmitting(true);
setError("");
try {
const result = await login({
username,
password,
});
saveLogin(result.token, result.user);
navigate("/account", {
replace: true,
});
} catch {
setError("Invalid username or password.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<label>
Username
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
{error && <p>{error}</p>}
<button
type="submit"
disabled={!username || !password || submitting}
>
{submitting ? "Signing in..." : "Sign in"}
</button>
</form>
);
}
There are no JWT details in this component.
It does three things:
collect credentials
↓
call login()
↓
save returned auth state
That separation keeps the component readable.
A common version of this form stores another state value:
const [isValid, setIsValid] = useState(false);
and then uses an effect to keep it synchronized with the form.
For a simple login form, that is unnecessary.
Validity can be derived directly:
const isValid =
username.trim().length >= 3 &&
password.length >= 6;
Then:
<button disabled={!isValid || submitting}>
Sign in
</button>
If a value can be calculated from existing state, it usually does not need its own state variable.
Sending a token with API requests does not automatically protect your React pages.
Suppose /account should only be visible after login.
Create a small route guard:
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuthStore } from "../store/auth";
export function RequireAuth() {
const token = useAuthStore((state) => state.token);
const location = useLocation();
if (!token) {
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname }}
/>
);
}
return <Outlet />;
}
Then define routes:
import {
BrowserRouter,
Route,
Routes,
} from "react-router-dom";
import { RequireAuth } from "./components/RequireAuth";
import { Account } from "./pages/Account";
import { Home } from "./pages/Home";
import { Login } from "./pages/Login";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth />}>
<Route path="/account" element={<Account />} />
</Route>
</Routes>
</BrowserRouter>
);
}
The idea is simple:
/account requested
↓
is there a token?
↙ ↘
no yes
↓ ↓
/login Account
The source article uses the same pattern through a RequireAuth wrapper and <Navigate />.
This distinction matters.
RequireAuth protects the UI.
It does not protect your backend.
A user can bypass React completely and call an API endpoint directly:
curl https://example.com/api/account
So the server must verify authorization too.
Think of the frontend route guard as:
user experience protection
and server-side JWT verification as:
actual access control
You need both.
The account page can call the protected endpoint:
import { useEffect, useState } from "react";
import { getAccount } from "../api/account";
type AccountData = {
username: string;
role: string;
};
export function Account() {
const [account, setAccount] = useState<AccountData | null>(null);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function loadAccount() {
try {
const data = await getAccount();
setAccount(data);
} catch {
if (!controller.signal.aborted) {
setError("Could not load account.");
}
}
}
loadAccount();
return () => {
controller.abort();
};
}, []);
if (error) {
return <p>{error}</p>;
}
if (!account) {
return <p>Loading...</p>;
}
return (
<section>
<h1>Account</h1>
<p>User: {account.username}</p>
<p>Role: {account.role}</p>
</section>
);
}
The original code uses a manual cancelled flag to avoid updating state after the component leaves the page.
For modern fetch-style flows, AbortController is often easier to reason about when the underlying request API supports cancellation.
Logout is deliberately boring.
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "../store/auth";
export function LogoutButton() {
const logout = useAuthStore((state) => state.logout);
const navigate = useNavigate();
function handleLogout() {
logout();
navigate("/login", {
replace: true,
});
}
return (
<button type="button" onClick={handleLogout}>
Log out
</button>
);
}
Our store clears both locations:
localStorage.removeItem("token");
localStorage.removeItem("user");
set({
token: null,
user: null,
});
The browser forgets the persistent credential, and React immediately updates every subscribed component.
The frontend cannot issue trusted JWTs to itself.
The server has to verify credentials and sign the token.
A simplified Node example using jsonwebtoken might look like this:
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET is missing");
}
Never write the production secret directly into source code:
// Don't do this
const JWT_SECRET = "secret123";
The login handler could be:
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
const user = await findUserByUsername(username);
if (!user) {
return res.status(401).json({
message: "Invalid credentials",
});
}
const passwordMatches = await verifyPassword(
password,
user.passwordHash,
);
if (!passwordMatches) {
return res.status(401).json({
message: "Invalid credentials",
});
}
const token = jwt.sign(
{
sub: user.id,
role: user.role,
},
JWT_SECRET,
{
expiresIn: "15m",
},
);
return res.json({
token,
user: {
id: user.id,
username: user.username,
},
});
});
A short-lived access token is safer than handing out one token that remains valid for days.
A protected endpoint needs to read the header:
Authorization: Bearer <token>
A small middleware function can do that:
function requireAuth(req, res, next) {
const authorization = req.headers.authorization;
if (!authorization?.startsWith("Bearer ")) {
return res.status(401).json({
message: "Authentication required",
});
}
const token = authorization.slice("Bearer ".length);
try {
req.auth = jwt.verify(token, JWT_SECRET);
next();
} catch {
return res.status(401).json({
message: "Invalid or expired token",
});
}
}
Then:
app.get("/api/account", requireAuth, (req, res) => {
res.json({
username: req.auth.sub,
role: req.auth.role,
});
});
The source shows the same fundamental operation by extracting the token after the Bearer prefix and passing it to jwt.verify().
Bearer?The request header normally looks like:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
The word Bearer identifies the authentication scheme.
The token follows after a space.
That is why code often does:
const token = authorization.split(" ")[1];
I prefer:
const token = authorization.slice("Bearer ".length);
after checking:
authorization.startsWith("Bearer ")
It makes the expected format a little more explicit.
This is where the complete architecture becomes clearer.
Imagine the user has logged in.
Zustand contains:
{
token: "...",
user: {
id: "42",
username: "alex"
}
}
Then the browser refreshes.
React disappears and starts again.
The in-memory Zustand store disappears too.
But this remains:
localStorage
When the new store initializes:
token: localStorage.getItem("token"),
the token is restored.
So the sequence is:
login
↓
Zustand
+
localStorage
↓
refresh
↓
React restarts
↓
Zustand reads localStorage
↓
user still appears logged in
Again, HTTP remembered nothing.
The browser simply persisted the credential.
Once logged in:
getAccount();
calls:
api.get("/account");
The Axios interceptor sees the token:
const token = localStorage.getItem("token");
and changes the request:
GET /api/account
Authorization: Bearer <token>
The server verifies the signature.
If valid:
200 OK
If invalid or expired:
401 Unauthorized
The whole authentication chain is now connected.
Here is the full process:
1. User enters credentials
↓
2. React calls POST /login
↓
3. Server verifies username/password
↓
4. Server signs JWT
↓
5. React saves token in auth state
↓
6. Browser persists token
↓
7. Axios attaches Bearer token
↓
8. Server verifies JWT
↓
9. Protected resource is returned
And on refresh:
Page reload
↓
Store is recreated
↓
Token restored
↓
Protected UI remains available
It is worth separating Zustand’s job from JWT’s job.
JWT answers:
How can the server verify this request?
Zustand answers:
How does my React UI know the current auth state?
Axios answers:
How does the credential reach every API request?
React Router answers:
Which pages should the UI expose?
localStorage answers:
How does this demo restore auth after refresh?
They solve different problems.
That is why the architecture works better when each piece stays small.
Not always.
If authentication is the only global state in a small application, React Context may be perfectly fine.
For example:
AuthProvider
↓
useAuth()
Zustand becomes attractive when shared state grows or when you prefer stores without wrapping the component tree in additional providers.
The source article makes the same practical point: don’t add a state library just because it exists.
The tool should match the application.
Our example is intentionally easy to inspect.
A more serious authentication system might use:
Login
↓
short-lived access token
+
HttpOnly refresh cookie
Then:
access token expires
↓
refresh endpoint
↓
new access token
This avoids keeping a long-lived refresh credential directly in JavaScript-accessible storage.
You would also typically add:
CSRF protection where relevant
Secure cookies
SameSite settings
HTTPS
password hashing
token rotation
server-side authorization
rate limiting
logout revocation strategy
JWT is only one piece of authentication.
It is not the security system by itself.
One common mistake is treating the existence of a token as proof that the user is authenticated.
if (token) {
// user must be valid
}
The token might be expired, malformed, revoked by some external policy, or simply fake.
Only the server can make the final authorization decision.
Another mistake is putting sensitive information in the JWT payload.
Don’t do this:
{
password: "...",
privateKey: "...",
}
Signed does not mean encrypted.
A third mistake is spreading token logic everywhere:
axios.get("/one", {
headers: {
Authorization: `Bearer ${token}`,
},
});
axios.get("/two", {
headers: {
Authorization: `Bearer ${token}`,
},
});
That is exactly what the interceptor should eliminate.
The interesting part of authentication is not jwt.sign() or one Zustand store.
It is how all the pieces cooperate.
HTTP stays stateless.
The server signs a credential. The browser keeps enough state to continue the session experience. Zustand makes that state available to React. Axios attaches the token to outgoing requests. React Router keeps protected pages out of the normal navigation flow. The backend verifies every protected request again.
Once you see the system that way, login persistence stops feeling like magic.
It is just a chain:
JWT
↓
persistent client state
↓
Zustand
↓
Axios
↓
route guard
↓
server verification
And the most important part remains on the server.
A route guard can hide a page.
Only backend authorization can actually protect the data.
Zustand is useful for exposing the current authentication state to React components, but it is not a secure storage mechanism by itself. Use it for UI state, and let the backend verify the token on protected requests.
It is simple and visible for a tutorial, but it can be read by JavaScript running on the page. Production apps should consider XSS risk, token lifetime, HttpOnly cookies, refresh tokens, and revocation before choosing storage.
An interceptor centralizes the Authorization: Bearer <token> logic. Components and API functions can stay focused on the endpoint they call instead of repeating header setup.
No. RequireAuth protects the React interface. A user can still call your backend directly, so the server must verify JWT signatures, expiry, and permissions on every protected endpoint.
Keep it minimal. Identifiers, roles, issuer, audience, and expiry are common. Do not put passwords, private keys, refresh secrets, or sensitive personal data in a normal signed JWT payload.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。