Passport Custom Callbacks: Returning Consistent JSON from API Routes
Passport's default authenticate behavior redirects or returns a bare 401, which breaks JSON clients. A custom callback hands response control back to your route.
06 Jan 2026, 03:53 UTC

The problem: API clients do not want HTML redirects
When you protect an Express route with passport.authenticate('local') and authentication fails, Passport's default behavior is often to redirect the browser to a login page or return a bare 401 Unauthorized with no body. For a server-rendered app, that redirect is acceptable. For a JSON API consumed by a single-page application or mobile client, it is a bug: the client receives an HTML redirect or an empty 401 and cannot tell the user what went wrong.
The fix: a custom callback
Passport supports a custom callback form: passport.authenticate('local', (err, user, info) => { ... }). Instead of letting the middleware write the response, you receive the outcome and decide the status code and body. This keeps error shapes consistent across your API.
Inside the callback, err signals a server or strategy failure, user is the authenticated user object (or false), and info carries strategy-specific details such as "Missing credentials" or "Incorrect password." You can map these to your own JSON envelope.
Worked example
Assume an Express app with passport and passport-local installed. Verify your installed versions with npm ls passport passport-local before adopting this pattern, because strategy packages vary in maintenance activity.
const express = require('express');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const app = express();
app.use(express.json());
passport.use(new LocalStrategy(
{ usernameField: 'email' },
(email, password, done) => {
// Replace with your user lookup
User.findOne({ email }, (err, user) => {
if (err) { return done(err); }
if (!user) { return done(null, false, { message: 'Invalid credentials' }); }
user.verifyPassword(password, (err, ok) => {
if (err) { return done(err); }
if (!ok) { return done(null, false, { message: 'Invalid credentials' }); }
return done(null, user);
});
});
}
));
app.post('/api/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) { return next(err); }
if (!user) {
return res.status(401).json({
error: 'authentication_failed',
message: info.message || 'Invalid credentials'
});
}
req.logIn(user, (err) => {
if (err) { return next(err); }
return res.json({ id: user.id, email: user.email });
});
})(req, res, next);
});
app.listen(3000);
Run this with node app.js. A request with no credentials should produce a 401 and a JSON body such as {"error":"authentication_failed","message":"Missing credentials"}. A request with a wrong password should produce the same shape, which avoids leaking whether the email exists.
Sessions or stateless tokens: pick one per client type
Passport does not force a session model. For server-rendered pages, keep express-session and let req.logIn establish the session. For SPAs and mobile clients, pass session: false and issue your own token, such as a JWT, in the success branch.
passport.authenticate('local', { session: false }, (err, user, info) => {
if (err || !user) {
return res.status(401).json({ error: 'authentication_failed' });
}
const token = signToken(user); // your JWT helper
return res.json({ token });
});
With session: false, you can omit serializeUser and deserializeUser. The trade-off is revocation: a signed JWT remains valid until it expires unless you maintain a denylist or short-lived refresh tokens. A server-side session can be destroyed immediately.
Middleware order is a common failure point
passport.initialize() must run before any route that calls req.logIn or req.isAuthenticated(). If you see req.isAuthenticated is not a function, the middleware is missing or ordered after the route. passport.session() is only needed when you use sessions; adding it without express-session configured will not help.
Limitations and verification
- Passport core is stable, but individual strategy packages vary. Audit the exact
passport-*package and its dependencies before production use. - Session cookie security—
secure,sameSite,secretrotation—depends on yourexpress-sessionconfiguration, not Passport defaults. - The examples above are illustrative. Confirm behavior against your installed versions.
To check the result, call your protected API route with no credentials and with a wrong password. You should receive the same JSON structure and status code in both cases, with no Set-Cookie header when session: false is set.