跪拜 Guibai
← Back to the summary

JWT Authentication, End to End: How a Token Gets Signed, Stored, Carried, Verified, and Guarded

Thoroughly Understand JWT Authentication: The Complete Chain of a Token from Issuance to Guard

The moment you log in successfully, the backend throws a long string of gibberish at you. Have you ever wondered: what exactly is inside this gibberish? Why can the backend, without "knowing you at all" or storing any of your information, determine who you are based solely on this string?

This article doesn't teach you how to call an API; instead, it takes you through a thorough understanding of the entire JWT authentication chain—from how a token is "signed" to how it ultimately helps "guard" your pages. Once you grasp this chain, any authentication-related code you encounter in the future will be instantly recognizable: you'll know which link it belongs to and why it's written that way.


First, the Big Picture: The Life of a Token

Before dissecting the details, first get the whole chain into your head. A token goes through 5 stages from birth to retirement:

① Issuance (sign)            ② Storage                    ③ Carrying
Backend jwt.sign ──────▶ Token lands in browser ──────▶ Interceptor auto-adds header
                    localStorage + store      Authorization: Bearer
                                                     │
                                                     ▼
⑤ Guard RequireAuth ◀────── ④ Verification (verify) ◀──────────┘
No token, kicked back to login          Backend jwt.verify decodes identity

Remember this diagram. Each section that follows is one circle in this diagram. The five circles are interlocked; take each one apart, and you can piece together the complete picture of authentication.


① Issuance: How a Token Is Actually "Made"

When a user enters the correct username and password, the backend needs to issue them a "credential." This demo uses the jsonwebtoken library, producing that credential with a single line of sign:

import jwt from 'jsonwebtoken';
const secret = 'secret819!$';

const token = jwt.sign(
  { user: 'admin', role: 'admin' },   // Identity info to put in the token
  secret,                             // Salting secret key
  { expiresIn: 86400 }                // Validity period: one day (in seconds)
);

I ran it and broke it down—what exactly does jwt.sign produce:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODcxNTUwMjEsImV4cCI6MTc4NzI0MTQyMX0.erdXGrnDQ9zbuW-IUqoe43nNpYFsKYytUuVSDSvP05c

This long string, split by ., is the entire structure of a JWT:

Segment What It Decodes To Function
header {"alg":"HS256","typ":"JWT"} Declares "signed using HS256 algorithm"
payload {"user":"admin","role":"admin","iat":...,"exp":...} Your identity information
signature A hash A signature of the first two segments using the secret, tamper-proof

Decoding the payload segment indeed yields plaintext:

{
  "user": "admin",
  "role": "admin",
  "iat": 1787155021,
  "exp": 1787241421
}

The Most Easily Misunderstood Point: Payload Is Not "Encryption," Just "Encoding"

This sentence deserves to be singled out, because 90% of people get this wrong when first encountering JWT.

The JWT payload uses Base64 encoding, not encryption. Base64 is an encoding rule that "converts binary data into 64 printable characters," which is reversible and requires no key—anyone can take this gibberish and restore the original text. The "decoding payload" example above was decoded directly, without using any key.

So the question arises: If anyone can decode and read it, what does JWT's security actually rely on?

It relies on the third segment, the signature. The signature generation process is roughly:

signature = HMAC-SHA256(
  base64(header) + "." + base64(payload),
  secret          // A key known only to the backend
)

Its ingenuity lies in:

This is why "payload being plaintext doesn't matter"—its security doesn't rely on hiding information, but on preventing forgery.

A one-sentence definition: JWT is an "ID card with an anti-counterfeiting stamp"—identity information is written in plaintext on the card, but the backend stamps it with a secret. Any service holding the same secret can verify whether this card is forged.

Advanced: Why Use JWT Instead of Traditional Sessions?

The watershed between "knowing how to use it" and "understanding it" lies at this level—you need to know what problem JWT solves that sessions couldn't.

HTTP itself is stateless: you make a request, the server responds, and then it "forgets" you. Next time you come, the server doesn't recognize you. So how do you make the server "remember who you are"? Historically, there's a classic solution called session:

Session flow:
1. Login successful → Server stores a mapping of sessionId → user info in its own memory
2. Sends the sessionId to the browser via a cookie
3. The browser automatically includes the sessionId from the cookie in every subsequent request
4. The server looks up "who this person is" in memory based on the sessionId

This solution itself is fine and runs well on a single machine. But it shows its weakness in distributed scenarios:

