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.
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
npm init -y
npm install express mongoose bcryptjs jsonwebtoken cookie-parser express-rate-limit nodemailerKeep secrets in environment variables, never in the code or the repository:
# .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-passwordGenerate JWT_SECRET with openssl rand -base64 64. A short or guessable secret lets anyone forge a login.
3. The user model
// 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
// 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
// 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.' });
}
};// 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.
// 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:
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
Securecookie 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
| Where | How it runs | Where MongoDB lives |
|---|---|---|
| cPanel or DirectAdmin shared hosting | Setup Node.js App in the control panel; the app listens on process.env.PORT | A hosted MongoDB service, since MongoDB can't run on shared hosting |
| App Platform | Node.js apps are detected and built automatically | A hosted MongoDB service; the included database is PostgreSQL |
| VPS | Your own Node.js, Nginx and PM2 with full root access | On 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.
- 512 MB RAM per app
- 1 vCPU
- 5 GB NVMe SSD
- PostgreSQL Database
- 1 vCPU
- 2 GB DDR4 RAM
- 64 GB NVMe SSD Storage
- 2 TB Monthly Bandwidth
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.
Node.js apps detected and built automatically, with free SSL and custom domains.
See App Platform plans