跪拜 Guibai
← Back to the summary

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 Authorization header 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:

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?"

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:

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 verify and 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):

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:

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:

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:

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:

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:

Remember in one sentence: A route guard is a "gatekeeper." children is the room you want to protect, Navigate sends 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.

Q2: What is the difference between cookie/session and JWT?

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.

Q4: What is the role of an axios interceptor?

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 🔥