LEARN · DEBUGGING GUIDE

Passport.js Strategy Not Authenticating: A Diagnostic Walkthrough

When Passport.js silently refuses to authenticate, it's usually not the strategy itself—it's missing serialization, wrong middleware order, or a broken verify callback. This guide walks you through the exact commands and logs to find the root cause.

IntermediateAuth8 min read

What this usually means

The verify callback (the function you pass to the strategy) likely didn't invoke done() correctly, or the serialization/deserialization is broken. Passport relies on a three-step chain: strategy verify callback calls done(null, user) → serializeUser stores user.id in the session → deserializeUser fetches the user on every request. If any link is missing, authentication appears to fail silently. Common causes: the verify callback doesn't return a user object, serializeUser is missing, deserializeUser throws an error, or the session middleware is placed after Passport.initialize().

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Add console.log inside your verify callback: console.log('verify callback called', profile, err) to confirm it fires.
  • 2Check if done() is called in all branches (success, failure, error). Wrap the callback body in try/catch and log.
  • 3Verify that serializeUser is registered: add console.log('serialize', user) in the function.
  • 4Check that deserializeUser is registered and does not throw: add console.log('deserialize', id).
  • 5Confirm middleware order: express-session before passport.initialize() before passport.session().
  • 6Inspect the session store directly (e.g., redis-cli keys *session* or connect-pg-simple query) to see if a session exists after login attempt.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchapp.js or server.js – middleware order (express-session, passport.initialize, passport.session).
  • searchThe strategy configuration file (e.g., passport-setup.js) – verify callback implementation.
  • searchThe route handler calling passport.authenticate() – check if successRedirect and failureRedirect are set correctly.
  • searchSession store (Redis, MongoDB, PostgreSQL) – check if session ID is created and contains passport.user key.
  • searchBrowser developer tools → Application → Cookies → check connect.sid exists and is HTTP-only.
  • searchServer logs for any unhandled promise rejections or stack traces in the verify callback.
  • searchUser model or database query inside deserializeUser – ensure it returns a valid user object.
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningVerify callback never calls done(), or calls done(err) without a user object.
  • warningserializeUser is not defined, so Passport cannot store user info in session.
  • warningdeserializeUser is defined but throws an error (e.g., database query fails) which Passport swallows.
  • warningMiddleware order reversed: passport.initialize() before express-session, or passport.session() missing.
  • warningThe strategy's callbackURL does not match the actual redirect URI (OAuth strategies).
  • warningSession secret changes between server restarts, invalidating existing sessions.
  • warningPassport.authenticate called without (req, res, next) – missing next in route handler.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildEnsure the verify callback always calls done(null, user) on success, done(null, false) on failure, done(err) on error.
  • buildImplement serializeUser to store user.id: passport.serializeUser((user, done) => done(null, user.id)).
  • buildImplement deserializeUser to fetch user by id: passport.deserializeUser(async (id, done) => { try { const user = await User.findById(id); done(null, user); } catch(err) { done(err); } }).
  • buildFix middleware order: app.use(session(...)) then app.use(passport.initialize()) then app.use(passport.session()).
  • buildFor OAuth, explicitly set callbackURL in strategy options to exactly match the route (including protocol and port).
  • buildAdd failureFlash or failureMessage to passport.authenticate to surface errors: failureFlash: true.
  • buildWrap the verify callback in async/await and handle errors with try/catch, always calling done().
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter login, check req.user in the route handler: console.log('req.user:', req.user). Should be the user object.
  • verifiedCheck session content: req.session.passport should contain { user: <user.id> }.
  • verifiedAccess a protected route that uses req.isAuthenticated() – should return true.
  • verifiedRestart the server and test again – session should persist if using a persistent store.
  • verifiedUse a tool like curl or Postman with session cookie to simulate authenticated requests.
  • verifiedAdd a test route that returns JSON of req.user and req.session to inspect live.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningDo not call done() with the user object as the first argument: done(user) is wrong; must be done(null, user).
  • warningDo not forget to call passport.session() – without it, deserializeUser never runs.
  • warningDo not put passport.initialize() before session middleware – session must exist first.
  • warningDo not assume the verify callback is synchronous – if using async, handle promises properly.
  • warningDo not ignore the failureRedirect – it might be hiding the actual error (set failureFlash instead).
  • warningDo not share the same session secret across different environments without clearing sessions.
