A Token's Lifecycle: The Complete JWT Login Loop in React
After logging in, how does that long token actually "come to life"? This article doesn't just recite concepts; it walks you through a token's journey from birth to verification, covering every file, every redirect, and every automatic addition of the
Authorizationheader in requests.
Foreword
Recently, while working on a small project, I got stuck for a long time on the problem of "how to make the backend recognize me after logging in." There are many articles about JWT out there, but most of them talk about things in isolation: one discusses how to sign with jsonwebtoken, another explains how to write an axios interceptor, and others cover zustand or route guards... Individually, they make sense, but when put together, it's unclear how these files connect.
So, this article takes a different approach: using the lifecycle of a token as the main thread, I'll string together React + react-router-dom + zustand + axios + vite-plugin-mock + jsonwebtoken by showing the "connections between files."
You will gain:
- 🎯 A clear understanding of what problem JWT solves and how it differs from cookie/session.
- 🔑 An understanding of the essence of the two actions in
jsonwebtoken:sign(issuing) andverify(checking). - 🚀 The ability to use
vite-plugin-mockto fake a "backend server" on the frontend without waiting for a real backend. - 🕵️ An understanding of how an axios interceptor "invisibly" adds a token to every request automatically.
- 🏪 An understanding of why login state should be managed globally with zustand instead of being passed through components.
- 🛡️ The ability to write a route guard that redirects unauthenticated users to the login page.
- 📌 A walkthrough of the complete login → authentication → logout flow, knowing which file plays a role at each stage.
Tech Stack: React 19 + react-router-dom 7 + zustand 5 + axios + vite-plugin-mock + jsonwebtoken
Table of Contents
- I. First, Understand: HTTP is Stateless, So How Do You Handle "Who Are You?"
- II. Panoramic View: The Life of a Token and the Files It Passes Through
- III. jsonwebtoken: The Two Actions of sign and verify
- IV. Using Mock to Fake the "Backend"
- V. Axios Interceptor: The Token's Invisible Carrier
- VI. zustand: The Central Repository for Login State
- VII. Route Guard RequireAuth: The Gatekeeper
- VIII. Stringing the Complete Flow Together
- IX. Pitfalls I Encountered
- X. High-Frequency Interview Questions
- Summary
I. First, Understand: HTTP is Stateless, So How Do You Handle "Who Are You?"
1.1 The Pain Point: The Server Has "Face Blindness"
The HTTP protocol has a characteristic called statelessness. What does that mean?
An analogy: You go to a membership restaurant, but the waiters don't remember faces. You order once, they serve you. You come back later to order again, and they have no idea who you are, how much money you loaded last time, or if you're a member. You have to reintroduce yourself every time.
The web is the same. A browser sends a request, the server processes it, and then forgets about it. You just logged in, but when you send a second request, the server still doesn't recognize you. So the problem arises:
I've already logged in. Why do I have to prove "I am me" with every request?
This is the problem that authentication aims to solve: allowing the server to recognize you as an identified individual in every "stateless" request.
1.2 The Classic Solution: cookie + session
The old way works like this:
┌────────┐ 1.Login ┌────────┐
│ Browser │ ──────► │ Server │ Server notes who you are, generates a sessionId
└────────┘ └────────┘
│ ┌──────┴──────┐
│ 2.Stores │ Memory/Redis │
│ sessionId in │ stores │
│ a cookie │ session │
│◄───────────────┴─────────────┘
│
│ 3.Subsequent requests automatically carry sessionId via cookie
│ ───────────────────────────► Server looks up sessionId: Oh, it's you!
The advantage is that the server can "remember" you, but the disadvantage is also obvious: the server must maintain a set of session data. The server you logged into remembers the session; switch to another server, and it won't know you.
⚠️ Interview point: Why is cookie/session unsuitable for distributed systems? Because the session is stored in the memory of a specific server. A user's sessionId can be found on that machine, but not on another. You either need session sharing (adding complexity) or a different solution.
1.3 The JWT Approach: "Engraving" Identity into the Token
JWT's approach is completely different: The server doesn't remember you; instead, it "packages" who you are into an encrypted string and gives it to you to keep.
| cookie + session | JWT | |
|---|---|---|
| Does the server store state? | Yes (session) | No (stateless) |
| Where is identity info stored? | Server memory/Redis | Encoded in the token |
| Recognized on another server? | No (requires shared session) | Yes (any machine can decode it) |
| Analogy | A restaurant noting you in a ledger | Giving you a tamper-proof membership wristband |
One-line analogy: cookie/session is like an "internet cafe register" (the owner notes who you are), while JWT is like an "amusement park wristband" (the wristband is the proof; checking the wristband is enough, no need to look up your identity).
The core JWT flow has four steps:
1. Login → Server verifies username/password, uses jwt.sign to sign {username, role} into a token and sends it to you.
2. Storage → Frontend stores the token (localStorage).
3. Carrying → For every subsequent request, an interceptor automatically inserts the token into the request header: Authorization: Bearer xxx.
4. Verification → Server uses jwt.verify to decode the token, restoring the JSON identity object.
These four steps are the main thread running through this entire article. Below, we'll see which file each step occurs in.
II. Panoramic View: The Life of a Token and the Files It Passes Through
Before diving into the code, here's a "map." This project isn't large, but the dependency relationships between files are especially worth understanding—many people get stuck precisely because they don't know "where this variable comes from or where that function goes."
2.1 Directory Structure (One-line responsibility for each file)
login-demo/
├── mock/
│ └── user.js ← Fake backend: handles /api/login and /api/repo, issues and verifies tokens
├── src/
│ ├── api/
│ │ ├── config.js ← Axios instance + interceptors (Core! The token's invisible carrier)
│ │ ├── user.js ← Login API wrapper (calls the instance from config)
│ │ └── repo.js ← Protected API wrapper (calls the instance from config)
│ ├── store/
│ │ └── user.js ← zustand global store: token/user state + setAuth / logout
│ ├── components/
│ │ ├── Nav.jsx ← Navbar: shows Login / Logout button based on token
│ │ └── RequireAuth.jsx ← Route guard: Navigates to /login if no token
│ ├── pages/
│ │ ├── Login.jsx ← Login page: form → calls login → setAuth → redirect
│ │ ├── Home.jsx ← Home page (public)
│ │ └── Pay.jsx ← Pay page (protected)
│ ├── App.jsx ← Route table + lazy loading
│ └── main.jsx ← Entry point
├── vite.config.js ← Registers the vite-plugin-mock plugin
└── package.json
2.2 File Dependency Diagram
This diagram is the most important one in the whole article. Imprint it in your mind first:
┌────────────────────────────────────────┐
│ vite.config.js │
│ Registers viteMockServe(mockPath:'mock') │
└──────────────┬─────────────────────────┘
│ Loads
▼
┌────────────────────────────────────────┐
│ mock/user.js │
│ /api/login → jwt.sign issues token │
│ /api/repo → jwt.verify checks token │
└──────────────▲─────────────────────────┘
│ Intercepts requests, returns fake data
│
┌─────────────┐ useNavigate ┌────────────────────────────────────────┐
│ pages/ │ useLocation │ src/api/ │
│ Login.jsx ──┼──────────────► │ user.js ──┐ │
│ │ login() │ repo.js ──┼──► config.js (interceptor) │
└──────┬──────┘ └─────────────┘ baseURL:'/api' │
│ Auto-adds Authorization │
│ setAuth(token,user) └─────────────────────┘
▼
┌─────────────────────┐ useAuthStore ┌─────────────────────┐
│ store/user.js │◄───────────────────────────►│ components/ │
│ token / user │ │ Nav.jsx (reads token) │
│ setAuth / logout │ │ RequireAuth.jsx │
└─────────────────────┘ │ (reads token for guard) │
│ └─────────────────────┘
│ localStorage persistence
▼
localStorage: token / user
Once you understand this diagram, every subsequent section just adds details to a specific block.
2.3 A Token's Complete Journey (Spoiler, frame-by-frame breakdown later)
① You enter admin / 123456 on the /login page and click login.
↓
② Login.jsx calls user.js's login() → config.js's axios.post('/login', ...)
↓
③ config.js's request interceptor catches the request (no token yet, so no Authorization header).
↓
④ The request goes to /api/login, is intercepted by vite-plugin-mock, and handed to mock/user.js.
↓
⑤ Mock verifies username/password. On success, uses jwt.sign to issue a token, returns {code:0, user, token}.
↓
⑥ config.js's response interceptor returns res.data, login() gets {code:0, user, token}.
↓
⑦ Login.jsx calls setAuth({token, user}) → store/user.js writes to localStorage + updates global state.
↓
⑧ navigate redirects back to the page you originally wanted.
↓
⑨ Later, when you visit /pay or call getRepo(), config.js's interceptor automatically reads the token from localStorage.
↓
⑩ Request header automatically carries Authorization: Bearer xxx → mock uses jwt.verify to check → Passed!
With this main thread in mind, let's break down each file one by one.
III. jsonwebtoken: The Two Actions of sign and verify
JWT stands for JSON Web Token. Its essence is converting a JSON identity object into a long string (the token) using an encryption algorithm + a secret key.
This library has just two core actions. Remember these two words:
| Action | Function | Analogy |
|---|---|---|
jwt.sign() |
"Signs" a JSON object into a token | Stamping: putting an anti-counterfeit seal on an ID card |
jwt.verify() |
"Verifies" a token and restores it to a JSON object | Checking the seal: verifying if the anti-counterfeit seal is genuine |
import jwt from 'jsonwebtoken';
const secret = 'secret819!$'; // Secret key, like an "anti-counterfeit stamp," known only to you
// sign: Issuing — turns an identity object into a token
const token = jwt.sign(
{ user: 'admin', role: 'admin' }, // ① The JSON identity object to pack into the token
secret, // ② Secret key (salt)
{ expiresIn: 86400 } // ③ Expiration time in seconds (86400 = 1 day)
);
// verify: Checking — turns a token back into an identity object
const decoded = jwt.verify(token, secret); // { user: 'admin', role: 'admin', iat: ..., exp: ... }
Line-by-line breakdown:
jwt.sign(payload, secret, options): The three parameters are the data to sign, the secret key, and options (like expiration time).expiresIn: 86400means the token expires after 1 day. After expiry,verifywill throw an error.jwt.verify(token, secret): Uses the same secret key to decode it, restoring the exact JSON object you originally put in.
Remember in one sentence: sign is "locking the identity in a safe," verify is "opening the safe with a key to check the goods." The secret key is that key; whoever has it can sign and verify.
A key point to understand here: JWT is "one-way." You cannot reverse-engineer the secret key from the token, but with the key, any server can decode this token. This is precisely why it's suitable for distributed systems—the machine that signs and the machine that verifies don't need to be the same.
⚠️ Interview point: Why is JWT considered stateless and suitable for distributed systems? Because the identity information is encoded directly in the token, the server doesn't need to store anything. Any server holding the secret key can
verifyand get the same JSON object. There's no problem of "this machine knows you, that one doesn't."
IV. Using Mock to Fake the "Backend"
4.1 Why Mock?
Discussing JWT inevitably requires a "backend"—who issues the token? Who verifies it? But you likely don't have a backend yet (or the backend team hasn't finished the APIs). This is where vite-plugin-mock comes in: forging a fake backend in the frontend development environment.
Its principle is: intercept browser requests. If the URL matches a rule you defined, return fake data directly; the request never reaches a real server.
4.2 Register the Plugin First
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [react(), viteMockServe({
mockPath: 'mock', // Directory where mock files are located
localEnabled: true // Enable in local development environment
})],
})
mockPath: 'mock' tells the plugin "my fake APIs are written in the mock/ directory," and localEnabled: true means it's on during development.
4.3 mock/user.js: Two Interfaces of the Fake Backend
// mock/user.js
import jwt from 'jsonwebtoken';
const secret = 'secret819!$'
export default [
{
// Protected interface: verifies token
url: '/api/repo',
method: 'get',
response: req => {
const token = req.headers['authorization'].split(' ')[1]; // ① Extract token from request header
try {
let decoded = jwt.verify(token, secret); // ② Verify
return { code: 0, data: decoded.user }; // ③ Pass, return identity
} catch (err) {
return { code: 401, msg: 'Invalid token' }; // ④ Fail, return 401
}
}
},
{
// Login interface: issues token
url: '/api/login',
method: 'post',
response: (req) => {
const body = req.body;
if (body.username !== 'admin' || body.password !== '123456') {
return { code: -1, message: 'username or password error' };
}
// Server issues a token to the user
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // Identity object
secret, // Secret key
{ expiresIn: 86400 } // Expires in 1 day
);
return { code: 0, user: { username: body.username }, token };
}
}
]
Line-by-line breakdown (focus on the symmetric relationship between the two interfaces):
/api/loginusessign: User password is correct → signs the identity into a token → returns it to the frontend. This is the token's birthplace./api/repousesverify: Extracts the token from the request header → verifies it → returns the identity if passed. This is the token's ticket checkpoint.- The value of
req.headers['authorization']is in the format"Bearer xxx", so.split(' ')[1]removes theBearerprefix to get the pure token.
Remember in one sentence: login is "issuing the wristband," repo is "checking the wristband"—sign issues, verify checks, a closed loop.
Note this for now: The baseURL in the frontend's config.js is /api, and the interface URLs in mock are also /api/login, /api/repo. When these two sides match, the requests will be intercepted by mock. This "matching" is crucial and will be mentioned again later.
V. Axios Interceptor: The Token's Invisible Carrier
This is the most critical, yet most easily overlooked file in the entire project. Its role is: allowing you to never manually write the token for each request; axios secretly carries it for you.
5.1 config.js: Axios Instance + Two Interceptors
// src/api/config.js
import axios from 'axios';
const instance = axios.create({
baseURL: '/api', // All requests automatically prepend the /api prefix
timeout: 5000 // 5-second timeout
});
// ① Request interceptor: Every request is caught here before being sent out
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`; // Automatically stuffs into request header
}
return config; // Must return, otherwise the request won't be sent
});
// ② Response interceptor: Every response is caught here after returning
instance.interceptors.response.use(res => {
return res.data; // Directly strips the data layer, saving you from writing .data every time
});
export default instance;
Line-by-line breakdown:
axios.create({ baseURL: '/api' }): Creates a "customized axios." Later, writingaxios.get('/repo')actually requests/api/repo.- Request interceptor: Executes before the request is sent. It reads the token from
localStorage, and if it exists, stuffs it intoconfig.headers['Authorization']. This is the token's invisible carriage—you never need to write it in your business code. - Response interceptor: Executes after the response returns. It
return res.data, stripping the outerdatalayer that axios wraps, so the business code directly receives the JSON returned by the backend.
Remember in one sentence: The request interceptor handles "packing before departure," the response interceptor handles "unloading upon arrival."
5.2 Two Files That "Use the config Instance"
With the customized instance from config.js, business APIs all use it:
// src/api/repo.js —— Protected interface
import axios from './config'; // ✅ Imports the instance from config.js, not the real axios
export const getRepo = async () => {
const res = await axios.get('/repo');
return res.data;
};
// src/api/user.js —— Login interface
import axios from './config'; // ✅ Correct: imports the instance from ./config
export const login = async (data) => {
const res = await axios.post('/login', data);
return res.data;
};
Notice these two import lines: They both import from ./config, not from 'axios'. This is a very easy pitfall to fall into, discussed specifically below.
5.3 A Big Pitfall: instance is not defined
Initially, I wrote user.js like this:
// ❌ Wrong: imported the real axios, but used the instance variable
import axios from 'axios';
export const login = async (data) => {
const res = await instance.post('/login', data); // Where does instance come from? undefined!
return res.data;
};
The error is instance is not defined (or a runtime error). Root cause: This file doesn't have an instance variable at all—it's defined in config.js, and config.js uses a default export.
// ✅ Correct: import the customized instance from ./config, use it to send requests
import axios from './config';
export const login = async (data) => {
const res = await axios.post('/login', data);
return res.data;
};
Remember in one sentence:
import axios from './config'gets you the customized instance "with interceptors";import axios from 'axios'gets you just the bare axios, with nothing attached.
The root of this pitfall is simply not clarifying the connections between files: config.js is the "factory," repo.js/user.js are the "customers placing orders." Customers must pick up goods from the factory, not build their own.
VI. zustand: The Central Repository for Login State
6.1 Why Login State Needs Global Management
After a successful login, the token and user info need to be used in many places:
Nav.jsxneeds to see if a token exists to decide whether to show a "Login" or "Logout" button.RequireAuth.jsxneeds to see if a token exists to decide whether to allow access or block it.- Other business pages might need user information.
If relying on React's useState + parent-child prop passing, the token would have to be passed down from the top level through props, all the way to Nav, to RequireAuth... This is the infamous prop drilling.
zustand solves this: centralize the login state into a "central repository," and any component can directly fetch from the repository without passing it down layer by layer.
Analogy: Not using zustand is like a "telephone game" (passed layer by layer, the message gets distorted); using zustand is like a "bulletin board" (whoever needs it looks up themselves).
6.2 store/user.js: The Repository Looks Like This
// src/store/user.js
import { create } from 'zustand';
export const useAuthStore = create(set => ({
// ① Initial state: reads from localStorage, so it's not lost on page refresh
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// ② action: saves state after login
setAuth: ({ token, user }) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user }); // Updates the store, notifying all subscribed components to re-render
},
// ③ action: clears state on logout
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
}
}));
Line-by-line breakdown:
create(set => ({...})): zustand's core API.createis a higher-order function that takes a function and returns a hook (useAuthStore).- State (
token,user) and actions (setAuth,logout) are written together. This is zustand's style—one repository, with state and methods to change state all inside. set({...})is the method to change state. It causes all components usinguseAuthStoreto automatically re-render.- Key detail: The initial value of
tokenis read fromlocalStorage.getItem('token'). This way, on page refresh, the login state persists (because the token is persisted in localStorage).
6.3 How Components "Fetch from the Repository"
zustand's usage is on-demand subscription, using the selector state => state.xxx to precisely fetch:
// In Nav.jsx, only fetch the needed token and logout
const token = useAuthStore(state => state.token);
const logout = useAuthStore(state => state.logout);
// In RequireAuth.jsx, only fetch the token
const token = useAuthStore(state => state.token);
// In Login.jsx, only fetch the setAuth action
const setAuth = useAuthStore(state => state.setAuth);
Remember in one sentence: The selector syntax
useAuthStore(state => state.xxx)means "go to the repository and fetch that specific item," subscribing only to what you need to avoid unnecessary re-renders.
VII. Route Guard RequireAuth: The Gatekeeper
Now that the token exists and is stored in the repository, how do you block unauthenticated people from accessing protected pages? The answer is a route guard.
7.1 What is a Route Guard?
Route guard = a component that wraps protected pages, checks if you have a token before entering, and kicks you to the login page if you don't.
// src/components/RequireAuth.jsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token);
if (!token) {
return <Navigate to="/login" replace />; // Not logged in → kick to login page
}
return children; // Logged in → render child page normally
}
export default RequireAuth;
Line-by-line breakdown:
useAuthStore(state => state.token): Reads the token from the repository.if (!token): No token means not logged in, returns<Navigate to="/login" replace />.<Navigate>is react-router's declarative redirect component. Rendering it triggers a redirect.- The
replaceparameter: replaces the current history entry during the redirect, instead of adding a new one. This way, pressing the "back" button won't return the user to the blocked page, providing a better experience. - If there is a token,
return children, rendering the wrapped child page normally.
7.2 How to Use It in the Route Table
// src/App.jsx (excerpt)
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
}/>
</Routes>
See? The protected page /pay is wrapped in a layer of <RequireAuth>. When visiting /pay, React first renders RequireAuth, which checks the token:
- Token exists → renders
<Pay />, access granted. - No token → renders
<Navigate to="/login" />, sent back to login.
Remember in one sentence: A route guard is a "gatekeeper."
childrenis the room you want to protect,Navigatesends you back to the "registration desk" (login page).
VIII. Stringing the Complete Flow Together (Key Point!)
The previous sections were all "parts." This section assembles the parts into a whole machine, walking you through the complete flow three times. Please refer back to the diagram in Chapter 2.
8.1 Flow One: Login (The Birth of a Token)
① User visits /pay
→ App.jsx renders <RequireAuth><Pay/></RequireAuth>
→ RequireAuth.jsx reads token → none → <Navigate to="/login" replace/>
→ Browser redirects to login page Login.jsx
② User enters admin / 123456, clicks login
→ Login.jsx's handleLogin triggers → calls user.js's login(formData)
③ user.js's login() → axios.post('/login', data)
→ The axios here is the instance from config.js
④ config.js's request interceptor executes first:
→ Reads token from localStorage → none on first time → no Authorization header
→ Request continues, sent to /api/login (baseURL /api + /login)
⑤ vite-plugin-mock intercepts /api/login → hands to mock/user.js
→ Verifies username === 'admin' && password === '123456'
→ Pass → jwt.sign({user, role}, secret, {expiresIn:86400}) issues token
→ Returns { code: 0, user: {username:'admin'}, token }
⑥ config.js's response interceptor executes → return res.data
→ login() gets { code:0, user, token } → returns it
⑦ Inside Login.jsx: res.code === 0
→ setAuth({ token: res.token, user: res.user })
→ Inside store/user.js: writes to localStorage + set() updates global state
→ Now Nav.jsx sees token has a value, automatically switches from "Login" button to "Logout" button
⑧ navigate(from, { replace: true }) → redirects back to originally intended /pay
→ This time RequireAuth reads token again → exists → grants access, renders <Pay/>
Login flow in one sentence: Form → API → Interceptor (empty-handed) → Mock issues → Interceptor (strips shell) → setAuth stores → Redirect grants access.
8.2 Flow Two: Authentication (Carrying and Verifying the Token)
After successful login, the token is stored in localStorage. Subsequent access to protected interfaces is an automatic carrying + verification process:
① User accesses a protected interface, e.g., App.jsx calls getRepo() on mount
→ repo.js's getRepo() → axios.get('/repo')
② config.js's request interceptor executes:
→ This time, localStorage has the token!
→ config.headers['Authorization'] = 'Bearer ' + token
→ Request header automatically carries the token, sent to /api/repo
③ vite-plugin-mock intercepts /api/repo → hands to mock/user.js
→ req.headers['authorization'].split(' ')[1] extracts pure token
→ jwt.verify(token, secret) verifies
→ Pass → return { code:0, data: decoded.user }
④ response interceptor return res.data
→ getRepo() gets { code:0, data: 'admin' }
Authentication flow in one sentence: API → Interceptor (auto-carries header) → Mock verifies → Pass, returns data.
8.3 Flow Three: Logout (The Destruction of the Token)
① User clicks "Logout" button in Nav.jsx
→ handleLogout → logout() (from store/user.js)
② store/user.js's logout():
→ localStorage.removeItem('token')
→ localStorage.removeItem('user')
→ set({ token:'', user:null })
③ token becomes '', global state updates
→ Nav.jsx sees token is empty → button changes from "Logout" back to "Login"
→ Now if visiting /pay, RequireAuth finds no token → kicks back to login page
Logout flow in one sentence: Click logout → Clear localStorage + Clear store → Global instant "amnesia."
8.4 A Table Showing "Each File's Role in the Flow"
| File | Role | Key Moment in Flow |
|---|---|---|
Login.jsx |
Initiates login | Flow One ②⑦⑧ |
api/user.js |
Login API | Flow One ③ |
api/repo.js |
Protected API | Flow Two ① |
api/config.js |
Interceptor (carries token / strips data) | Flow One ④⑥, Flow Two ②④ |
mock/user.js |
Fake backend (sign / verify) | Flow One ⑤, Flow Two ③ |
store/user.js |
Central repository (store / clear) | Flow One ⑦, Flow Three ② |
RequireAuth.jsx |
Route guard (block / grant) | Flow One ①⑧ |
Nav.jsx |
Status display + logout entry | Flow One ⑦, Flow Three ③ |
App.jsx |
Route table + assembly | Flow One ① |
IX. Pitfalls I Encountered
Pitfall 1: instance is not defined
Covered in Section 5. user.js had import axios from 'axios' but used instance, a variable that didn't exist.
// ❌ Where does instance come from?
import axios from 'axios';
const res = await instance.post('/login', data);
// ✅ Import the customized instance from ./config
import axios from './config';
const res = await axios.post('/login', data);
Pitfall 2: Forgot to install jsonwebtoken
mock/user.js has import jwt from 'jsonwebtoken', but this package isn't in the dependencies. Starting vite throws a 500 error, saying Failed to fetch dynamically imported module, which is very confusing—on the surface, it looks like Login.jsx failed to load, but actually, the mock file couldn't resolve jsonwebtoken.
# Solution: Install it
pnpm add jsonwebtoken
Lesson: When vite reports a 500 error, don't just look at the file mentioned in the error. It's very likely dragged down by an indirect dependency whose import failed to resolve.
Pitfall 3: pnpm ignores esbuild build scripts
When installing dependencies with pnpm, you might see:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: [email protected]
pnpm 10+ doesn't execute dependency postinstall scripts by default (for security), but esbuild needs this to install native binaries. Ignoring it might prevent vite from starting. Solution:
pnpm approve-builds esbuild
Then run pnpm install again. Note that in pnpm 11, this configuration is written in pnpm-workspace.yaml, not in the pnpm field of package.json.
Pitfall 4: Security risks of storing tokens in localStorage
⚠️ Note: Putting a token in localStorage carries XSS risks (scripts can read localStorage). A more secure approach is httpOnly cookies. This small demo uses localStorage for convenience, but if asked in an interview "where is it safe to put a token," you should be able to articulate the difference.
| Approach | Pros | Cons |
|---|---|---|
| localStorage | Simple, easy for frontend to operate | Vulnerable to XSS theft |
| httpOnly cookie | JS cannot read it, prevents XSS | Must guard against CSRF |
X. High-Frequency Interview Questions
Q1: What is JWT? What is its structure?JWT (JSON Web Token) is a stateless authentication token that encodes user identity information into an encrypted string. Its structure consists of three parts separated by .: Header.Payload.Signature.
- Header: Declares the algorithm (e.g., HS256) and type (JWT).
- Payload: The actual identity data (e.g.,
{username, role}) + expiration time. - Signature: A signature of the first two parts using a secret key, used to prevent tampering.
- cookie/session: The server stores state. The sessionId is stored in server memory/Redis, and the client cookie only stores an id. Disadvantage: Session sharing is needed in distributed systems.
- JWT: The server does not store state. Identity information is encoded in the token, and the client keeps it. Any server holding the secret key can verify it. Disadvantage: Once a token is issued, it cannot be actively invalidated before expiration (unless a blacklist is added).
In one sentence: Session is "the server remembers," JWT is "the token carries its own identity."
Q3: Why is JWT suitable for distributed systems/microservices?Because the identity information is inside the token, the server doesn't need to query a database or share sessions. Any machine with the same secret key can verify the identity. It's naturally stateless and horizontally scalable.
- Request interceptor: Unified processing before a request is sent. The most typical use is automatically adding a token to the request header, or uniformly adding prefixes.
- Response interceptor: Unified processing after a response returns, such as uniformly stripping the data layer, unified error handling, or redirecting to login on a 401 status.
The benefit is consolidating repetitive logic into one place, so business code doesn't need to write it for every request.
Q5: How is a route guard implemented?Use a wrapper component (like RequireAuth) to wrap protected routes. The component internally reads the global login state (like zustand's token). If not logged in, it redirects with <Navigate to="/login" replace />. If logged in, it renders children. The essence is conditional rendering + declarative redirect.
Summary
Core Concept Quick Reference Table
| Concept | One Sentence |
|---|---|
| JWT | A stateless token that "engraves" identity into an encrypted string |
| sign / verify | Issuing / Verifying, a pair of "stamp/check stamp" actions |
| Stateless | The server doesn't remember you; each request identifies itself |
| baseURL | A request prefix uniformly added by the axios instance |
| Request interceptor | Automatically stuffs the token before the request departs |
| Response interceptor | Uniformly strips the data layer upon the response's arrival |
| zustand store | A "bulletin board" for global state, subscribed to on-demand |
| Route guard | A gatekeeper; kicks you to the login page if there's no token |
| localStorage persistence | Ensures login state isn't lost on page refresh |
A Mnemonic Rhyme
Login signs the token, the store keeps the token, the interceptor carries the token, the guard checks the token, logout clears the token.
Core Skeleton (Simplified Version)
// 1. Login → sign issues token (mock backend)
const token = jwt.sign({ user, role }, secret, { expiresIn: 86400 });
// 2. Interceptor → automatically carries token
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
// 3. Store → saves / clears token
const useAuthStore = create(set => ({
token: localStorage.getItem('token') || '',
setAuth: ({ token }) => { localStorage.setItem('token', token); set({ token }); },
logout: () => { localStorage.removeItem('token'); set({ token: '' }); }
}));
// 4. Guard → checks token to grant access or block
const token = useAuthStore(s => s.token);
if (!token) return <Navigate to="/login" replace />;
Conclusion
This article didn't try to cover every concept exhaustively. Instead, it aimed to help you straighten out the thread of a token's life from birth to destruction—once you understand the connections between files, the remaining details are just adding flesh to the skeleton.
The complete code can be built file by file following the structure above. Once you get it running, you'll have a brand new, holistic understanding of "frontend login authentication."
I hope this article helps you! If you have questions, feel free to discuss them in the comments. If you found it useful, a like and bookmark would be much appreciated 🔥