Session problem: The request lands on whichever machine, that machine must recognize this sessionId

  User ──▶ Load Balancer ──▶ Server A (has this session in memory) ✅
                    ──▶ Server B (doesn't have it in memory) ❌ Doesn't recognize!

Solution: Move the session to a shared store (like Redis) where all servers can look it up.
But this adds another centralized component that "must always be available."

JWT's approach is completely opposite—it puts the "identity" directly into the token:

JWT approach: Token carries its own identity; any server with the same secret can verify it

  User ──▶ Load Balancer ──▶ Server A (verifies successfully with secret) ✅
                    ──▶ Server B (verifies successfully with secret) ✅

No dependency on any shared storage; the issuing machine and the verifying machine can be completely different.

So JWT's core value, condensed into three words, is: stateless. The backend stores nothing; the token itself is the state. Anyone can verify it, and forget it immediately after. This is the fundamental reason it thrives in microservices, multi-datacenter, and front-end/back-end separation scenarios.


② Storage: Why the Token Is Stuffed into Two Places Simultaneously

Login successful, the backend sends the token back. Once the frontend gets it, it needs to store it. In this demo, it's stored in two places simultaneously:

import { create } from 'zustand';

export const useAuthStore = create(set => ({
  token: localStorage.getItem('token') || '',                   // Read initial value from localStorage
  user: JSON.parse(localStorage.getItem('user')) || null,

  setAuth: ({ token, user }) => {
    localStorage.setItem('token', token);                       // 🔑 Write to localStorage: persistence
    localStorage.setItem('user', JSON.stringify(user));
    set({ token, user });                                       // 🔑 Write to zustand: reactivity
  },

  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    set({ token: '', user: null });
  }
}));

Why store it in two places? Because they solve two completely different problems:

Storage Location Problem Solved What Happens If You Only Rely on This One
localStorage Persistence: Memory is cleared on page refresh, but the token must remain You'd have to log in again after every refresh
zustand store Reactivity: When the token changes, components subscribed to it must automatically re-render The UI won't follow the token change; the button still says "Login" after logging in

So "storing in both" isn't redundant; it's "one manages whether the data exists, the other manages whether the interface changes." Separating these two responsibilities is precisely one of the most core mental models in frontend state management: persistence and reactivity are two different things.

⚠️ A note on a security trade-off: Storing tokens in localStorage has a cost—any malicious script injected into the page (XSS) can use localStorage.getItem('token') to steal it. A more secure approach is storing it in an httpOnly cookie (unreadable by JS), but that introduces costs related to CSRF and mobile adaptation. That's another topic; for now, just remember that "storing in localStorage has a cost."


③ Carrying: How the Interceptor Automatically Attaches the Token to Every Request

The token is stored, but just storing it is useless. Afterwards, for every request the user makes that requires authentication (like fetching data), the backend needs to know "who I am." This is the job of the HTTP Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The problem is: if you manually add this header to every request, your code will look like this:

// Must repeat writing Authorization for every request
const res1 = await axios.get('/repo',  { headers: { Authorization: `Bearer ${token}` } });
const res2 = await axios.get('/user',  { headers: { Authorization: `Bearer ${token}` } });
const res3 = await axios.get('/order', { headers: { Authorization: `Bearer ${token}` } });

Hence the interceptor—a hook in axios that uniformly processes "before each request is sent":

import axios from 'axios';

const instance = axios.create({
  baseURL: '/api',
  timeout: 5000
});

// 🔑 Request interceptor: Every request is "intercepted" before being sent
instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;   // Uniformly add header; configure once, effective everywhere
  }
  return config;
});

export default instance;

Why the "Interceptor" Mechanism?

Because it solves a cross-cutting concern: adding a token has nothing to do with the business of "requesting data," yet it spans all requests. Without an interceptor, this logic would be scattered at every request call site—miss one, and that request gets a 401.

The idea behind interceptors is called Aspect-Oriented Programming (AOP): extract "common logic that cuts across all requests" from business code and manage it in one place. Adding a token is just the most common use case; you can also stuff in: uniformly adding common parameters, tracking, redirecting to login on token expiry, deduplicating requests... Configure once, effective everywhere.

Understanding Along the Way: What the Response Interceptor return res.data Changes

Axios's default response is an AxiosResponse object, looking like this:

{
  data: { code: 0, user: {...}, token: '...' },   // The actual response body is here
  status: 200,
  statusText: 'OK',
  headers: {...},
  config: {...}
}

If you attach a response interceptor:

instance.interceptors.response.use(res => {
  return res.data;   // Unwrap the AxiosResponse, directly return the response body
});

Then from that point on, await axios.get(...) directly gets the response body itself, no longer that nested object. This is a "convention"—the unwrapping is done for you by the interceptor; don't do another .data in your business code:

// ✅ Interceptor has already unwrapped, directly returns body
export const login = async (formData) => {
  const res = await axios.post('/login', formData);
  return res;               // This is the response body { code, user, token }
};

Once you understand this "return value chain," you won't write code with layers of nesting like res.data.data—because you know which layer unwrapped what, and what res actually points to.


④ Verification: Why the Backend Trusts the Token You Gave It