( 07 )War story

The Silent OAuth Redirect Loop

Backend EngineerNode.js 14, Express 4, Passport 0.5, passport-google-oauth20, Redis session store

Timeline

  1. 09:15Deploy new Google OAuth login feature to staging.
  2. 09:30QA reports that after Google consent screen, user is redirected back to /login with no error.
  3. 09:45Check server logs – no errors, no Passport logs. req.user is undefined.
  4. 10:00Add console.log inside verify callback – it never fires.
  5. 10:15Realize callbackURL in strategy is http://localhost:3000/auth/google/callback but Node app is behind nginx proxy using https.
  6. 10:20Fix: set callbackURL to https://staging.example.com/auth/google/callback and trust proxy in Express.
  7. 10:25Deploy fix. QA retests – still fails. Now verify callback fires but returns error: 'User not found'.
  8. 10:30Check database – email scope not requested from Google. Fix: add scope: ['profile', 'email'].
  9. 10:35Deploy scope fix. Login succeeds.

I pushed what I thought was a straightforward Google OAuth integration. Within 15 minutes, QA hit a redirect loop: the consent screen redirected back to /login with zero error feedback. No logs, no stack trace. I checked the callback URL in the Google Cloud Console and in my strategy – both matched, but I was behind an nginx proxy. The callbackURL was hardcoded to http but the actual request came over https. I added app.set('trust proxy', 1) and changed callbackURL dynamically based on req.protocol.

Second deploy: the callback hit but the user was null. I added console.log in the verify callback and saw it was called with a profile but no email. The Google profile only had id and displayName. I had omitted the 'email' scope. After adding scope: ['profile', 'email'], the verify callback received the email and successfully looked up the user.

The root cause was twofold: incorrect callbackURL due to proxy, and missing email scope. The silent failure was because Passport's default behavior on OAuth error is to redirect to failureRedirect without logging. I now always pass failureFlash: true and log the strategy's internal errors. Also, I verify the full OAuth flow manually with curl and inspect the callback URL parameters before trusting the frontend.

Root cause

Two issues: callbackURL mismatch due to HTTPS proxy (strategy used http, but request came over https), and missing 'email' scope in Google OAuth so the verify callback couldn't find the user.

The fix

Set trust proxy in Express and dynamically generate callbackURL from req.protocol. Add 'email' and 'profile' scopes. Enable failureFlash to surface errors.

The lesson

Always log the full profile and error from the verify callback during development. Never assume the OAuth provider returns all fields – check scopes. And for proxies, always set trust proxy and use relative or protocol-aware URLs.

( 08 )How Passport Authentication Flow Actually Works

Passport's authentication is a three-step middleware chain. First, passport.authenticate() invokes the strategy's verify callback. That callback must call done(null, user) to indicate success, done(null, false) for failure, or done(err) for error. Second, on success, passport.serializeUser() is called with the user object – it should call done(null, id) to store only the identifier in the session. Third, on subsequent requests, passport.session() middleware calls passport.deserializeUser() with the id from the session, and your function should fetch the full user object and call done(null, user). If any step is missing or throws, the entire chain breaks silently.

A common mistake is assuming that passport.authenticate() alone creates a session. It does not – it only invokes the strategy. The session is created by express-session, and the user is attached by passport.session() via deserializeUser. If deserializeUser is not defined, req.user will be undefined on subsequent requests. Always check that serializeUser and deserializeUser are registered before any routes that use authentication.

( 09 )Middleware Order: The Silent Killer

Express middleware runs in the order it's applied. For Passport to work, express-session must run before passport.initialize(), and passport.initialize() must run before passport.session(). Additionally, passport.session() must be applied after passport.initialize(), and before your routes. A typical pitfall is placing passport.initialize() before express-session, causing Passport to try to deserialize a session that doesn't exist yet.

