MERN Secure Admin Authentication: JWT Access Tokens, Rotating Refresh Tokens and Express Middleware
Build a production-ready admin login with Node.js, Express 5 and MongoDB: bcrypt password hashing, short-lived JWTs, rotating refresh tokens in httpOnly cookies, rate limiting and protected routes, explained step by step.

Mohit Malaya Nandy
Software Engineer & Product Builder

On this page
- What you will build
- How the login flow works
- 1. Install Node.js and MongoDB
- 2. Create the project
- 3. Environment variables
- 4. Admin model
- 5. Token helpers
- 6. Controller
- 7. Auth middleware
- 8. Routes and rate limiting
- 9. Server and error handler
- 10. Create the first admin
- 11. Test with curl
- 12. React client
- Common security mistakes
- Production checklist
- FAQ
- Official references
The admin login is the most valuable door in your application. If someone gets through it, they can read and change everything. In this guide you will build a secure admin authentication backend for a MERN app (MongoDB, Express, React, Node.js), step by step and in plain English.
You will hash passwords with bcrypt, issue short-lived JWT access tokens, keep rotating refresh tokens in an httpOnly cookie, slow down password guessing with rate limiting, and protect admin routes with middleware. Every decision follows current official documentation (Express 5, Mongoose 9, Node.js 24 LTS) and the OWASP security cheat sheets. The steps work on Windows, macOS and Linux; where a command differs, pick your system from the tabs.
What you will build#
| Feature | What it means | Why it matters |
|---|---|---|
| Invite-only admins | No public sign-up. The first admin is created from the command line; later admins are created by an existing admin. | An open register route would let anyone make themselves an admin. |
| Email or phone login | Admins sign in with either identifier and a password. | Flexible, but always with one generic error message. |
| Short-lived access token | A JWT that expires after 15 minutes, sent in the Authorization header. | A stolen token is only useful for a short time. |
| Rotating refresh token | A random 7-day token in an httpOnly cookie, stored only as a hash and replaced on every use. | Sessions can be revoked, and copied tokens stop working quickly. |
| Rate limiting | At most 10 login or refresh attempts per 15 minutes per IP address. | Slows down password-guessing attacks. |
| Safe errors | Details are logged on the server; clients only see a generic message. | Error messages can leak internals that help attackers. |
How the login flow works#
Before writing code, here is the whole journey of a login, from start to finish:
- The admin sends their email (or phone) and password to
POST /api/admin/login. - The server finds the account and checks the password against the stored bcrypt hash.
- If it matches, the server sends back an access token in the JSON response and sets a refresh token as a cookie that JavaScript cannot read.
- For every protected request, the client sends
Authorization: Bearer <access token>. Middleware checks the token before the route runs. - After 15 minutes the access token expires. The client calls
POST /api/admin/refresh, the browser sends the cookie automatically, and the server returns a new access token and a new refresh cookie. - On logout, the server deletes the stored refresh token hash and clears the cookie.
Why two tokens instead of one?
A JWT is checked using only its signature, without asking the database. That makes it fast, but it also means you cannot cancel a JWT before it expires. So we keep it short-lived. The refresh token is the opposite: it is checked against the database every time, so you can revoke it instantly. The pair gives you speed for everyday requests and control over sessions.
Step 1: Install Node.js and MongoDB#
Install Node.js
Install the current LTS (long-term support) version of Node.js. Mongoose 9 needs Node.js 20.19 or newer (Mongoose 9 migration guide), and Node 24 LTS also gives you built-in .env loading. Download the installer from nodejs.org, or use a package manager:
winget install OpenJS.NodeJS.LTSOn Linux, nvm (Node Version Manager) is the simplest way to get a current version without root; follow its install instructions first.
Open a new terminal and check the versions:
node -v
npm -vGet a MongoDB database
You need a MongoDB database. Choose the option that suits you:
- MongoDB Atlas (easiest): create a free cluster at mongodb.com/atlas and copy its connection string. Nothing to install.
- Local install: follow the official MongoDB Community installation guide for your system.
- Docker (any OS): if you have Docker installed, start MongoDB with one command:
docker run -d --name mongo -p 27017:27017 mongoA local or Docker database is available at mongodb://127.0.0.1:27017.
Step 2: Create the project#
Create a folder, initialise npm, and install the packages:
mkdir admin-auth
cd admin-auth
npm init -y
npm install express mongoose bcrypt jsonwebtoken cookie-parser express-rate-limit helmet| Package | What it does |
|---|---|
| express | The web framework (version 5). |
| mongoose | Models and queries for MongoDB. |
| bcrypt | Hashes and checks passwords. |
| jsonwebtoken | Signs and verifies JWT access tokens. |
| cookie-parser | Reads cookies from requests into req.cookies. |
| express-rate-limit | Limits how often an IP can call a route. |
| helmet | Sets secure HTTP headers in one line. |
You do not need the dotenv package. Node.js can load a .env file itself with the --env-file flag. Add these scripts to package.json:
"scripts": {
"dev": "node --env-file=.env --watch server.js",
"start": "node --env-file=.env server.js",
"create-admin": "node --env-file=.env scripts/createAdmin.js"
}--watch restarts the server whenever you save a file, like nodemon but built in.
Step 3: Configure environment variables#
Secrets must never be written in your code. Generate a strong signing key for your JWTs. This command works the same in PowerShell, Command Prompt and macOS/Linux terminals:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Create a .env file and paste the value in:
PORT=4000
MONGO_URI=mongodb://127.0.0.1:27017/admin-auth
JWT_SECRET_KEY=paste-the-64-byte-hex-value-here
JWT_EXPIRES=15m
REFRESH_TOKEN_DAYS=7
BCRYPT_ROUNDS=12
NODE_ENV=developmentThen make sure Git ignores it:
node_modules/
.envStep 4: Create the Admin model#
The model describes what an admin looks like in the database. Two security details matter most here: the password is hashed automatically before saving, and sensitive fields are hidden from queries by default.
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const adminSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
phoneNumber: { type: String, unique: true, sparse: true, trim: true },
password: { type: String, required: true, select: false },
designation: { type: String, trim: true },
refreshTokenHash: { type: String, select: false },
refreshTokenExpiresAt: { type: Date, select: false },
},
{ timestamps: true }
);
// Hash the password whenever it is new or changed.
adminSchema.pre('save', async function () {
if (!this.isModified('password')) return;
const rounds = parseInt(process.env.BCRYPT_ROUNDS || '12', 10);
this.password = await bcrypt.hash(this.password, rounds);
});
// The only fields we ever send to the client.
adminSchema.methods.toSafeJSON = function () {
return {
id: this._id,
name: this.name,
email: this.email,
phoneNumber: this.phoneNumber,
designation: this.designation,
};
};
module.exports = mongoose.model('Admin', adminSchema);What each security detail does:
select: falsemeans the password and token hash are not returned by normal queries. You must ask for them explicitly with.select('+password'), so they cannot leak into an API response by accident.sparse: truelets many admins have no phone number while still keeping phone numbers unique.pre('save')is Mongoose middleware. The hook is anasyncfunction with nonextparameter. In Mongoose 9, pre hooks no longer receivenext(); older tutorials that call it will break.toSafeJSON()is an explicit allow-list of fields, which is safer than deleting fields you remember to hide.
Step 5: Write the token helpers#
Keep all token logic in one small file, so every route creates and checks tokens the same way.
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const REFRESH_COOKIE = 'refreshToken';
const refreshDays = parseInt(process.env.REFRESH_TOKEN_DAYS || '7', 10);
const refreshMs = refreshDays * 24 * 60 * 60 * 1000;
// Short-lived JWT. "sub" (subject) is the standard claim for the user id.
function signAccessToken(admin) {
return jwt.sign({ sub: admin._id.toString(), role: 'admin' }, process.env.JWT_SECRET_KEY, {
algorithm: 'HS256',
expiresIn: process.env.JWT_EXPIRES || '15m',
});
}
// Refresh tokens are random bytes, not JWTs: they only need to be unguessable.
const createRefreshToken = () => crypto.randomBytes(48).toString('hex');
// Store only a hash, so a database leak does not expose working tokens.
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
const refreshExpiry = () => new Date(Date.now() + refreshMs);
function setRefreshCookie(res, token) {
res.cookie(REFRESH_COOKIE, token, {
httpOnly: true, // JavaScript in the browser cannot read it
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // not sent on requests from other sites
path: '/api/admin', // only sent to the auth routes
maxAge: refreshMs,
});
}
const clearRefreshCookie = (res) => res.clearCookie(REFRESH_COOKIE, { path: '/api/admin' });
module.exports = {
REFRESH_COOKIE,
signAccessToken,
createRefreshToken,
hashToken,
refreshExpiry,
setRefreshCookie,
clearRefreshCookie,
};Why SHA-256 for refresh tokens but bcrypt for passwords? Passwords are short and chosen by people, so they need a deliberately slow hash to resist guessing. Refresh tokens are 48 random bytes, far too many possibilities to guess, so a fast hash is enough and keeps lookups quick.
The cookie flags are explained in MDN's guide to HTTP cookies. Together they mean the refresh token cannot be read by scripts, is never sent over plain HTTP in production, and is not sent by other websites, which protects the refresh route from cross-site request forgery (CSRF).
Step 6: Write the controller#
The controller holds the logic for each route. Notice there is no try/catch: in Express 5, if an async handler throws, the error goes to your error handler automatically (Express error handling).
const bcrypt = require('bcrypt');
const Admin = require('../models/Admin');
const t = require('../utils/tokens');
// A real hash to compare against when the account does not exist, so a
// failed lookup takes about as long as a wrong password.
const DUMMY_HASH = bcrypt.hashSync('dummy-password-for-timing', 12);
const INVALID = { message: 'Invalid credentials' };
function validatePassword(password) {
if (typeof password !== 'string') return 'Password is required';
if (password.length < 15) return 'Password must be at least 15 characters';
if (Buffer.byteLength(password, 'utf8') > 72) return 'Password is too long';
return null;
}
// Create a new session: store a fresh refresh token hash and set the cookie.
async function issueSession(res, admin) {
const refresh = t.createRefreshToken();
admin.refreshTokenHash = t.hashToken(refresh);
admin.refreshTokenExpiresAt = t.refreshExpiry();
await admin.save();
t.setRefreshCookie(res, refresh);
return t.signAccessToken(admin);
}
// POST /api/admin (protected: only an existing admin can call this)
exports.createAdmin = async (req, res) => {
const { name, email, phoneNumber, password, designation } = req.body ?? {};
if (!name || !email) {
return res.status(400).json({ message: 'name and email are required' });
}
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ message: passwordError });
// Only check the phone number if one was given.
const conflicts = [{ email: email.toLowerCase() }];
if (phoneNumber) conflicts.push({ phoneNumber });
if (await Admin.exists({ $or: conflicts })) {
return res.status(409).json({ message: 'An admin with that email or phone already exists' });
}
const admin = await Admin.create({ name, email, phoneNumber, password, designation });
res.status(201).json({ admin: admin.toSafeJSON() });
};
// POST /api/admin/login
exports.login = async (req, res) => {
const { identifier, password } = req.body ?? {};
if (typeof identifier !== 'string' || typeof password !== 'string') {
return res.status(400).json({ message: 'identifier and password are required' });
}
const query = identifier.includes('@')
? { email: identifier.toLowerCase().trim() }
: { phoneNumber: identifier.trim() };
const admin = await Admin.findOne(query).select('+password');
// Same message, status and timing whether the account exists or not.
const ok = await bcrypt.compare(password, admin ? admin.password : DUMMY_HASH);
if (!admin || !ok) return res.status(401).json(INVALID);
const accessToken = await issueSession(res, admin);
res.json({ admin: admin.toSafeJSON(), accessToken });
};
// POST /api/admin/refresh
exports.refresh = async (req, res) => {
const incoming = req.cookies[t.REFRESH_COOKIE];
if (!incoming) return res.status(401).json({ message: 'Not authenticated' });
const admin = await Admin.findOne({ refreshTokenHash: t.hashToken(incoming) })
.select('+refreshTokenHash +refreshTokenExpiresAt');
if (!admin || admin.refreshTokenExpiresAt < new Date()) {
t.clearRefreshCookie(res);
return res.status(401).json({ message: 'Session expired' });
}
// Rotation: replace the token, so the one just used can never work again.
const accessToken = await issueSession(res, admin);
res.json({ accessToken });
};
// POST /api/admin/logout
exports.logout = async (req, res) => {
const incoming = req.cookies[t.REFRESH_COOKIE];
if (incoming) {
await Admin.updateOne(
{ refreshTokenHash: t.hashToken(incoming) },
{ $unset: { refreshTokenHash: '', refreshTokenExpiresAt: '' } }
);
}
t.clearRefreshCookie(res);
res.status(204).end();
};
// GET /api/admin/me
exports.me = async (req, res) => {
const admin = await Admin.findById(req.admin.id);
if (!admin) return res.status(401).json({ message: 'Not authenticated' });
res.json({ admin: admin.toSafeJSON() });
};The important decisions, explained
- One error for every failed login. If the API said "admin not found" for unknown emails, an attacker could test a list of emails and learn which ones are admins. OWASP's Authentication Cheat Sheet recommends the same message, the same status code and similar timing for both cases. That is why we compare against
DUMMY_HASHwhen no account is found. - Password length rules. OWASP, following NIST guidance, considers passwords under 15 characters weak without multi-factor authentication. The 72-byte limit protects against bcrypt silently ignoring the rest of a long password.
- The duplicate check only uses fields that were sent. Querying
{ phoneNumber: undefined }would match every admin without a phone number and wrongly report a duplicate. req.body ?? {}: in Express 5,req.bodyisundefinedwhen a request has no JSON body, so destructuring it directly would crash.- Rotation on every refresh. Because each refresh replaces the stored hash, a copied refresh token stops working as soon as either copy is used.
Step 7: Protect routes with middleware#
Middleware runs before a route handler. This one checks the access token and either lets the request continue or stops it with a 401 (not logged in) or 403 (not allowed).
const jwt = require('jsonwebtoken');
module.exports = function adminAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization || '').split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ message: 'Not authenticated' });
}
try {
// Pin the algorithm so a token signed any other way is rejected.
const payload = jwt.verify(token, process.env.JWT_SECRET_KEY, { algorithms: ['HS256'] });
if (payload.role !== 'admin') {
return res.status(403).json({ message: 'Admins only' });
}
req.admin = { id: payload.sub };
next();
} catch {
// Expired, tampered with, or signed with another key.
return res.status(401).json({ message: 'Invalid or expired token' });
}
};Pinning algorithms: ['HS256'] follows the JWT Best Current Practices (RFC 8725). It blocks "algorithm confusion" attacks, where an attacker crafts a token that claims a different (or no) signing algorithm.
Step 8: Add routes and rate limiting#
const express = require('express');
const { rateLimit } = require('express-rate-limit');
const adminAuth = require('../middleware/adminAuth');
const c = require('../controllers/adminController');
const router = express.Router();
// At most 10 attempts per 15 minutes per IP address.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { message: 'Too many attempts, please try again later' },
});
router.post('/login', authLimiter, c.login);
router.post('/refresh', authLimiter, c.refresh);
router.post('/logout', c.logout);
router.get('/me', adminAuth, c.me);
router.post('/', adminAuth, c.createAdmin);
module.exports = router;These options follow the current express-rate-limit documentation: limit replaced the old max option, draft-8 sends the latest standard RateLimit headers, and the legacy X-RateLimit-* headers are turned off.
Step 9: Create the server and error handler#
const express = require('express');
const mongoose = require('mongoose');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const app = express();
// Only if you deploy behind a proxy or load balancer (most hosts do),
// so rate limiting sees the visitor's real IP address.
if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);
app.use(helmet());
app.use(express.json({ limit: '10kb' }));
app.use(cookieParser());
app.use('/api/admin', require('./routes/admin'));
// Error handler: has four arguments. Log the details, send a generic message.
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ message: 'Something went wrong' });
});
async function start() {
await mongoose.connect(process.env.MONGO_URI);
app.listen(process.env.PORT, () => {
console.log(`Server running on http://localhost:${process.env.PORT}`);
});
}
start();helmet()sets security headers such asStrict-Transport-SecurityandX-Content-Type-Options, as the Express security best practices recommend.express.json({ limit: '10kb' })rejects huge request bodies that could be used to overload the server.trust proxyis explained in Express behind proxies. Without it on a hosted server, every request would seem to come from the proxy, and one user could trigger the rate limit for everyone.- The error handler never sends
err.messageto the client. Database errors and stack traces can reveal details that help attackers.
Start the development server:
npm run devStep 10: Create the first admin#
Creating an admin requires being an admin, so the very first account is made from the command line instead of a public sign-up route:
const mongoose = require('mongoose');
const Admin = require('../models/Admin');
async function main() {
const [name, email, password] = process.argv.slice(2);
if (!name || !email || !password || password.length < 15 || Buffer.byteLength(password) > 72) {
console.error('Usage: npm run create-admin -- "<name>" <email> "<password, 15+ characters>"');
process.exit(1);
}
await mongoose.connect(process.env.MONGO_URI);
await Admin.create({ name, email, password });
console.log('Admin created:', email);
await mongoose.disconnect();
}
main();npm run create-admin -- "Jane Doe" jane@example.com "correct horse battery staple"The -- tells npm to pass everything after it to the script.
Step 11: Test the API with curl#
Save the login details in a file. Using a file avoids the different quoting rules between PowerShell and macOS/Linux shells:
{ "identifier": "jane@example.com", "password": "correct horse battery staple" }Log in and save the cookie to cookies.txt. On Windows, type curl.exe, because in Windows PowerShell 5.1 curl is a shortcut for a different command:
curl.exe -i -c cookies.txt -H "Content-Type: application/json" -d "@login.json" http://localhost:4000/api/admin/loginThe response includes a Set-Cookie: refreshToken=…; HttpOnly; SameSite=Strict header and an accessToken in the body. Use the token to call a protected route (replace TOKEN):
curl.exe -H "Authorization: Bearer TOKEN" http://localhost:4000/api/admin/meGet a new access token using the saved cookie. This also rotates the cookie:
curl.exe -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/admin/refreshThings to check:
- A wrong password and an unknown email both return 401 with the same message.
- Calling
/mewithout a token returns 401. - Running the refresh command with an old copy of
cookies.txtfails, proving rotation works. - The 11th login attempt within 15 minutes returns 429 (Too Many Requests).
Step 12: Use it from the React admin panel#
On the frontend, keep the access token in memory and let the browser handle the cookie. This small wrapper around fetch refreshes the token automatically when it expires:
let accessToken = null;
let refreshing = null;
export function setAccessToken(token) {
accessToken = token;
}
// Share one refresh between parallel requests: with rotation, two refreshes
// at once would make the second one fail and log the admin out.
function refreshAccessToken() {
refreshing ??= fetch('/api/admin/refresh', { method: 'POST', credentials: 'include' })
.then(async (res) => {
accessToken = res.ok ? (await res.json()).accessToken : null;
return res.ok;
})
.finally(() => {
refreshing = null;
});
return refreshing;
}
export async function api(path, options = {}) {
const send = () =>
fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
},
});
let res = await send();
if (res.status === 401 && (await refreshAccessToken())) {
res = await send();
}
return res;
}After login, call setAccessToken(data.accessToken). When the page reloads, the token in memory is gone, so call refreshAccessToken() once on startup to restore the session from the cookie.
Final project structure#
admin-auth/
├── controllers/
│ └── adminController.js
├── middleware/
│ └── adminAuth.js
├── models/
│ └── Admin.js
├── routes/
│ └── admin.js
├── scripts/
│ └── createAdmin.js
├── utils/
│ └── tokens.js
├── .env # secrets (do not commit)
├── .gitignore
├── package.json
└── server.jsCommon security mistakes this design avoids#
| Mistake | Risk | What we do instead |
|---|---|---|
| A public /register route for admins | Anyone can make themselves an admin. | Admins are created by a CLI script or an existing admin. |
| “User not found” vs “Wrong password” | Attackers learn which accounts exist. | One generic 401 response with similar timing. |
| Tokens in localStorage | Any XSS bug can steal them. | Access token in memory, refresh token in an httpOnly cookie. |
| Long-lived JWTs (days) | A stolen token works for days and cannot be revoked. | 15-minute JWTs plus revocable refresh tokens. |
| Refresh tokens stored as plain text | A database leak gives out working sessions. | Only a SHA-256 hash is stored. |
| Refresh tokens that never change | A copied token works until it expires. | Rotation on every refresh. |
| Not pinning the JWT algorithm | Algorithm-confusion attacks. | algorithms: ['HS256'] in jwt.verify. |
| Sending err.message to clients | Leaks internals that help attackers. | Log on the server, return a generic message. |
| No rate limiting | Unlimited password guessing. | express-rate-limit on login and refresh. |
Production checklist#
- Serve everything over HTTPS and set
NODE_ENV=production, so the cookie gets theSecureflag. - Use a random 64-byte
JWT_SECRET_KEY, stored in your host's secret settings, and rotate it if it is ever exposed. - Keep access tokens short-lived (5–15 minutes).
- Rate-limit login and refresh, and log failed attempts so you can spot attacks.
- Set
trust proxycorrectly for your host. - Add multi-factor authentication for admins. OWASP calls MFA the best defense against most password attacks.
- Keep dependencies updated and run
npm auditregularly.
Frequently asked questions#
Should I store a JWT in localStorage?
Avoid it for admin panels. Any script that runs on your page, including one injected through an XSS bug, can read localStorage. Keep the short-lived access token in memory and the refresh token in an httpOnly cookie that JavaScript cannot read.
Why use both an access token and a refresh token?
The access token is short-lived (about 15 minutes), so a stolen one is only useful briefly. The refresh token lasts longer but is stored only as a hash on the server and can be revoked at any time, which a plain JWT cannot.
What is refresh token rotation?
Every time the refresh token is used, the server issues a new one and invalidates the old one. A copied token therefore stops working as soon as either the attacker or the real user uses it.
Is bcrypt still safe for passwords?
Yes, with a work factor of at least 10 (this guide uses 12) and passwords capped at 72 bytes. OWASP now prefers Argon2id for new systems, and you can swap it in without changing the rest of this design.
Should the login error say whether the email exists?
No. OWASP recommends the same response, status code and similar timing for an unknown account and a wrong password, so attackers cannot discover which accounts exist.
Do I still need try/catch in Express route handlers?
Not in Express 5. If an async route handler throws or its promise rejects, Express forwards the error to your error-handling middleware automatically.
Official references#
The commands, options and security recommendations in this guide come from these sources:
- Error Handling, Security Best Practices and Behind Proxies, Express 5 documentation
- Middleware and Migrating to Mongoose 9, Mongoose documentation
- --env-file and --watch, Node.js CLI documentation
- jsonwebtoken, bcrypt, express-rate-limit and Helmet package documentation
- Authentication, Password Storage and Session Management, OWASP Cheat Sheet Series
- RFC 8725: JSON Web Token Best Current Practices, IETF
- Using HTTP cookies, MDN Web Docs
- Install MongoDB Community Edition, MongoDB documentation
Found this useful? You can support my writing on GitHub Sponsors or with a coffee.


