惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
L
LangChain Blog
H
Help Net Security
博客园_首页
T
Tailwind CSS Blog
Microsoft Security Blog
Microsoft Security Blog
T
The Blog of Author Tim Ferriss
雷峰网
雷峰网
Recent Announcements
Recent Announcements
D
DataBreaches.Net
U
Unit 42
Vercel News
Vercel News
I
InfoQ
Martin Fowler
Martin Fowler
Microsoft Azure Blog
Microsoft Azure Blog
Apple Machine Learning Research
Apple Machine Learning Research
S
SegmentFault 最新的问题
Jina AI
Jina AI
博客园 - 叶小钗
博客园 - 【当耐特】
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
月光博客
月光博客
Last Week in AI
Last Week in AI

Echo JS

billboard.js 4.1.0: Live resizing, configurable subchart, React subpath & CSP-safe worker From 1,256ms to 96ms: Fixing INP in a Massive React Dropdown GitHub - evoluteur/cymatics: Play a frequency and watch the sand settle into its Chladni figure, computed from the wave equation. Memdeklaro - The Basics of Decentralized Identity (DID) and Self-Sovereign Identity (SSI) How Railmid Works GitHub - evoluteur/platonic-solids: Turn the five Platonic solids in 3D, show their duals, read their measurements, and print the nets to fold your own. Sharing Application State in a URL GitHub - evoluteur/sacred-geometry: Sacred Geometry Generator: draw, tune, and export Vesica Piscis, Seed of Life, Flower of Life, Metatron's Cube, and the Golden Spiral as SVG Best of Self-Sovereign Identity: Digitalcourage, World Passport and Memdeklaro Reads Are Subscriptions - Migrating from Zustand to Coaction GitHub - evoluteur/binaural-beats: Simple web page to play binaural beats for sleep, meditation, relaxation, and focus: Delta, Theta, Alpha, Beta, and Gamma brainwave frequencies, with an optional pink or brown noise bed. toast-queue — Accessible, customizable toast notifications Building a High-Performance Data Grid in React, Vue, and Svelte I built a flight recorder for AI sessions My idempotency library had one job. A dropped connection made it run the payment twice. "half-open" twice is not the same state: the bug that shaped breakwater 1.0 GitHub - evoluteur/evolutility-server-node: Framework for building REST APIs for CRUD with models rather than code (using Node.js, Express, and PostgreSQL). React Router v8 in Action: Lazy Loading and Nested Routes One $ for every environment | Xec My test suite had 100% coverage. Mutation testing still found real bugs. The type-safe data layer for Kysely | Kysera What JavaScript Obfuscation in the AI Era | JavaScript Tools Blog Using Mongoose Studio with Apache Cassandra via Data API GitHub - trekhleb/yesbrainer: 🧠 A council of AI models for the decisions that aren't no-brainers — they answer in parallel, debate to consensus, or get judged to a verdict. Browser-only, open source, bring your own keys (BYOK), no backend. Node.js has plenty of circuit breakers. So why did I build another one? My Redis library said the write succeeded. Redis was down. GitHub - Techthos/gadget: Prebuilt, interactive HTML widgets for MCP Apps in Go — data tables and forms, self-contained in a single binary, host-themed, spec-compliant. GitHub - evoluteur/react-morph-charts: React component for bubble chart, bar chart, and pie chart, with animated morphing transitions between charts, on hover, and on window resize. Interactive Metaballs Tutorial
React Authentication With JWT, Zustand, and Axios | JavaS...
JSTools.Space · 2026-08-21 · via Echo JS

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.

Why Login State Exists If HTTP Is Stateless

HTTP is stateless in the sense that one request does not automatically carry knowledge about an earlier one.

Suppose we send:

POST /api/login

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.

Session Authentication vs JWT

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

Server remembers the session

  • +The browser sends a cookie with a session ID.
  • +The server looks up that session in storage.
  • +Revocation can happen by deleting the server-side session.
  • +Works well for many traditional web applications.

JWT FLOW

Server verifies a signed credential

  • +The browser sends a bearer token with protected requests.
  • +The server verifies the JWT signature and claims.
  • +Short expiry and refresh strategy matter a lot.
  • +Useful for APIs, SPAs, and distributed services when designed carefully.

Our Small Authentication 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.

Step 1: Create the Zustand Auth Store

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.

A Security Note About localStorage

For 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.

Step 2: Create One Axios Client

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.

Step 4: Keep API Functions Small

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.

Step 5: Build the Login Page

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.

Avoid Storing Derived Form State

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.

Step 6: Protect Routes

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 />.

A Route Guard Is Not Real Security

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.

Step 7: Build a Protected Page

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.

Step 8: Logout

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.

Step 9: What the Server Actually Does

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.

Step 10: Verify the JWT on Protected Requests

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().

Why 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.

What Survives a Page Refresh?

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.

What Happens on Every API Request?

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.

The Complete Flow

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

Where Zustand Fits

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.

Do You Actually Need Zustand?

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.

A Better Production Architecture

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.

Common Mistakes

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.

  • Keep token attachment in one Axios client instead of repeating headers in every API call.
  • Treat React route guards as UI behavior, not backend security.
  • Keep secrets out of JWT payloads because signed tokens are not automatically encrypted.
  • Use short-lived access tokens and a deliberate refresh strategy for production applications.
  • Verify authorization on the server for every protected endpoint, even when the UI hides the route.

Final Thoughts

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.

FAQ

Should React store JWTs in Zustand?

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.

Is localStorage safe for JWT authentication?

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.

Why use Axios interceptors with JWT?

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.

Does RequireAuth protect my API?

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.

What should I put inside a JWT payload?

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.