Understanding Mongoose Authentication
Authentication in Mongoose-based applications is the process of verifying user identity before granting access to protected resources. Mongoose, as the MongoDB object modeling tool for Node.js, handles data persistence, but authentication logic typically combines Mongoose schemas with dedicated authentication libraries. The three dominant patterns—JWT (JSON Web Tokens), session-based authentication, and OAuth integration—each solve different architectural needs.
Why authentication matters: Without robust authentication, your API exposes sensitive user data, allows unauthorized mutations, and violates basic security principles. A well-implemented authentication layer protects user privacy, complies with regulations like GDPR, and establishes trust. Mongoose's schema validation and middleware hooks make it an ideal partner for embedding authentication logic directly into your data models.
Setting Up the Mongoose User Model
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Every authentication system begins with a properly designed User schema. This schema stores credentials, handles password hashing, and provides methods for verification.
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
validate: {
validator: function(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
},
message: 'Please enter a valid email address'
}
},
password: {
type: String,
required: true,
minlength: 8
},
name: {
type: String,
required: true,
trim: true
},
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user'
},
refreshToken: {
type: String,
default: null
},
oauthProvider: {
type: String,
enum: ['local', 'google', 'github', 'facebook'],
default: 'local'
},
oauthId: {
type: String,
default: null
},
createdAt: {
type: Date,
default: Date.now
}
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Compare password method
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// Remove password from JSON output
userSchema.methods.toJSON = function() {
const obj = this.toObject();
delete obj.password;
delete obj.refreshToken;
delete obj.__v;
return obj;
};
const User = mongoose.model('User', userSchema);
module.exports = User;
Key design decisions in this schema: The email field uses a custom validator with regex for format checking. The password is hashed using bcrypt with a salt rounds value of 12—balancing security and performance. The toJSON() method strips sensitive fields from API responses, preventing accidental password leaks. The refreshToken field supports token rotation strategies we'll explore later.
JWT Authentication with Mongoose
What JWT Authentication Is
JWT authentication issues cryptographically signed tokens that clients store and attach to subsequent requests. Each token contains encoded claims—typically a user ID, role, and expiration timestamp. The server verifies the signature without querying the database on every request, making JWT ideal for stateless, high-throughput APIs and microservices.
Why JWT Matters for Mongoose Applications
JWT eliminates server-side session storage, reducing database load and enabling horizontal scaling without sticky sessions. For Mongoose-backed REST APIs and GraphQL servers, JWT provides a clean separation between authentication (verifying the token) and authorization (checking the user's role from the decoded payload). The stateless nature means your Mongoose models focus on business logic rather than session management.
Implementing JWT Authentication
Install required dependencies:
npm install jsonwebtoken bcryptjs cookie-parser
Create a dedicated JWT utility module for token generation and verification:
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-change-in-production';
const tokenUtils = {
// Generate access token (short-lived)
generateAccessToken(user) {
const payload = {
userId: user._id.toString(),
email: user.email,
role: user.role
};
return jwt.sign(payload, JWT_SECRET, {
expiresIn: '15m',
issuer: 'your-app-name',
audience: 'your-app-users'
});
},
// Generate refresh token (long-lived)
generateRefreshToken(user) {
const payload = {
userId: user._id.toString(),
tokenVersion: user.tokenVersion || 0
};
return jwt.sign(payload, JWT_REFRESH_SECRET, {
expiresIn: '7d',
issuer: 'your-app-name',
audience: 'your-app-users'
});
},
// Verify access token
verifyAccessToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'your-app-name',
audience: 'your-app-users'
});
return { valid: true, expired: false, decoded };
} catch (error) {
if (error.name === 'TokenExpiredError') {
return { valid: true, expired: true, decoded: jwt.decode(token) };
}
return { valid: false, expired: false, decoded: null };
}
},
// Verify refresh token
verifyRefreshToken(token) {
try {
const decoded = jwt.verify(token, JWT_REFRESH_SECRET, {
issuer: 'your-app-name',
audience: 'your-app-users'
});
return { valid: true, decoded };
} catch (error) {
return { valid: false, decoded: null };
}
},
// Decode token without verification (for extracting userId)
decodeToken(token) {
return jwt.decode(token);
}
};
module.exports = tokenUtils;
Now implement the authentication middleware that protects routes:
const tokenUtils = require('../utils/tokenUtils');
const User = require('../models/User');
const authMiddleware = {
// Verify access token and attach user to request
async authenticate(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Authentication required',
message: 'Please provide a valid access token in the Authorization header'
});
}
const token = authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({
error: 'Authentication required',
message: 'Token not found in Authorization header'
});
}
const result = tokenUtils.verifyAccessToken(token);
if (!result.valid) {
return res.status(401).json({
error: 'Invalid token',
message: 'The provided token is invalid or has been tampered with'
});
}
if (result.expired) {
return res.status(401).json({
error: 'Token expired',
message: 'Your access token has expired, please refresh your token'
});
}
// Fetch user from database to ensure they still exist and have proper permissions
const user = await User.findById(result.decoded.userId).select('-password -refreshToken');
if (!user) {
return res.status(401).json({
error: 'User not found',
message: 'The user associated with this token no longer exists'
});
}
// Attach user to request object for downstream handlers
req.user = user;
req.tokenPayload = result.decoded;
next();
} catch (error) {
return res.status(500).json({
error: 'Authentication error',
message: 'An unexpected error occurred during authentication'
});
}
},
// Role-based authorization middleware
authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: 'Authentication required',
message: 'You must be authenticated to access this resource'
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
message: `You need one of the following roles: ${allowedRoles.join(', ')}`
});
}
next();
};
},
// Optional authentication (doesn't fail if no token)
async optionalAuth(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
const result = tokenUtils.verifyAccessToken(token);
if (result.valid && !result.expired) {
const user = await User.findById(result.decoded.userId).select('-password -refreshToken');
if (user) {
req.user = user;
req.tokenPayload = result.decoded;
}
}
}
// Always proceed, even without authentication
next();
} catch (error) {
next(); // Proceed without authentication on error
}
}
};
module.exports = authMiddleware;
Create the authentication routes for login, registration, and token refresh:
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const tokenUtils = require('../utils/tokenUtils');
const authMiddleware = require('../middleware/authMiddleware');
// Register a new user
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email: email.toLowerCase() });
if (existingUser) {
return res.status(409).json({
error: 'User already exists',
message: 'An account with this email address already exists'
});
}
// Create new user
const user = new User({
email: email.toLowerCase(),
password, // Will be hashed by the pre-save hook
name,
oauthProvider: 'local'
});
await user.save();
// Generate tokens
const accessToken = tokenUtils.generateAccessToken(user);
const refreshToken = tokenUtils.generateRefreshToken(user);
// Store refresh token hash in database for tracking
user.refreshToken = refreshToken;
await user.save();
res.status(201).json({
message: 'User registered successfully',
user: user.toJSON(),
accessToken,
refreshToken
});
} catch (error) {
if (error.name === 'ValidationError') {
const messages = Object.values(error.errors).map(e => e.message);
return res.status(400).json({
error: 'Validation failed',
messages
});
}
res.status(500).json({
error: 'Registration failed',
message: 'An unexpected error occurred during registration'
});
}
});
// Login user
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email: email.toLowerCase() });
if (!user) {
return res.status(401).json({
error: 'Invalid credentials',
message: 'No account found with this email address'
});
}
// Check if user uses local authentication
if (user.oauthProvider !== 'local') {
return res.status(401).json({
error: 'OAuth account',
message: `This account uses ${user.oauthProvider} authentication. Please sign in with ${user.oauthProvider}.`
});
}
// Verify password
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({
error: 'Invalid credentials',
message: 'Incorrect password'
});
}
// Generate tokens
const accessToken = tokenUtils.generateAccessToken(user);
const refreshToken = tokenUtils.generateRefreshToken(user);
// Store refresh token
user.refreshToken = refreshToken;
await user.save();
res.json({
message: 'Login successful',
user: user.toJSON(),
accessToken,
refreshToken
});
} catch (error) {
res.status(500).json({
error: 'Login failed',
message: 'An unexpected error occurred during login'
});
}
});
// Refresh access token
router.post('/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
error: 'Refresh token required',
message: 'Please provide a refresh token'
});
}
const result = tokenUtils.verifyRefreshToken(refreshToken);
if (!result.valid) {
return res.status(401).json({
error: 'Invalid refresh token',
message: 'The refresh token is invalid or expired, please login again'
});
}
// Find user and verify they still exist
const user = await User.findById(result.decoded.userId);
if (!user) {
return res.status(401).json({
error: 'User not found',
message: 'The user associated with this token no longer exists'
});
}
// Check if the stored refresh token matches (optional but adds security)
if (user.refreshToken !== refreshToken) {
// Potential token reuse detected — invalidate all tokens
user.refreshToken = null;
await user.save();
return res.status(401).json({
error: 'Token reuse detected',
message: 'This refresh token has already been used. Please login again for security.'
});
}
// Generate new access token
const newAccessToken = tokenUtils.generateAccessToken(user);
// Generate new refresh token (rotation for security)
const newRefreshToken = tokenUtils.generateRefreshToken(user);
user.refreshToken = newRefreshToken;
await user.save();
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
} catch (error) {
res.status(500).json({
error: 'Token refresh failed',
message: 'An unexpected error occurred during token refresh'
});
}
});
// Logout - invalidate refresh token
router.post('/logout', authMiddleware.authenticate, async (req, res) => {
try {
req.user.refreshToken = null;
await req.user.save();
res.json({ message: 'Logged out successfully' });
} catch (error) {
res.status(500).json({
error: 'Logout failed',
message: 'An unexpected error occurred during logout'
});
}
});
// Get current user profile
router.get('/me', authMiddleware.authenticate, async (req, res) => {
res.json({ user: req.user });
});
// Admin-only route example
router.get('/admin/users',
authMiddleware.authenticate,
authMiddleware.authorize('admin'),
async (req, res) => {
const users = await User.find().select('-password -refreshToken');
res.json({ users });
}
);
module.exports = router;
JWT Best Practices
- Short-lived access tokens: Keep access tokens to 15-30 minutes. This limits the damage window if a token is compromised.
- Refresh token rotation: Issue a new refresh token with each refresh request. If a refresh token is reused, invalidate all tokens for that user—this detects token theft.
- Store secrets in environment variables: Never hardcode JWT secrets. Use
process.envwith different secrets for access and refresh tokens. - Include audience and issuer claims: The
audandissclaims prevent tokens issued for one service from being used on another. - Validate user existence on every request: Even though JWT is stateless, verify the user still exists in MongoDB to handle account deletion scenarios.
- Use HTTPS exclusively: Never transmit tokens over unencrypted connections. Use secure cookies or Authorization headers over HTTPS.
- Implement token blacklisting for critical operations: Maintain a Redis set of revoked tokens for actions like password changes or account deletion.
Session-Based Authentication with Mongoose
What Session Authentication Is
Session-based authentication stores user identity on the server after successful login. A session identifier—typically a random, opaque string—is stored in a cookie on the client. On each request, the server looks up the session in the database or an in-memory store like Redis, retrieves the associated user data, and attaches it to the request context.
Why Session Authentication Matters
Sessions provide immediate revocation capability: deleting the session record instantly invalidates access. This contrasts with JWT, where tokens remain valid until expiration unless you implement a blacklist. Sessions excel in traditional server-rendered applications (Express + EJS/Pug), where the server controls the full request lifecycle. For Mongoose applications, sessions stored in MongoDB offer persistence across server restarts without external dependencies.
Implementing Session Authentication
Install the required packages:
npm install express-session connect-mongo
Create a session store backed by MongoDB using connect-mongo, which leverages Mongoose's connection:
const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const mongoose = require('mongoose');
const app = express();
// Configure session middleware
app.use(session({
secret: process.env.SESSION_SECRET || 'a-strong-secret-for-sessions',
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
maxAge: 24 * 60 * 60 * 1000, // 24 hours
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
domain: process.env.COOKIE_DOMAIN || undefined
},
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI || 'mongodb://localhost:27017/yourapp',
collectionName: 'sessions',
ttl: 24 * 60 * 60, // 24 hours in seconds
autoRemove: 'native',
crypto: {
secret: process.env.SESSION_STORE_SECRET || 'another-strong-secret'
},
touchAfter: 3 * 60 // Update session every 3 minutes max to reduce DB writes
}),
name: 'app.sid' // Custom session cookie name to avoid default fingerprinting
}));
Create the login and session management routes:
const router = require('express').Router();
const User = require('../models/User');
// Session-based login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email: email.toLowerCase() });
if (!user) {
return res.status(401).json({
error: 'Invalid credentials',
message: 'No account found with this email'
});
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({
error: 'Invalid credentials',
message: 'Incorrect password'
});
}
// Regenerate session to prevent session fixation
req.session.regenerate(async (err) => {
if (err) {
return res.status(500).json({
error: 'Session error',
message: 'Failed to create session'
});
}
// Store user data in session
req.session.userId = user._id.toString();
req.session.email = user.email;
req.session.role = user.role;
req.session.loginTime = new Date().toISOString();
req.session.userAgent = req.headers['user-agent'] || 'unknown';
req.session.ipAddress = req.ip;
// Save session explicitly
req.session.save((saveErr) => {
if (saveErr) {
return res.status(500).json({
error: 'Session save error',
message: 'Failed to persist session'
});
}
res.json({
message: 'Login successful',
user: user.toJSON()
});
});
});
} catch (error) {
res.status(500).json({
error: 'Login failed',
message: 'An unexpected error occurred'
});
}
});
// Session check middleware
function requireSession(req, res, next) {
if (!req.session || !req.session.userId) {
return res.status(401).json({
error: 'Authentication required',
message: 'Please login to access this resource'
});
}
next();
}
// Attach user to request from session
async function attachUser(req, res, next) {
if (req.session && req.session.userId) {
try {
const user = await User.findById(req.session.userId).select('-password -refreshToken');
if (user) {
req.user = user;
req.isAuthenticated = true;
} else {
// User deleted — destroy session
req.session.destroy();
req.isAuthenticated = false;
}
} catch (error) {
req.isAuthenticated = false;
}
} else {
req.isAuthenticated = false;
}
next();
}
// Logout - destroy session
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({
error: 'Logout failed',
message: 'Could not destroy session'
});
}
res.clearCookie('app.sid', {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
res.json({ message: 'Logged out successfully' });
});
});
// Get current session info
router.get('/session', requireSession, async (req, res) => {
const user = await User.findById(req.session.userId).select('-password -refreshToken');
res.json({
session: {
id: req.session.id,
loginTime: req.session.loginTime,
userAgent: req.session.userAgent
},
user
});
});
module.exports = { router, requireSession, attachUser };
Apply the session middleware globally and protect routes:
const express = require('express');
const app = express();
const { attachUser, requireSession } = require('./routes/sessionAuth');
// Apply attachUser globally for optional authentication
app.use(attachUser);
// Public route
app.get('/api/public', (req, res) => {
res.json({ message: 'Public data', authenticated: req.isAuthenticated });
});
// Protected route requiring active session
app.get('/api/protected', requireSession, (req, res) => {
res.json({
message: 'Protected data',
user: req.user
});
});
// Admin route with role check
app.get('/api/admin', requireSession, (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
error: 'Forbidden',
message: 'Admin access required'
});
}
res.json({ message: 'Admin data', user: req.user });
});
Session Authentication Best Practices
- Regenerate session on login: Always call
req.session.regenerate()after successful authentication to prevent session fixation attacks. - Set cookie flags properly: Use
httpOnly: trueto prevent JavaScript access,secure: truein production for HTTPS-only cookies, andsameSite: 'lax'for CSRF protection. - Use MongoDB TTL indexes:
connect-mongoautomatically creates TTL indexes on the sessions collection. Verify these exist to ensure expired sessions are cleaned up. - Store minimal session data: Only store user ID, role, and essential metadata in the session. Fetch the full user object from MongoDB when needed.
- Implement session fixation protection: Change the session ID on login and periodically during long-lived sessions.
- Monitor session store size: Sessions in MongoDB can grow large. Set appropriate TTL values and monitor collection size.
- Handle session store errors gracefully: The session store can fail. Implement retry logic or fallback to an in-memory store for high availability.
OAuth Integration with Mongoose
What OAuth Integration Is
OAuth integration delegates authentication to trusted third-party providers like Google, GitHub, or Facebook. Instead of managing passwords, your application redirects users to the provider, receives an authorization code, exchanges it for an access token, and uses that token to fetch the user's profile information. This profile data is then used to find or create a corresponding Mongoose User document.
Why OAuth Matters
OAuth reduces friction in the onboarding process—users can sign up with one click using an existing account. It also shifts the burden of password management and security to specialized providers. For applications that integrate with third-party services (like accessing a user's Google Drive or GitHub repositories), OAuth provides both authentication and API authorization in a single flow.
Implementing Google OAuth Integration
Install the Google OAuth library and configure it:
npm install googleapis @google-oauth2 passport passport-google-oauth20
Set up Passport with the Google strategy and connect it to Mongoose:
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: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback',
passReqToCallback: true,
scope: ['profile', 'email']
}, async (req, accessToken, refreshToken, profile, done) => {
try {
// Check if user already exists with this OAuth ID
let user = await User.findOne({
oauthProvider: 'google',
oauthId: profile.id
});
if (user) {
// Update tokens and profile info
user.name = profile.displayName || user.name;
user.email = profile.emails[0].value || user.email;
user.refreshToken = refreshToken || user.refreshToken;
await user.save();
return done(null, user);
}
// Check if user exists with same email but different auth method
const email = profile.emails[0]?.value;
if (email) {
const existingUser = await User.findOne({ email: email.toLowerCase() });
if (existingUser) {
// Link OAuth to existing account
existingUser.oauthProvider = 'google';
existingUser.oauthId = profile.id;
existingUser.refreshToken = refreshToken;
await existingUser.save();
return done(null, existingUser);
}
}
// Create new user from Google profile
user = new User({
email: email || `${profile.id}@google.oauth`,
name: profile.displayName,
password: await require('crypto').randomBytes(32).toString('hex'), // Random secure password
oauthProvider: 'google',
oauthId: profile.id,
refreshToken: refreshToken
});
await user.save();
return done(null, user);
} catch (error) {
return done(error, null);
}
}));
// Serialize user for session (if using sessions alongside OAuth)
passport.serializeUser((user, done) => {
done(null, user._id.toString());
});
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id).select('-password -refreshToken');
done(null, user);
} catch (error) {
done(error, null);
}
});
module.exports = passport;
Create the OAuth routes for initiating and handling the callback:
const express = require('express');
const router = express.Router();
const passport = require('../config/passport');
const tokenUtils = require('../utils/tokenUtils');
// Initiate Google OAuth flow
router.get('/google', (req, res, next) => {
// Store the intended redirect URL in the state parameter
const state = req.query.redirect || '/';
const stateEncoded = Buffer.from(JSON.stringify({ redirect: state })).toString('base64');
passport.authenticate('google', {
scope: ['profile', 'email'],
state: stateEncoded,
prompt: 'select_account' // Allow user to choose account
})(req, res, next);
});
// Google OAuth callback
router.get('/google/callback',
passport.authenticate('google', {
failureRedirect: '/login?error=oauth_failed',
session: false // Disable session if using JWT
}),
async (req, res) => {
try {
// Generate JWT tokens for the authenticated user
const accessToken = tokenUtils.generateAccessToken(req.user);
const refreshToken = tokenUtils.generateRefreshToken(req.user);
req.user.refreshToken = refreshToken;
await req.user.save();
// Parse the state parameter to get redirect URL
let redirectUrl = process.env.CLIENT_URL || 'http://localhost:3000/dashboard';
if (req.query.state) {
try {
const stateObj = JSON.parse(Buffer.from(req.query.state, 'base64').toString());
if (stateObj.redirect) {
redirectUrl = stateObj.redirect;
}
} catch (e) {
// Invalid state, use default redirect
}
}
// Redirect with tokens (in production, use a secure method to pass tokens)
const params = new URLSearchParams({
accessToken,
refreshToken,
user: JSON.stringify(req.user.toJSON())
});
res.redirect(`${redirectUrl}?${params.toString()}`);
} catch (error) {
res.redirect('/login?error=token_generation_failed');
}
}
);
// GitHub OAuth routes follow the same pattern
router.get('/github', passport.authenticate('github', {
scope: ['user:email']
}));
router.get('/github/callback',
passport.authenticate('github', {
failureRedirect: '/login?error=oauth_failed',
session: false
}),
async (req, res) => {
const accessToken = tokenUtils.generateAccessToken(req.user);
const refreshToken = tokenUtils.generateRefreshToken(req.user);
req.user.refreshToken = refreshToken;
await req.user.save();
res.redirect(`${process.env.CLIENT_URL}?accessToken=${accessToken}&refreshToken=${refreshToken}`);
}
);
// Link additional OAuth providers to existing account
router.post('/link/google', tokenUtils.verifyAccessToken, async (req, res) => {
// This would redirect to Google OAuth with a linking state parameter
const linkState = Buffer.from(JSON.stringify({
action: 'link',
userId: req.user._id.toString()
})).toString('base64');
res.redirect(`/auth/google?state=${linkState}`);
});
module.exports = router;
Handle account linking in the Passport callback by checking the state parameter:
// Inside the GoogleStrategy callback, add this logic before finding the user:
if (req.query.state) {
try {
const stateObj = JSON.parse(Buffer.from(req.query.state, 'base64').toString());
if (stateObj.action === 'link' && stateObj.userId) {
// User wants to link this Google account to an existing account
const existingUser = await User.findById(stateObj.userId);
if (existingUser) {
// Check if this Google account is already linked to another user
const googleUser = await User.findOne({
oauthProvider: 'google',
oauthId: profile.id
});
if (googleUser && googleUser._id.toString() !== stateObj.userId) {
return done(new Error('This Google account is already linked to another user'), null);
}
// Link the Google account
existingUser.oauthProvider = 'google';
existingUser.oauthId = profile.id;
existingUser.email = profile.emails[0]?.value || existingUser.email;
await existingUser.save();
return done(null, existingUser);
}
}
} catch (e) {
// Invalid state, proceed with normal flow
}
}
OAuth Integration Best Practices
- Verify the state parameter: The OAuth state parameter prevents CSRF attacks. Store a cryptographically random value and verify it in the callback.
- Handle email conflicts gracefully: When a user tries to authenticate with Google but an account with that email already exists (created locally), offer to link accounts rather than creating duplicates.
- Use PKCE for mobile and SPA clients: Proof Key for Code Exchange adds an extra security layer for clients that cannot store client secrets securely.
- Store OAuth tokens securely: OAuth refresh tokens should be encrypted at rest. Use Mongoose getters/setters with encryption for the
refreshTokenfield. - Implement token refresh for OAuth providers: When the provider's access token expires, use the stored refresh token to obtain a new one transparently.
- Validate OAuth provider tokens on each login: Verify the access token with the provider to ensure it hasn't been revoked.
- Provide clear error messages for failed OAuth flows: Users may deny permissions or encounter provider errors. Redirect them to a dedicated error page with actionable guidance.
Combining Multiple Authentication Strategies
Real-world applications often support multiple authentication methods simultaneously. Users might log in via email/password, while others use Google OAuth, and admin