To verify middleware order, add a simple middleware that logs the order: app.use((req, res, next) => { console.log('session:', !!req.session, 'passport:', !!req._passport); next(); }). If req.session is undefined when passport.initialize() runs, you have an order problem. Also, ensure that passport.session() is called even if you use passport.authenticate('local') – without it, deserializeUser never runs and req.user will be null on subsequent requests.

( 10 )The Verify Callback: Where Most Bugs Live

The verify callback is the function you pass to the strategy constructor. It receives (accessToken, refreshToken, profile, done) for OAuth, or (username, password, done) for local. It must call done() exactly once. The most common bug is not calling done() at all, especially in error branches. For example, if your database query throws, and you don't catch it, the callback never completes, and Passport hangs indefinitely (until timeout).

Always wrap the callback body in try/catch and call done(err) in the catch. Also, never call done() with more than two arguments: done(null, user) or done(err). A frequent mistake is calling done(user) (missing null), which Passport interprets as an error because the first argument is truthy. Add a guard: if (err) return done(err); if (!user) return done(null, false); done(null, user);.

( 11 )OAuth-Specific Pitfalls: Redirect URI Mismatch and Missing Scopes

OAuth providers (Google, Facebook, GitHub) validate the redirect URI exactly. If your strategy's callbackURL doesn't match the one registered in the provider's console, the provider will return an error, but Passport may silently redirect to failureRedirect. Always log the full URL from the callback request. Also, check that your app is behind a proxy – you may need to set proxy options in the strategy: proxy: true or custom callbackURL.

Another common issue is missing scopes. If your verify callback expects an email but the scope only includes 'profile', the email field will be undefined. Always log the complete profile object to see what fields are available. Also, some providers return different field names (e.g., 'emails' array vs 'email' string). Normalize the profile data before using it.

( 12 )Session Store Inspection: The Final Proof

When all else fails, inspect the session store directly. If using Redis, run redis-cli keys 'sess:*' to see session keys, then redis-cli get 'sess:<id>' to view session data. The session should contain a 'passport' property with a 'user' key. If the session exists but passport.user is missing, serializeUser didn't run. If the session doesn't exist after login, express-session might not be configured correctly (e.g., missing secret, cookie settings).

For MongoDB connect-mongo, check the 'sessions' collection. For PostgreSQL, query the 'session' table. A common issue is the session cookie not being sent because of secure: true without HTTPS, or sameSite: 'strict' blocking cross-origin requests. Use browser dev tools to verify the cookie is present and has the correct attributes.

Frequently asked questions

Why does passport.authenticate() redirect to failureRedirect even when credentials are correct?

This usually happens when the verify callback calls done(null, false) instead of done(null, user). Check your verify callback logic – you might be returning false because the user lookup failed or a condition wasn't met. Also, ensure you're calling done exactly once and not passing extra arguments.

req.isAuthenticated() returns false after login, but req.session exists. What's wrong?

This indicates that deserializeUser either isn't called or fails silently. Check that passport.session() middleware is applied after passport.initialize(). Also, verify that deserializeUser correctly fetches the user and calls done(null, user). If deserializeUser throws an error, Passport will not set req.user, and isAuthenticated() returns false.

I added console.log in serializeUser but it never fires. Why?

serializeUser is called only after a successful authentication (i.e., when the verify callback calls done(null, user)). If your verify callback never succeeds, serializeUser won't fire. Also, ensure that you're using passport.authenticate() middleware and that it's invoked during the request.

My OAuth strategy works locally but fails in production. What could be different?

Common differences: callbackURL mismatch due to different domain or protocol (e.g., http vs https), proxy configuration (trust proxy setting), or session secret not consistent across deployments. Also, check that the OAuth provider's redirect URI is exactly the same, including trailing slashes. Use environment variables for callbackURL and session secret.

How do I get more detailed error messages from Passport?

Set failureFlash: true in the passport.authenticate() options and install connect-flash middleware. Then in your view, display flash messages. Alternatively, pass a custom callback to passport.authenticate() to handle errors manually: passport.authenticate('local', (err, user, info) => { ... }). The info object often contains error details from the strategy.