Node.js Authentication and Security: JWT, bcrypt, and Sessions
Key takeaways
Secure Node.js APIs: hash passwords with bcrypt, issue and verify JWTs, refresh tokens, express-session with Mongo store, Passport Google OAuth, Helmet, rate limits, CORS, validation, and XSS defenses.
Introduction
Authentication vs authorization
Authentication: “Who are you?” (identity) Authorization: “What may you do?” (permissions, e.g. RBAC)
This post builds authentication for an Express app from the ground up: hashing passwords with bcrypt, issuing JWT access and refresh tokens, server-side sessions, and social login with Passport’s OAuth 2.0 strategies. It then covers the surrounding defenses (Helmet, rate limiting, CORS, input validation, SQL injection and XSS), a complete user model with auth routes, and the mistakes that most often reach production, such as JWTs that cannot be revoked and in-memory session stores.
Password hashing (bcrypt)
npm install bcrypt
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
bcrypt is deliberately slow: saltRounds is a base-2 exponent, so 10 means 2^10 iterations of the key schedule and each +1 doubles the time. The point is to make an attacker who steals the database pay that cost for every guess, while a legitimate login pays it once. 10 is the library’s historic default; on current server hardware 12 is a common choice, and the right number is whatever keeps a single hash around a few hundred milliseconds on your production machines. Measure it there, not on a laptop. The salt is generated per call and stored inside the resulting string ($2b$10$<salt><hash>), which is why compare needs only the password and the stored hash.
Two properties surprise people. bcrypt only uses the first 72 bytes of input, so two long passphrases that share a 72-byte prefix hash identically; cap the length in validation or pre-hash deliberately. And the native bcrypt package needs a compiler toolchain on install; if prebuilt binaries are not available for your Node version or Alpine image, npm install fails with a node-gyp error, which is why some teams use the pure-JS bcryptjs (slower, same format) or argon2.
Registration route
app.post('/auth/register', async (req, res) => {
try {
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'All fields are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({
email,
password: hashedPassword,
name
});
const { password: _, ...safe } = user.toObject();
res.status(201).json({ message: 'Registered', user: safe });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
The findOne followed by create has a race: two simultaneous sign-ups with the same email can both pass the check. The real guard is the unique index on email in the schema; catch the duplicate-key error (MongoDB code 11000) and turn it into the same 400 response. Also note the trade-off in “Email already registered”: it is friendly, but it lets anyone test whether an address has an account. For most consumer apps that is accepted; for sensitive services, respond identically in both cases and send an email instead. Finally, returning err.message to the client in the 500 branch leaks internals such as database errors; log it and return a generic message in production.
JWT
npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be set to a long random value'); // fail at startup
}
function generateToken(payload) {
return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
}
Two lines in this setup decide whether it is secure. A fallback like process.env.JWT_SECRET || 'change-me' means that a deployment which forgot to set the variable runs normally while signing every token with a secret published in tutorials, so anyone can forge an admin token. Failing loudly at startup when the secret is missing or short turns that mistake into a deploy failure instead of a breach. The secret should be a long random value (for example openssl rand -base64 48), not a memorable phrase: HS256 tokens can be brute-forced offline if the secret is guessable, since any captured token lets an attacker test candidate secrets.
Passing algorithms: ['HS256'] to verify pins the algorithm the server accepts. JWT headers name their own algorithm, and libraries have historically had vulnerabilities where a token claiming alg: none, or claiming a symmetric algorithm when the server expected an asymmetric key, was accepted. Current versions of jsonwebtoken block the worst cases by default, but stating the expected algorithm is cheap and keeps you safe across library upgrades and other languages’ libraries.
Remember that a JWT is signed, not encrypted. Anyone holding the token can base64-decode its payload, so never put secrets or personal data in it beyond an ID and a role.
Login
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const token = jwt.sign(
{ id: user._id, email: user.email, role: user.role },
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '1h', jwtid: require('crypto').randomUUID() } // jti for the denylist below
);
res.json({
message: 'Login successful',
token,
user: {
id: user._id,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Both failure branches return the same status and message on purpose, so the response does not reveal whether the email exists. The timing still does: when the user is not found, the handler returns without running bcrypt, which is measurably faster than a wrong password. If account enumeration matters to you, run bcrypt.compare against a fixed dummy hash in the not-found branch so both paths cost the same.
Auth middleware
async function authenticate(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const user = await User.findById(decoded.id).select('-password');
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
req.tokenPayload = decoded; // jti/exp, used by logout below
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
res.status(401).json({ error: 'Invalid token' });
}
}
module.exports = { authenticate };
Notice that this middleware loads the user from the database on every request. That gives up the main selling point of JWTs, stateless verification, but it buys two important properties: a deleted or banned user is rejected immediately, and role changes take effect on the next request instead of when the token expires. Skipping the lookup and trusting decoded.role is faster, but then a demoted admin keeps admin rights for up to the token lifetime. A common compromise is short access tokens (5–15 minutes) that are trusted without a lookup, plus a database check at refresh time.
Role guard
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { authorize };
const { authenticate } = require('./middlewares/auth');
const { authorize } = require('./middlewares/authorize');
app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.status(204).send();
});
app.get('/api/reports', authenticate, authorize('admin', 'manager'), (req, res) => {
res.json({ reports: [] });
});
The status codes are not interchangeable: 401 means “we do not know who you are” (the client should log in or refresh), 403 means “we know who you are and the answer is no” (refreshing will not help). Front-end interceptors usually trigger a token refresh on 401, so returning 401 for a permission failure creates a refresh loop. Also keep in mind that a role check is only the coarse layer; DELETE /api/users/:id guarded by authorize('admin') is fine, but an endpoint like “edit my post” needs an ownership check (post.author.equals(req.user._id)) inside the handler, and forgetting that check is the most common authorization bug in APIs (OWASP calls it broken object-level authorization).
Refresh tokens
Model and routes
// models/RefreshToken.js
const mongoose = require('mongoose');
const refreshTokenSchema = new mongoose.Schema({
token: { type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now }
});
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
module.exports = mongoose.model('RefreshToken', refreshTokenSchema);
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const RefreshToken = require('./models/RefreshToken');
const JWT_SECRET = process.env.JWT_SECRET;
function generateAccessToken(user) {
return jwt.sign(
{ id: user._id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
}
async function generateRefreshToken(user) {
const token = crypto.randomBytes(40).toString('hex');
await RefreshToken.create({
token,
user: user._id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
return token;
}
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const accessToken = generateAccessToken(user);
const refreshToken = await generateRefreshToken(user);
res.json({ accessToken, refreshToken, expiresIn: 900 });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
const tokenDoc = await RefreshToken.findOne({ token: refreshToken }).populate('user');
if (!tokenDoc) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
if (tokenDoc.expiresAt < new Date()) {
await RefreshToken.deleteOne({ token: refreshToken });
return res.status(401).json({ error: 'Refresh token expired' });
}
const accessToken = generateAccessToken(tokenDoc.user);
res.json({ accessToken, expiresIn: 900 });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/auth/logout', async (req, res) => {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await RefreshToken.deleteOne({ token: refreshToken });
}
res.json({ message: 'Logged out' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
This works, but it leaves out two protections that matter once refresh tokens live for a week.
Store a hash, not the token. The refresh token is effectively a week-long password. If the database leaks, plaintext tokens can be used immediately. Since the token is 40 random bytes, a fast hash is enough (unlike passwords, there is nothing to brute-force): store crypto.createHash('sha256').update(token).digest('hex') and look up by that hash. The same reasoning is explained in the bcrypt guide for password-reset tokens.
Rotate on every refresh, and detect reuse. The /auth/refresh route above returns a new access token but keeps the same refresh token valid for the rest of the week, so a stolen refresh token works as long as the legitimate one does. With rotation, each refresh deletes the old refresh token and issues a new one. If an old, already-used token ever comes back, either the legitimate client or an attacker is replaying it, and the safe response is to revoke all of that user’s refresh tokens, forcing a new login. Keep a family id on each token so the whole chain can be revoked at once. With rotation, the refresh endpoint must be written carefully for concurrent requests: two browser tabs refreshing at the same moment will both present the same token, so allow a short grace period rather than treating that as theft.
Where the client keeps tokens matters as much as the server code. An access token in localStorage can be read by any script running on your page, so a single XSS bug or a compromised third-party script can steal it. An httpOnly, Secure, SameSite=Lax (or Strict) cookie cannot be read by JavaScript at all, which is why many teams keep at least the refresh token in such a cookie, scoped to the refresh path. Cookies bring back the need for CSRF protection on state-changing requests (the SameSite attribute handles most of it, plus a CSRF token for older browsers or cross-site setups). There is no option with zero trade-offs, but “long-lived token in localStorage” is the weakest one.
Sessions
npm install express-session connect-mongo
const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET, // no fallback: fail at startup if unset, as with JWT_SECRET
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: 'mongodb://localhost:27017/mydb',
ttl: 24 * 60 * 60
}),
cookie: {
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}
}));
With sessions the cookie holds only a random session ID, signed with secret so it cannot be tampered with; the data lives in MongoDB. resave: false stops the store from being rewritten on every request when nothing changed, which otherwise causes lost updates when two parallel requests each save their own copy. saveUninitialized: false avoids creating a stored session (and a cookie) for every anonymous visitor and bot, which matters both for store size and for cookie-consent rules. secure: true means the browser only sends the cookie over HTTPS; if your app sits behind a TLS-terminating proxy, also set app.set('trust proxy', 1), or express-session sees plain HTTP and silently never sets the cookie. That combination produces one of the most confusing bugs in Express: login returns 200, but the next request is anonymous.
sameSite: 'strict' blocks the cookie on all cross-site navigations, including a user clicking a link to your app from an email, who then appears logged out on the first page. 'lax' is the usual compromise: it still blocks cross-site POSTs (the main CSRF vector) but allows top-level GET navigation.
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Production: wrap the lines below in req.session.regenerate(...) to prevent session fixation
req.session.userId = user._id;
req.session.email = user.email;
req.session.role = user.role;
res.json({
message: 'Login successful',
user: { id: user._id, email: user.email, name: user.name }
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Login required' });
}
next();
}
app.get('/api/profile', requireAuth, async (req, res) => {
try {
const user = await User.findById(req.session.userId).select('-password');
res.json({ user });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
The login handler writes the user ID into the existing session. If an attacker can plant a known session ID in the victim’s browser before login (session fixation), they share the authenticated session afterwards. Calling req.session.regenerate() at login issues a fresh ID and closes that hole. Storing role in the session is convenient but has the same staleness problem as a role inside a JWT: a demotion takes effect only when the session expires unless you reload the user or delete that user’s sessions.
The big advantage of this model shows up in logout: destroy deletes the server record, so the cookie is worthless immediately, even if someone copied it. That is the revocation JWTs cannot give you without extra machinery, and it is why server-side sessions remain the simpler choice for a web app served from one domain.
OAuth 2.0 (Passport)
Passport splits the OAuth flow into two routes. /auth/google redirects the browser to Google’s consent screen; Google redirects back to /auth/google/callback with a one-time code, and the strategy exchanges that code for tokens and a profile on the server side, then calls your verify callback. Whatever the callback passes to done(null, user) becomes the logged-in user, and serializeUser decides what is stored in the session (only the ID here), while deserializeUser turns it back into a user object on every request.
npm install passport passport-google-oauth20 passport-github2
// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User');
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0].value
});
}
done(null, user);
} catch (err) {
done(err, null);
}
}));
passport.serializeUser((user, done) => done(null, user._id));
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
module.exports = passport;
const express = require('express');
const session = require('express-session');
const passport = require('./config/passport');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
app.get('/auth/logout', (req, res) => {
req.logout((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.redirect('/');
});
});
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) return next();
res.redirect('/login');
}
app.get('/dashboard', ensureAuthenticated, (req, res) => {
res.json({ user: req.user });
});
The errors people hit first with this setup are almost all configuration. redirect_uri_mismatch from Google means the callback URL your server sends does not exactly match one registered in the Google Cloud console, down to scheme, host, port and trailing slash; behind a proxy, a relative callbackURL is resolved against the request, so it can come out as http:// unless you set proxy: true on the strategy and trust proxy in Express. req.logout requires a callback function appears after upgrading to Passport 0.6, which made logout asynchronous (the code above already uses the callback form). Passport 0.6 also regenerates the session on login, so it handles fixation for you.
One design decision in the verify callback deserves thought: it looks users up by googleId only. If a user registered earlier with email and password and then clicks “Sign in with Google”, this code creates a second account with the same email, and create will fail on the unique email index. Linking accounts by email is tempting but only safe when the provider asserts the email is verified; otherwise someone can create a provider account with your email address and take over your local account.
Security best practices
Helmet
npm install helmet
const helmet = require('helmet');
app.use(helmet());
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"], // keywords need the inner quotes
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
The two app.use(helmet(...)) calls are alternatives: use the defaults, or the customized version, not both. In CSP, keywords such as 'self' and 'unsafe-inline' must include the single quotes inside the string; writing 'self' without them (JavaScript ['self']) produces the header default-src self, which the browser reads as a hostname called “self” and then blocks your own scripts. HSTS with preload: true is a one-way door in practice: once the domain is in browser preload lists, removing HTTPS or a subdomain that cannot serve TLS takes months to undo, so enable includeSubDomains and preload only after every subdomain is on HTTPS.
Rate limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests from this IP, please try again later.'
});
app.use('/api/', limiter);
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
});
app.post('/auth/login', loginLimiter, async (req, res) => { /* ... */ });
The login limiter with skipSuccessfulRequests counts only failed attempts, so a user who mistypes twice is fine while a password-guessing script is cut off after five failures per window. Newer versions of express-rate-limit (v7) call the option limit; max still works as an alias. Two deployment details decide whether this works at all. Behind a reverse proxy or load balancer, every request appears to come from the proxy’s IP unless you set app.set('trust proxy', ...) correctly, so the whole world shares one bucket and legitimate users get 429s; recent versions log a ERR_ERL_UNEXPECTED_X_FORWARDED_FOR validation warning when they detect this. And the default store is in memory, so with four instances each has its own counters and an attacker gets four times the budget; use a Redis store when you scale out. IP-based limits alone also do little against credential stuffing spread across many IPs, so per-account throttling is a useful second layer.
CORS
npm install cors
const cors = require('cors');
app.use(cors());
app.use(cors({
origin: 'https://yourdomain.com',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = ['https://yourdomain.com', 'https://admin.yourdomain.com'];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));
These three blocks are alternatives, not a stack; the bare cors() allows every origin and would make the later restrictions pointless if left in. CORS is also frequently misunderstood as a server-side protection. It is a browser rule about which other sites’ JavaScript may read your responses; curl or a script ignores it entirely, so it never replaces authentication. With credentials: true (needed for cookies), the browser refuses a wildcard Access-Control-Allow-Origin: *, which is why an explicit origin list is required. Returning an error from the origin callback, as the last example does, makes Express respond with a 500 for disallowed origins; callback(null, false) is quieter and simply omits the CORS headers.
Input validation
npm install express-validator
const { body, validationResult } = require('express-validator');
app.post('/auth/register',
body('email').isEmail().withMessage('Valid email required').normalizeEmail(),
body('password')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/).withMessage('Must include an uppercase letter')
.matches(/[a-z]/).withMessage('Must include a lowercase letter')
.matches(/[0-9]/).withMessage('Must include a digit')
.matches(/[@$!%*?&#]/).withMessage('Must include a special character'),
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2–50 characters'),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
/* register */
}
);
SQL injection
async function vulnerable(email) {
const query = `SELECT * FROM users WHERE email = '${email}'`;
const [rows] = await pool.query(query);
return rows;
}
async function safe(email) {
const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
return rows;
}
async function safest(email) {
return User.findOne({ email });
}
MongoDB has its own injection variant that the “safest” version above is not immune to. If email comes straight from a JSON body, an attacker can send {"email": {"$ne": null}, "password": ...} and the query matches the first user in the collection. Validating that the field is a string (express-validator’s isEmail() does this) or setting Mongoose’s sanitizeFilter option closes it.
XSS
npm install xss
const xss = require('xss');
app.post('/api/posts', async (req, res) => {
const { title, content } = req.body;
const post = await Post.create({
title: xss(title),
content: xss(content)
});
res.status(201).json(post);
});
Sanitizing on input is a reasonable backstop for fields that are meant to contain limited HTML, but the primary XSS defense is escaping on output: React, Vue and most template engines escape interpolated text by default, and XSS appears when code bypasses that (dangerouslySetInnerHTML, v-html, <%- %> in EJS). Sanitizing plain-text fields such as a title also has a cost: it permanently alters data (a title containing <3 gets mangled) and the same stored value may later be rendered in a context, such as an attribute or a JSON API consumed by a mobile app, where the HTML-oriented cleaning was irrelevant.
Full example: User model and auth routes
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, minlength: 8 },
name: { type: String, required: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
isVerified: { type: Boolean, default: false },
verificationToken: String,
resetPasswordToken: String,
resetPasswordExpires: Date,
lastLogin: Date
}, { timestamps: true });
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Moving hashing into a pre('save') hook means routes can assign a plain password and never forget to hash it, but it changes the contract: the registration route from the first section already calls bcrypt.hash, and combining it with this model would hash twice, after which no password ever matches. Pick one place. The isModified('password') check is what prevents re-hashing an existing hash whenever the user saves an unrelated field. Also note that pre('save') does not run for updateOne or findOneAndUpdate, so a password change written with those methods is stored in plaintext; route password changes through save().
// routes/auth.js
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const User = require('../models/User');
const { sendEmail } = require('../utils/email');
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const verificationToken = crypto.randomBytes(32).toString('hex');
const user = await User.create({ email, password, name, verificationToken });
const verificationUrl = `${req.protocol}://${req.get('host')}/auth/verify/${verificationToken}`;
await sendEmail({
to: email,
subject: 'Verify your email',
text: `Click to verify: ${verificationUrl}`
});
res.status(201).json({
message: 'Registered. Please check your email.',
user: { id: user._id, email: user.email, name: user.name }
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/verify/:token', async (req, res) => {
try {
const user = await User.findOne({ verificationToken: req.params.token });
if (!user) {
return res.status(400).json({ error: 'Invalid token' });
}
user.isVerified = true;
user.verificationToken = undefined;
await user.save();
res.json({ message: 'Email verified' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.json({ message: 'If an account exists, you will receive an email.' });
}
const resetToken = crypto.randomBytes(32).toString('hex');
user.resetPasswordToken = resetToken;
user.resetPasswordExpires = Date.now() + 3600000;
await user.save();
const resetUrl = `${req.protocol}://${req.get('host')}/auth/reset-password/${resetToken}`;
await sendEmail({
to: email,
subject: 'Password reset',
text: `Reset link: ${resetUrl}`
});
res.json({ message: 'If an account exists, you will receive an email.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/reset-password/:token', async (req, res) => {
try {
const { password } = req.body;
const user = await User.findOne({
resetPasswordToken: req.params.token,
resetPasswordExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
user.password = password;
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.json({ message: 'Password has been reset' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
The forgot-password route answers identically whether or not the account exists, which is the right call. The reset token itself, however, is stored in plaintext here; like refresh tokens, it is a bearer credential, so storing its SHA-256 and comparing hashes means a database leak does not hand out working reset links. Building the URL from req.get('host') is another quiet risk: the Host header comes from the client, and an attacker who triggers a reset for your address with a forged host can make the email point at their server (host header poisoning). Use a configured base URL instead. After a successful reset, also revoke the user’s refresh tokens and sessions, otherwise an attacker who already had a session keeps it.
Common pitfalls
JWT invalidation
JWTs cannot be revoked server-side by default. Use a denylist checked on each request, or prefer short-lived access tokens plus refresh rotation.
// Denylist by token id (jti), with a TTL equal to the token's remaining lifetime
app.post('/auth/logout', authenticate, async (req, res) => {
const { jti, exp } = req.tokenPayload; // set by authenticate()
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`denylist:${jti}`, '1', { EX: ttl });
res.json({ message: 'Logged out' });
});
An in-memory Set looks simpler but fails in three ways: it is empty again after every restart, it is not shared between instances behind a load balancer (the token still works on the other server), and it grows forever because nothing removes expired tokens. Storing only the token’s jti claim (add one with jwtid when signing) with a TTL equal to the remaining lifetime keeps the list small, shared, and self-cleaning. If a denylist lookup on every request feels like it defeats the point of JWTs, it partly does: with 5–15 minute access tokens, many teams skip the denylist and accept that a logged-out access token remains valid until it expires, while revoking the refresh token immediately.
In my experience, the auth bugs that reach production are rarely clever cryptographic attacks. They are defaults: a secret that falls back to a placeholder, a refresh token that never rotates, a logout that only clears client state, or a denylist that exists on one server out of four. When I review auth code, I check those four things before anything else.
Session store in memory
Do not use the default MemoryStore in production—use connect-mongo, connect-redis, etc.
Plaintext passwords
Never store raw passwords. Always hash with bcrypt/argon2 before persisting.
JWT vs sessions, password rules and secrets
JWT vs session
| JWT | Session | |
|---|---|---|
| Where | Client (often Authorization) | Server + cookie |
| Scale | Stateless | Needs shared store at scale |
| Revocation | Hard (use denylist or short TTL) | Easy (delete session) |
| Payload size | Larger | Small session id |
| Typical use | APIs, microservices | Traditional web apps |
Password policy helper
Character-class rules like the ones below are common, but current NIST guidance favors a minimum length, a maximum that fits bcrypt’s 72-byte input limit, and checking new passwords against lists of breached passwords over composition rules (see the bcrypt guide for why). Treat this helper as a starting point, not a recommendation.
function validatePassword(password) {
const errors = [];
if (password.length < 8) errors.push('At least 8 characters');
if (!/[A-Z]/.test(password)) errors.push('Include an uppercase letter');
if (!/[a-z]/.test(password)) errors.push('Include a lowercase letter');
if (!/[0-9]/.test(password)) errors.push('Include a digit');
if (!/[@$!%*?&#]/.test(password)) errors.push('Include a special character');
return errors;
}
Environment variables
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
REFRESH_SECRET=your-refresh-token-secret
SESSION_SECRET=your-session-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
require('dotenv').config();
const config = {
jwtSecret: process.env.JWT_SECRET,
refreshSecret: process.env.REFRESH_SECRET,
sessionSecret: process.env.SESSION_SECRET
};
if (!config.jwtSecret || !config.refreshSecret) {
throw new Error('JWT_SECRET and REFRESH_SECRET are required');
}
module.exports = config;