The token is automatically attached by the interceptor, and the request reaches the backend. The backend only needs to do one thing: verify authenticity. In this demo's mock, it looks like this:

response: req => {
  const auth = req.headers['authorization'];     // "Bearer xxxx"
  if (!auth) {
    return { code: 401, msg: 'No token provided' };  // No header at all, direct 401
  }
  const token = auth.split(' ')[1];              // Extract the xxxx from "Bearer xxxx"
  try {
    let decoded = jwt.verify(token, secret);     // 🔑 Verify authenticity using the same secret
    return { code: 0, data: decoded.user };      // Verification passed, return identity
  } catch (err) {
    return { code: 401, msg: 'Invalid token' };  // Verification failed, 401
  }
}

What jwt.verify does is recalculate the signature part using the secret and compare it with the signature carried in the token:

When verify(token, secret) succeeds, it returns the payload:
{
  "user": "admin",
  "role": "admin",
  "iat": 1787155021,
  "exp": 1787241421
}

Wrong secret / token tampered with / expired → throws error
JsonWebTokenError: invalid signature

This step best embodies the value of "stateless": The backend never queried any database, never touched any shared storage, yet based solely on a secret and the token itself, it verified "who you are, what role you have, whether it's expired." The issuing machine and the verifying machine can be worlds apart. This is the fulfillment of the statement planted in section ①—the token itself is the state.


⑤ Guard: Unauthenticated Users Shouldn't Even See the Page

The first four stages are all at the backend or "request" level. But there's another type of requirement at the frontend routing level: if a user isn't logged in and directly enters the URL of a protected page like /pay, the frontend must intercept them before rendering and kick them back to the login page.

This demo uses a RequireAuth component as a "route guard":

import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';

function RequireAuth({ children }) {
  const token = useAuthStore(state => state.token);   // Read token from store
  if (!token) {
    return <Navigate to="/login" replace />;          // 🔑 No token, redirect to login page
  }
  return children;                                    // Has token, render protected content normally
}

The usage is declarative—expressing "this route requires login" as a component wrapper, rather than writing if statements inside every page:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/login" element={<Login />} />
  <Route path="/pay" element={
    <RequireAuth>
      <Pay />
    </RequireAuth>
  } />
</Routes>

Why Write It as a Component Wrapper Instead of Checking Inside the Pay Component?

Because "who can access this route" is a routing layer responsibility, not a page's own responsibility. Extracting the validation logic into a reusable RequireAuth means if you have 10 protected pages, you wrap it 10 times; if you want to change "where to redirect when not logged in," you only change this one component.

Behind this is React's declarative philosophy: describe "what is" (this route requires login to view), rather than commanding "how to do it" (if not logged in, redirect). Declarative writing offers better readability and maintainability—you can tell at a glance which pages are protected.


Stringing the Five Stages Together: A Complete Data Flow

With all five stages explained, connect them, and the life of a token forms a closed loop:

[Backend]  jwt.sign({user, role}, secret) ────── ① Issuance ──▶ token
                                                          │
[Frontend] localStorage stores credential + zustand reactivity ◀──── ② Storage ──┘
                                                          │
[Frontend] Request interceptor auto-adds Authorization: Bearer ◀─ ③ Carrying ──┘
                                                          │
[Backend]  jwt.verify(token, secret) verifies & decodes identity ◀─ ④ Verification ──┘
                                                          │
[Frontend] RequireAuth reads store, no token kicks back to login ◀─ ⑤ Guard ─┘

Hidden in this diagram is the most essential fact about JWT authentication: the token is stateless. The backend never "remembers" who logged in; it just stamps a seal at the moment of issuance, and for every subsequent request, it re-identifies the person by "verifying the seal." Understanding this, you understand why JWT thrives in distributed systems—because it transfers the act of "remembering" from the backend onto the token itself.


Conclusion: Remember This Diagram, Not the API

Back to the opening question: What exactly is inside that string of gibberish?

It contains your identity (payload), its anti-counterfeiting seal (signature), and a mechanism that "anyone can verify, but doesn't depend on any server memory." It is signed out by the backend, lands in your browser and is stored, is automatically carried out again and again by the interceptor, is verified again and again at the backend, and finally guards your protected pages at the routing layer.

The core of JWT authentication has never been "knowing how to call the sign/verify APIs," but understanding how the token circulates in a closed loop between frontend and backend, and why each step is designed the way it is.

The next time you see authentication-related code, don't rush to find the API docs first. First, run through this diagram in your head: "① Issuance → ② Storage → ③ Carrying → ④ Verification → ⑤ Guard." You'll find that every line of code can find its place on this diagram—the rest is just details.

An open question, let's discuss in the comments: Should tokens be stored in localStorage (simple but vulnerable to XSS) or httpOnly cookie (secure but vulnerable to CSRF, inconvenient for mobile)? How do you weigh this trade-off in real projects?