JSON Web Tokens in a MERN Stack (JWT), How I Finally Made It “Click”

When I first heard “JWT authentication” in MERN tutorials, it sounded like a magical passcode. Then I built it a few times (and broke it several times 😅) and realized there's a repeatable pattern:

  1. User signs in → server returns a token
  2. Client stores token (carefully) → sends it back on requests
  3. Server verifies token → allows or blocks protected routes

This post explains that flow in a way that's meant to be evergreen (not tied to one specific library version), while still being practical enough to implement in a typical MERN app.

Diagram showing the structure of a JSON Web Token (JWT)

Source: 

SuperTokens


JWT in one sentence

A JWON Web Token, or JWT, is a string the server signs that the client can send back later to prove “I already logged in,” without the server having to store a session in memory and using its precious memory. Which is definitely a concern for us on the free tiers of things.

That's why JWTs are popular for APIs: they work well across multiple devices, multiple frontends, and multiple servers.


Authentication vs Authorization (this is where I got confused)

JWTs are usually used for authorization after the user authenticates.


What's inside a JWT?

A JWT looks like:

xxxxx.yyyyy.zzzzz

Those three parts are:

Important: The payload is not secret. It's just base64url encoded. Anyone can decode it. So: never put passwords, credit cards, or private data inside JWT payloads.


The “normal” MERN flow

Step A: Signup

Step B: Signin

Step C: Protected requests


A standard API contract I like

On signin/signup success, return:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "_id": "656c4d...",
    "email": "you@email.com"
  }
}

That keeps the client simple: it has what it needs to save the token and display user info.


Where should I store the token?

This is one of those “depends” answers, but there are two common approaches.

Option 1: Store token in localStorage and sync it into React state

This is the pattern I personally use in most of my MERN projects.

A very common flow looks like this:

Pros

Cons

Option 2: Store token in an HTTP-only cookie (often considered “best practice”)

Pros

Cons

The main takeaway


The classic “Bearer token” request

Frontend (React) sends:

const token = localStorage.getItem("token");

const res = await fetch("https://your-api.com/api/v1/client", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

Backend verifies it before allowing protected routes.


What makes a route “protected”?

A protected route is just:

If token is valid → call next()
If not → return 401 Unauthorized

Express middleware shape

export function requireAuth(req, res, next) {
  // 1) read Authorization header
  // 2) verify token signature + expiration
  // 3) attach user info to req.user
  // 4) call next()
}

That next() is the whole point: it passes control to the next function in the chain.


A clean “protect everything in this router” pattern

In MERN APIs I like doing this:

// Everything below is protected via the requireAuth middleware:
router.post("/", requireAuth, createClient);
router.get("/", requireAuth, getAllClients);
router.get("/:id", requireAuth, getClientById);
router.put("/:id", requireAuth, updateClient);
router.delete("/:id", requireAuth, deleteClient);

It's easy to see which route is protected, and which aren't in the case if you weren't protecting all routes within a particular route category.


What should the server verify?

When the server receives a token, it should validate:


Expiration: you want it (even in student projects)

Tokens should expire. Even a simple setup should include an exp claim.

Why it matters: if a token is stolen, expiration limits how long it can be used.

Good practice:


Password hashing is non-negotiable

JWTs don't replace password hashing. Hash passwords with something like bcrypt:

Even though this post is about JWTs, password handling is part of auth.


Logout (the honest truth)

With JWTs, logout is mostly a client-side action:

If you're using cookies, logout usually means:

If you want “real logout” where a token becomes invalid immediately, you need a revocation strategy (like a token blacklist or token versioning). That's more advanced, but it's why many teams mix JWTs with other patterns.


Common JWT gotchas I've personally hit

1) “I'm sending Authorization, but it still 401s”

Usually one of these:

2) Frontend base URL points to the wrong place

Especially when using separate deployments (frontend + backend). I always double-check:

3) Token is saved, but UI doesn't update

That's typically state management:


My “evergreen” checklist

If I'm building JWT auth in a MERN app, I try to make sure I have:


Final thoughts

JWT auth isn't hard because the code is complex. It's hard because there are a lot of moving pieces across frontend, backend, headers, and middleware.

Once you memorize the flow, it becomes just another repeatable pattern you can confidently rebuild in new projects.

If you're also building a React Native client for the same API, the exact same JWT flow applies, which is honestly one reason JWTs are so popular in MERN + mobile setups.