PublishedJul 15, 20267 min read
Passport.js auth patterns I keep reaching for
Four projects in, the same Passport setup keeps showing up — local plus Google plus Facebook, sessions in Mongo, and the redirect edge cases nobody documents.
- Node.js
- Auth
- Express

Jssconnect, Heckfree, QuizTown and Coscholars all authenticate people, and all four ended up with a near-identical Passport setup. This is that setup, written down so I stop rebuilding it from memory.
One user model, several ways in
The mistake I made first was modelling a Google user and a local user as different things. They're the same person with a different door. One collection, an optional password hash, and a small array of linked providers keeps every later query simple.
const UserSchema = new Schema({
email: { type: String, required: true, unique: true },
name: String,
hash: String, // absent for OAuth-only accounts
providers: [{ name: String, id: String }]
});Every strategy below funnels into one function against that model. Write it once and the three verify callbacks become four lines each.
// Find by provider id first, then by email, then create. The order matters:
// provider id is the stable key, email is the merge key, and creating is the
// last resort rather than the default.
async function findOrLink({ provider, id, email, name }) {
const linked = await User.findOne({ "providers.name": provider, "providers.id": id });
if (linked) return linked;
const existing = await User.findOne({ email });
if (existing) {
existing.providers.push({ name: provider, id });
return existing.save();
}
return User.create({ email, name, providers: [{ name: provider, id }] });
}Wiring the strategies
Local carries a password, the OAuth two don't. That's the only real difference between them.
passport.use(new LocalStrategy({ usernameField: "email" }, async (email, password, done) => {
const user = await User.findOne({ email });
// One message for both branches — "no such email" is an account oracle.
if (!user?.hash || !(await bcrypt.compare(password, user.hash))) {
return done(null, false, { message: "Email or password is incorrect." });
}
return done(null, user);
}));
passport.use(new GoogleStrategy(GOOGLE_OPTS, async (accessToken, refreshToken, profile, done) => {
try {
done(null, await findOrLink({
provider: "google",
id: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
}));
} catch (err) {
done(err);
}
}));Facebook may not give you an email
profile.emails is undefined when the account was registered with a phone number, or when the user declines the email permission on the consent screen. profile.emails[0].value then throws inside the verify callback, which surfaces as a generic 500 on the callback URL and looks nothing like a Facebook problem. Request scope: ["email"] and fields: ["id", "displayName", "emails"], then handle the absence — I send those users to a one-field "where should we reach you?" page instead of failing.
Serialize the id, nothing else
serializeUser decides what lands in the session cookie's server-side record. Put the id there and nothing else.
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
try {
done(null, await User.findById(id).select("-hash"));
} catch (err) {
done(err);
}
});Serializing the whole user document is tempting and wrong twice over: the session goes stale the moment a profile is edited, and a permission change doesn't take effect until the user logs out. The cost is a lookup per authenticated request — which is the point where .select("-hash") stops being hygiene and starts being the thing that keeps the hash out of every req.user you later spread into a template.
Sessions in Mongo, not in memory
The default MemoryStore logs every user out on each deploy, which on a free Render instance means several times a day. connect-mongo moves sessions into the database you already have, and the change is three lines.
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: MongoStore.create({ mongoUrl: process.env.MONGO_URL }),
cookie: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production" }
}));sameSite: "lax" is load-bearing rather than decorative. The OAuth callback arrives as a top-level cross-site GET from Google's domain; "strict" withholds the session cookie on exactly that request, so Passport finds no session, cannot match the state parameter, and the login fails only in production — where secure is also on and it's easy to blame the wrong flag.
The redirect edge cases
Two of them cost me an evening each.
- 1
Deep links lose their destination
A user who signs in from a deep link should land back on that link, not the dashboard. Stash the intended path in the session before redirecting to the provider. - 2
An OAuth email that already exists locally
It should link providers, not throw a duplicate-key error. This is the one that looks like a database bug and is actually a modelling decision you deferred.
The first is a guard that writes down where it turned someone away.
function ensureAuth(req, res, next) {
if (req.isAuthenticated()) return next();
// Only GETs are worth returning to, and only same-origin paths — an open
// redirect is the classic way this helper becomes a vulnerability.
if (req.method === "GET" && /^\/(?!\/)/.test(req.originalUrl)) {
req.session.returnTo = req.originalUrl;
}
res.redirect("/login");
}
function afterLogin(req, res) {
const target = req.session.returnTo || "/dashboard";
delete req.session.returnTo;
res.redirect(target);
}The /^\/(?!\/)/ test is the whole security story in that file. req.originalUrl is attacker-reachable — a browser will happily request //evil.example and Express hands that through as the path — so without the second-slash check you have stored an absolute URL and afterLogin redirects off your domain, wearing your login page on the way out.
req.login() regenerates the session in Passport 0.6+, so read returnTo before the callback runs or write it back after — this is the version bump where a working helper quietly starts sending everyone to /dashboard:
app.get("/auth/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
afterLogin
);The second edge case is already solved above — findOrLink looks up by email before it creates. What's left is deciding whether that's safe, and the honest answer is only if the provider verified the email:
Linking on an unverified email is account takeover
If a provider hands you an address it never confirmed, silently merging into the existing account lets anyone who can claim that address at the provider walk into it. Google exposes profile.emails[0].verified; when it isn't true, I create a separate record and ask the signed-in user to link deliberately from their settings page.
Every auth bug I have shipped was a redirect, not a token.
What I copy between projects now
The file layout
config/passport.js holds the strategies and findOrLink; middleware/auth.js holds ensureAuth and afterLogin; routes import both and contain no auth logic of their own. Four projects in, only the strategy file ever needs editing.
The order in app.js
session() before passport.session(), both before any router. Passport reads req.session, so a router mounted above the session middleware sees an anonymous request no matter who is logged in — and it fails silently rather than throwing.
The env vars
SESSION_SECRET, MONGO_URL, and a client id/secret pair per provider. The callback URL belongs in the provider console and the strategy options, and a mismatch between them is the error message that just says redirect_uri_mismatch.
None of this is clever, and that's the appeal. Auth is the part of a side project where novelty buys nothing — the interesting decisions are one collection instead of two, an id in the session instead of a document, and a redirect helper that refuses to leave the origin. The rest is boilerplate you can copy between projects, which is exactly what I do now.