MERN Stack

MERN Stack: Developing the User Authentication Service

By the Domain India teamPublished 8 min read
Knowledge base article
Contents (9 sections)

Almost every web app needs sign-up, login and password reset, and these are the parts attackers test first. This guide builds an email-and-password authentication service for a MERN app (MongoDB, Express, React, Node.js) with current practice: hashed passwords, the session token in an httpOnly cookie rather than in browser storage, rate-limited login, and reset tokens that are stored only as hashes.

Key takeaways

Hash passwords with bcrypt, keep the login token in an httpOnly, Secure, SameSite cookie that JavaScript can't read, and give it a short lifetime. Rate-limit the login and reset routes, return the same message whether or not an email exists, and store only a SHA-256 hash of each password-reset token with a short expiry. On the React side, send requests with credentials included and never put tokens in localStorage.

1. What we are building

The service has six routes: register, log in, log out, a /me route that tells the React app who is signed in, and a forgot-password and reset-password pair. Middleware protects every other route.

The examples use Node.js 22 or 24 (current LTS lines), Express 5 and Mongoose 8. Express 5 passes errors thrown in async route handlers to your error handler, so you don't need a wrapper around every route.

2. Setup and secrets

bash
npm init -y
npm install express mongoose bcryptjs jsonwebtoken cookie-parser express-rate-limit nodemailer

Keep secrets in environment variables, never in the code or the repository:

bash
# .env (add it to .gitignore)
MONGODB_URI=mongodb+srv://user:[email protected]/app
JWT_SECRET=paste-the-output-of-openssl-rand-base64-64
APP_URL=https://app.example.com
SMTP_HOST=smtp.example.com
[email protected]
SMTP_PASS=your-mailbox-password

Generate JWT_SECRET with openssl rand -base64 64. A short or guessable secret lets anyone forge a login.

3. The user model

javascript
// models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true, lowercase: true, trim: true },
  passwordHash: { type: String, required: true, select: false },
  tokenVersion: { type: Number, default: 0 },
  resetTokenHash: { type: String, select: false },
  resetTokenExpires: { type: Date, select: false },
}, { timestamps: true });

module.exports = mongoose.model('User', userSchema);

select: false keeps the hash and reset fields out of every query unless you ask for them, so they can't leak into an API response by accident. tokenVersion lets you end every session for a user by incrementing it, for example after a password reset.

4. Register, log in and log out

javascript
// routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const User = require('../models/User');

const router = express.Router();
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 });

const cookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 60 * 60 * 1000, // 1 hour
};

function setSession(res, user) {
  const token = jwt.sign({ sub: user.id, v: user.tokenVersion }, process.env.JWT_SECRET,
    { algorithm: 'HS256', expiresIn: '1h' });
  res.cookie('session', token, cookieOptions);
}

router.post('/register', limiter, async (req, res) => {
  const email = String(req.body.email || '').toLowerCase().trim();
  const password = String(req.body.password || '');
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || password.length < 12) {
    return res.status(400).json({ error: 'Enter a valid email and a password of at least 12 characters.' });
  }
  if (await User.exists({ email })) {
    return res.status(409).json({ error: 'Could not create the account.' });
  }
  const user = await User.create({ email, passwordHash: await bcrypt.hash(password, 12) });
  setSession(res, user);
  res.status(201).json({ id: user.id, email: user.email });
});

router.post('/login', limiter, async (req, res) => {
  const email = String(req.body.email || '').toLowerCase().trim();
  const user = await User.findOne({ email }).select('+passwordHash');
  const ok = user && await bcrypt.compare(String(req.body.password || ''), user.passwordHash);
  if (!ok) return res.status(401).json({ error: 'Email or password is incorrect.' });
  setSession(res, user);
  res.json({ id: user.id, email: user.email });
});

router.post('/logout', (req, res) => {
  res.clearCookie('session', { httpOnly: true, secure: true, sameSite: 'lax' });
  res.status(204).end();
});

module.exports = router;

Points worth copying:

  • One message for every login failure. "Email or password is incorrect" doesn't tell an attacker which emails have accounts.
  • bcrypt cost 12 is a sensible default in 2026. Argon2id is an equally good choice if you prefer it.
  • A minimum length of 12 does more than complexity rules. Also consider checking new passwords against a list of known breached passwords.

5. Protecting routes

javascript
// middleware/requireAuth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');

module.exports = async function requireAuth(req, res, next) {
  try {
    const payload = jwt.verify(req.cookies.session || '', process.env.JWT_SECRET,
      { algorithms: ['HS256'] });
    const user = await User.findById(payload.sub);
    if (!user || user.tokenVersion !== payload.v) throw new Error('stale');
    req.user = user;
    next();
  } catch {
    res.status(401).json({ error: 'Please sign in.' });
  }
};
javascript
// server.js
const express = require('express');
const mongoose = require('mongoose');
const cookieParser = require('cookie-parser');
const requireAuth = require('./middleware/requireAuth');

const app = express();
app.set('trust proxy', 1);          // behind Nginx or a platform proxy
app.use(express.json({ limit: '10kb' }));
app.use(cookieParser());
app.use('/api/auth', require('./routes/auth'));
app.use('/api/auth', require('./routes/reset'));

app.get('/api/me', requireAuth, (req, res) => res.json({ id: req.user.id, email: req.user.email }));

mongoose.connect(process.env.MONGODB_URI).then(() => {
  app.listen(process.env.PORT || 3000);
});

