Skip to content

[BUG] [GSSoC'26] deserializeUser returns full Mongoose document, silently attaching password hash to req.user on every authenticated request #555

@anshul23102

Description

@anshul23102

Bug Description

deserializeUser in backend/config/passportConfig.js calls User.findById(id) without any field projection. This returns the full Mongoose document, including the password field (bcrypt hash), __v, and any future fields added to the schema. The full document is attached to req.user for every authenticated request.

Affected File

backend/config/passportConfig.js lines 24-28

passport.deserializeUser(async (id, done) => {
    try {
        const user = await User.findById(id);  // full document, no projection
        done(null, user);
    } catch (err) {
        done(err, null);
    }
});

Why This Is a Problem

Immediate risk: accidental hash exposure

Any route handler that returns req.user to the client (e.g. a future /api/auth/me, a profile endpoint, or an admin panel) will include the password field in the JSON response without the author realising it, because the object looks like a plain user record at the call site.

Offline hash cracking

A leaked bcrypt hash is not plaintext, but it is directly attackable offline. Using a GPU-accelerated cracker and a wordlist, a $2b$10$... hash can be reversed for a significant fraction of users with common passwords. The attacker does not need access to the database; a single leaked API response is enough.

Mongoose document methods on req.user

User.findById() returns a full Mongoose document, not a plain object. This means req.user.comparePassword(), req.user.save(), and other instance methods are callable from route handlers, which is never the intended design for a session user object. It creates a wider attack surface if any route handler incorrectly trusts req.user as user-controlled input and calls document methods on it.

Contrast With the Login Path

The LocalStrategy correctly scopes what gets serialized:

return done(null, {
    id: user._id.toString(),
    username: user.username,
    email: user.email
});

deserializeUser then undoes this careful scoping by returning the full document on every subsequent request.

Steps to Reproduce

  1. Add a temporary debug route (or inspect req.user in any existing route handler after login):
    app.get('/api/debug/me', (req, res) => {
        if (!req.isAuthenticated()) return res.status(401).json({});
        res.json(req.user); // will include 'password' field
    });
  2. Log in, then call GET /api/debug/me.
  3. Observe that the response includes the password hash.

Expected Behavior

deserializeUser should exclude sensitive fields so that req.user contains only what is safe to use throughout the application:

passport.deserializeUser(async (id, done) => {
    try {
        const user = await User.findById(id).select('-password -__v').lean();
        done(null, user);
    } catch (err) {
        done(err, null);
    }
});

Using .lean() additionally converts the result to a plain JavaScript object, removing Mongoose document methods from req.user and reducing memory overhead per request.

Severity

High / Security - The password hash is silently present on req.user for every authenticated request. Any route that serializes req.user (current or future) leaks the hash to the client.


This issue is raised under GSSoC 2026 for open-source contribution.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions