Auth in MERN: Every Mistake I Made So You Don't Have To
I implemented authentication three times before getting it right. JWT confusion, cookie problems, and security holes I didn't know I had.
Authentication seemed straightforward when I started. User enters credentials, you send back a token, they're logged in. Simple, right?
Then reality hit. Where should this token live? What happens when it expires? Can someone steal it? Before I knew it, I was knee-deep in security concepts I'd never heard of.
Here's what I learned from doing it wrong multiple times.
Version One: LocalStorage (Seemed Fine Until It Wasn't)
My first attempt followed what I saw in most YouTube tutorials. Store the JWT in localStorage, include it in the Authorization header with every request. Clean, easy to understand.
And it worked! Users could log in, make authenticated requests, everything functioned as expected.
Then a developer friend looked at my code during a code review session. "You're storing tokens in localStorage?" he asked. I said yes, wondering what the problem was.
Turns out, any JavaScript running on your page can access localStorage. If someone manages to inject malicious code through an XSS vulnerability, they can just grab the token and send it to themselves. I had no idea this was even a thing.
Version Two: Cookies (And the CORS Nightmare)
So I switched to HTTP-only cookies. The browser automatically sends them with requests, and JavaScript can't touch them. Seemed like the perfect solution.
Except nothing worked.
My frontend made requests to the backend, but the cookie never showed up. I checked the browser dev tools, checked my backend logs, double-checked my code. The cookie was being set, but not sent.
Spent an entire Saturday figuring out it was a CORS issue. Cross-origin requests don't include cookies by default. You have to explicitly tell both sides to handle credentials.
Here's what finally worked:
// Backend (Express)
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}))
// Frontend (fetch calls)
fetch(url, {
credentials: 'include'
})
Both the server and client have to agree to send credentials. Miss either one and it silently fails. Lovely.
The Constant Logout Problem
My access tokens expired after one hour. Seemed reasonable for security. But in practice, users got kicked out constantly. Mid-workflow, mid-form-filling, mid-everything.
Really annoying user experience.
This is where refresh tokens come in. The idea is you have two tokens: a short-lived access token (15 minutes) for actual API requests, and a long-lived refresh token (7 days) that can get you a new access token without logging in again.
The refresh token sits in an HTTP-only cookie. When the access token expires, you use the refresh token to get a fresh one. User stays logged in, but if someone steals the access token, it's only valid for 15 minutes.
The tricky part is making sure refresh tokens can't be reused maliciously. I store them in the database with the user ID, and when a new one gets issued, the old one is invalidated. Not perfect, but way better than my first attempt.
Wait, How Do You Actually Log Out?
This one messed with my head for a while.
With JWTs, the token is still valid until it expires, even if the user clicks "logout." You can't really invalidate it because JWTs are stateless. They're just cryptographically signed data, not something you can revoke on the server.
Some people keep a blocklist of invalidated tokens, but then you're back to hitting the database on every request, which defeats the whole stateless benefit.
My solution: I keep a token version number in the user record. When someone logs out, I increment their version. Every token includes the version it was issued for. If the versions don't match during validation, the token is rejected, even if it hasn't expired yet.
Is this the "right" way? I don't know. But it works and doesn't feel too hacky.
Passwords and Timing Attacks
I knew to use bcrypt for password hashing. That's like rule number one of auth, right?
What I didn't know about was timing attacks. Apparently, if you compare passwords with a regular string comparison, attackers can measure how long the comparison takes and use that to guess the password character by character.
Sounds paranoid, but it's a real thing.
Thankfully, bcrypt.compare() does constant-time comparison automatically. But I had written some custom validation logic that didn't, and that could have been vulnerable. Removed that code real quick.
Should I Have Just Used Sessions?
Here's the thing. After all this, I realized that sessions with server-side storage might have been simpler for my use case.
JWTs are great if you're building a mobile app, or if you need to share authentication across multiple services, or if you really need stateless auth. But for a standard web app talking to its own backend? Session cookies are perfectly fine and way less complicated.
But honestly, I'm glad I went through the pain of implementing JWTs. I understand the tradeoffs now. I know why someone would choose one over the other instead of just following whatever the tutorial said.
My Current Checklist
These days, before I call auth "done," I make sure:
- Tokens are in HTTP-only cookies (or use sessions)
Secureflag is set in production so cookies only go over HTTPSSameSiteattribute is configured to prevent CSRF attacks- Login endpoint has rate limiting so people can't brute force passwords
- JWT payload doesn't contain sensitive data (it's base64, not encrypted)
- Passwords are hashed with bcrypt, minimum 10 rounds
- Refresh token rotation is working if I'm using them
Still Not Totally Confident
Even now, I still get a little nervous about auth.
Every few months some new vulnerability gets disclosed and I wonder if my implementation is affected. I'll read an article about session fixation or token sidejacking and go double-check my code.
Maybe that's a good thing. Auth is one of those areas where a healthy dose of paranoia probably keeps you from getting lazy.
I'm not an expert. I'm just someone who's messed it up enough times to know what not to do.
If you're building auth for the first time, you'll probably make some of these same mistakes. That's fine. Just fix them before you go to production.