Always pass algorithms to jwt.verify, so a token that claims a different algorithm is rejected. The tokenVersion check means a password reset signs the user out everywhere. For refresh tokens, revocation lists and replay protection, see JWT security best practices.

6. Password reset

The reset link carries a random token. Store only its SHA-256 hash, so a database leak doesn't hand out working reset links.

javascript
// routes/reset.js
const crypto = require('crypto');
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const nodemailer = require('nodemailer');
const User = require('../models/User');

const router = express.Router();
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 5 });
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const mailer = nodemailer.createTransport({
  host: process.env.SMTP_HOST, port: 465, secure: true,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

router.post('/forgot', limiter, async (req, res) => {
  const email = String(req.body.email || '').toLowerCase().trim();
  const user = await User.findOne({ email });
  if (user) {
    const token = crypto.randomBytes(32).toString('hex');
    user.resetTokenHash = sha256(token);
    user.resetTokenExpires = new Date(Date.now() + 30 * 60 * 1000);
    await user.save();
    await mailer.sendMail({
      from: process.env.SMTP_USER,
      to: user.email,
      subject: 'Reset your password',
      text: `Reset your password within 30 minutes:\n${process.env.APP_URL}/reset?token=${token}\n\nIf you didn't ask for this, ignore this email.`,
    });
  }
  res.json({ message: 'If that email has an account, a reset link is on its way.' });
});

router.post('/reset', limiter, async (req, res) => {
  const password = String(req.body.password || '');
  if (password.length < 12) return res.status(400).json({ error: 'Use at least 12 characters.' });
  const user = await User.findOne({
    resetTokenHash: sha256(String(req.body.token || '')),
    resetTokenExpires: { $gt: new Date() },
  });
  if (!user) return res.status(400).json({ error: 'This link is invalid or has expired.' });
  user.passwordHash = await bcrypt.hash(password, 12);
  user.resetTokenHash = undefined;
  user.resetTokenExpires = undefined;
  user.tokenVersion += 1;           // sign out every existing session
  await user.save();
  res.json({ message: 'Password updated. Please sign in.' });
});

module.exports = router;

The forgot route answers the same way whether or not the account exists. The link points at your React app, which posts the token and the new password to /api/auth/reset.

7. The React side

Because the session is an httpOnly cookie, React never sees or stores the token. It only has to send cookies with each request:

javascript
export async function login(email, password) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error((await res.json()).error);
  return res.json();
}

export async function currentUser() {
  const res = await fetch('/api/me', { credentials: 'include' });
  return res.ok ? res.json() : null;
}

Serve the React build and the API from the same site (for example the app at / and the API at /api/ behind one domain). The cookie then works with SameSite=Lax, and you don't need CORS. If the API must live on another domain, allow only your app's exact origin with credentials, and add CSRF protection for state-changing requests.

8. Testing and hardening

  • Write tests for each route with Jest or Vitest and Supertest, including the failure cases: wrong password, expired reset link, reused reset link.
  • Serve everything over HTTPS; the Secure cookie flag stops the cookie being sent over plain HTTP.
  • Add security headers with helmet, and log failed logins without logging passwords or tokens.
  • For "Sign in with Google" instead of passwords, see MERN user authentication with OAuth 2.0.

9. Running it on Domain India

WhereHow it runsWhere MongoDB lives
cPanel or DirectAdmin shared hostingSetup Node.js App in the control panel; the app listens on process.env.PORTA hosted MongoDB service, since MongoDB can't run on shared hosting
App PlatformNode.js apps are detected and built automaticallyA hosted MongoDB service; the included database is PostgreSQL
VPSYour own Node.js, Nginx and PM2 with full root accessOn the same VPS, or a hosted service

For shared hosting, follow deploying a Node.js app on shared hosting. For a self-managed server, running MERN on a clean VPS covers Nginx, PM2 and MongoDB. Outgoing SMTP from a Node.js app on shared hosting hasn't been tested by us, so test your reset emails on your plan before you rely on them.

App Starter
₹100/mo + GST
  • 512 MB RAM per app
  • 1 vCPU
  • 5 GB NVMe SSD
  • PostgreSQL Database
See plan details
VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details

Prices on the cards are live and exclude 18% GST.

Should I store a JWT in localStorage or a cookie?

Use an httpOnly, Secure cookie with SameSite set to Lax or Strict. JavaScript can't read an httpOnly cookie, so a cross-site scripting bug can't steal the session. Tokens in localStorage can be read by any script on the page.

bcrypt or Argon2 for passwords?

Both are good choices. bcrypt with a cost of 12 is widely supported; Argon2id is the newer recommendation. Never store passwords in plain text or with a fast hash such as SHA-256.

How should password reset tokens be stored?

Generate at least 32 random bytes, email the token to the user, and store only its SHA-256 hash with a short expiry such as 30 minutes. Clear it once it is used.

Why does my login cookie not reach the API?

The browser only sends it when the fetch call uses credentials: 'include', the site runs on HTTPS for Secure cookies, and the app and API share a site or the API allows your exact origin with credentials.

Can I run MongoDB on Domain India shared hosting?

No. On shared hosting or the App Platform, connect to a hosted MongoDB service. On a Domain India VPS you can install MongoDB yourself.

Ready to deploy? Compare the App Platform and VPS plans, or open a support ticket if you're not sure which fits your app.

Deploy your MERN app

Node.js apps detected and built automatically, with free SSL and custom domains.

See App Platform plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app