Blog
Building a Secure MERN Authentication Flow With JWT
A production JWT flow in the MERN stack hinges on where you store tokens, how you refresh them, and how the server verifies every request.
Authentication is the feature developers most often get subtly wrong, because the happy path is easy and the security details are not. In a MERN application, the server issues a token after verifying credentials, the browser sends that token on subsequent requests, and the server checks it before serving protected resources. That summary hides the decisions that actually matter: how you hash passwords, where the token is stored in the browser, how you keep sessions alive without forcing constant logins, and how you guard against the common web attacks that target auth. This article builds the flow end to end with JSON Web Tokens, from registration through protected routes and token refresh, and explains the reasoning behind each choice. The emphasis is on the patterns that hold up in production rather than the minimal demo that works on localhost but leaks in the real world.
Hashing Passwords Properly
Authentication starts before any token exists, at registration, where you must never store a password as written. Use a deliberately slow, salted hashing algorithm such as bcrypt or argon2, which are designed to resist brute-force attacks by being expensive to compute. Bcrypt automatically generates a unique salt per password and embeds it in the resulting hash, so two users with the same password get different stored values, defeating precomputed rainbow tables. The cost factor controls how slow the hash is; tune it so a single hash takes a reasonable fraction of a second on your hardware, balancing security against login latency. On login, you hash the submitted password with the stored salt and compare. Crucially, never reveal whether the email or the password was the wrong one in your error messages, since distinguishing them lets attackers enumerate which accounts exist on your system.
What a JWT Actually Contains
A JSON Web Token is three base64url-encoded parts separated by dots: a header, a payload, and a signature. The header names the signing algorithm, the payload holds claims like the user id and an expiry timestamp, and the signature is computed by signing the first two parts with a secret only the server knows. The vital point is that the payload is encoded, not encrypted, so anyone can read it; never put secrets or sensitive data in a JWT. The signature is what makes the token trustworthy: because only the server holds the secret, no client can forge or alter a token without invalidating the signature. When a request arrives, the server recomputes the signature and rejects any mismatch. This is why JWTs enable stateless auth: the server can verify a token's authenticity from the signature alone, without looking it up in a database on every request.
Where to Store the Token
This is the decision with the largest security consequences, and the common advice to use localStorage is risky. Any token in localStorage is readable by JavaScript, which means a single cross-site scripting flaw anywhere on your page can steal it and hand an attacker a valid session. The safer pattern is an httpOnly cookie, which the browser stores and sends automatically but which your JavaScript cannot read, neutralizing token theft via XSS. The trade-off is that cookies are sent automatically, which opens cross-site request forgery, so you must set the cookie's SameSite attribute and add CSRF protection for state-changing requests. Mark the cookie Secure so it only travels over HTTPS. For a MERN app on one domain, an httpOnly, Secure, SameSite cookie holding the token is the soundest default. Reserve localStorage tokens for cases where the API is consumed by non-browser clients.
The Login Endpoint
The login route ties the pieces together on the server. It receives an email and password, looks up the user in MongoDB, and if no user exists it returns the same generic error it would for a wrong password. If the user exists, it compares the submitted password against the stored bcrypt hash. On success it signs a JWT containing the user id and an expiry, using a strong secret kept in an environment variable and never committed to source control. It then sets that token in an httpOnly cookie on the response. Keep the token's lifetime short, on the order of fifteen minutes, to limit the damage if it ever leaks. Avoid putting roles or permissions that can change into the token, since you cannot revoke an issued JWT before it expires; for anything that must reflect current server state, look it up rather than trusting a stale claim.
Protecting Routes With Middleware
Protected resources sit behind Express middleware that runs before the route handler. The middleware reads the token from the cookie, verifies its signature and expiry with the same secret used to sign it, and if valid, attaches the decoded user information to the request object so downstream handlers know who is calling. If the token is missing, expired, or tampered with, verification throws and the middleware responds with a 401 status, stopping the request before it reaches protected logic. This keeps authorization in one reusable place rather than scattered through every handler. A clean structure is to have an authentication middleware that establishes identity and, where needed, a separate authorization check that confirms the identified user has permission for the specific action. Because verification is purely cryptographic and stateless, it adds negligible latency and requires no database round trip in the common case.
Refresh Tokens and Sessions
Short-lived access tokens are safer but would log users out every fifteen minutes, which is unacceptable. The standard solution is a second, longer-lived refresh token stored in its own httpOnly cookie. When the access token expires, the client calls a dedicated refresh endpoint, which verifies the refresh token and issues a new access token without requiring the user to log in again. Unlike access tokens, refresh tokens should be stored server-side, typically in a database or Redis, so you can revoke them, which gives you a real logout and the ability to invalidate a session if a token is compromised. A robust pattern rotates the refresh token on each use and detects reuse of an old one as a sign of theft. This two-token design reconciles the conflicting goals of short access-token lifetimes for safety and long sessions for usability.
The React Side of Auth
On the frontend, the cleanest pattern with httpOnly cookies is that React never touches the token directly. The browser attaches the cookie automatically, so your fetch or axios calls simply need to send credentials with each request. To know whether the user is logged in, the app calls a current-user endpoint on load, which returns the user if the cookie is valid and a 401 otherwise, and you store that result in a context or query so components can react to it. Wrap protected pages in a guard that redirects to login when there is no authenticated user. Avoid trying to decode the token in the browser to check expiry, since with httpOnly cookies you cannot read it anyway; instead, treat a 401 from any request as the signal to attempt a refresh and, failing that, send the user to log in.
Common Pitfalls to Avoid
Several mistakes recur in MERN auth implementations. Hardcoding the JWT secret or committing it to the repository is a complete compromise, since anyone with it can mint valid tokens. Using a weak or short secret invites brute-forcing the signature. Setting absurdly long token expiries to avoid dealing with refresh defeats the security benefit of expiry entirely. Forgetting to enforce HTTPS lets tokens travel in plaintext. Trusting claims like roles embedded in an old token rather than checking current server state leads to privilege bugs after a user's permissions change. Returning different errors for unknown email versus wrong password enables account enumeration. And skipping CSRF protection when using cookies leaves state-changing routes exposed. None of these are exotic; they are the predictable gaps that appear when the demo is shipped without revisiting the security details that the happy path never exercises.