, required: true, unique: true, lowercase: true },
displayName: { type: String, required: true },
avatarUrl: { type: String },
passwordHash: { type: String, select: false },
googleId: { type: String, sparse: true, unique: true },
refreshToken: { type: String, select: false },
authProvider: { type: String, enum: ['local', 'google'], default: 'local' },
},
{ timestamps: true }
);
UserSchema.index({ googleId: 1 });
UserSchema.index({ email: 1 });
export const UserModel = mongoose.model<IUser>('User', UserSchema);
**Rationale:** Using `sparse: true` on `googleId` allows multiple local users to exist without violating uniqueness constraints. Excluding `passwordHash` and `refreshToken` by default prevents accidental serialization in API responses. The `authProvider` enum enables future logic branching for password reset flows or account linking.
### Step 2: Abstract the OAuth Strategy
Passport strategies should be isolated from route handlers. The strategy must handle three scenarios: new user creation, existing user retrieval, and account linking when an email collision occurs.
```typescript
import passport from 'passport';
import { Strategy as GoogleOAuthStrategy } from 'passport-google-oauth20';
import { UserModel, IUser } from '../models/user.schema';
const GOOGLE_SCOPES = ['profile', 'email'];
passport.use(
new GoogleOAuthStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: process.env.GOOGLE_CALLBACK_URL!,
},
async (accessToken: string, refreshToken: string, profile: any, done: any) => {
try {
const primaryEmail = profile.emails?.[0]?.value;
if (!primaryEmail) {
return done(new Error('OAuth profile missing primary email'), false);
}
const providerId = profile.id;
let existingUser: IUser | null = await UserModel.findOne({
$or: [{ googleId: providerId }, { email: primaryEmail }],
});
if (existingUser) {
if (!existingUser.googleId) {
existingUser.googleId = providerId;
existingUser.authProvider = 'google';
await existingUser.save();
}
return done(null, existingUser);
}
const newUser = await UserModel.create({
email: primaryEmail,
displayName: profile.displayName || primaryEmail.split('@')[0],
avatarUrl: profile.photos?.[0]?.value,
googleId: providerId,
authProvider: 'google',
});
return done(null, newUser);
} catch (err) {
return done(err as Error, false);
}
}
)
);
export default passport;
Rationale: The $or query efficiently resolves whether the user is returning via Google or linking an existing email account. Updating googleId on collision prevents duplicate accounts. Error handling is explicit, ensuring Passport's done callback receives consistent signatures.
Step 3: Token Generation & Cookie Serialization
JWTs should be issued as a pair: short-lived access tokens and long-lived refresh tokens. Both are serialized into httpOnly, SameSite cookies to prevent client-side script access.
import jwt from 'jsonwebtoken';
import { Response } from 'express';
import { IUser } from '../models/user.schema';
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
interface TokenPair {
accessToken: string;
refreshToken: string;
}
export const issueTokenPair = (user: IUser): TokenPair => {
const payload = { sub: user._id, email: user.email, provider: user.authProvider };
const accessToken = jwt.sign(payload, process.env.JWT_ACCESS_SECRET!, {
expiresIn: ACCESS_TTL,
});
const refreshToken = jwt.sign(payload, process.env.JWT_REFRESH_SECRET!, {
expiresIn: REFRESH_TTL,
});
return { accessToken, refreshToken };
};
export const attachAuthCookies = (res: Response, tokens: TokenPair): void => {
const isProd = process.env.NODE_ENV === 'production';
const cookieConfig = {
httpOnly: true,
secure: isProd,
sameSite: isProd ? ('strict' as const) : ('lax' as const),
maxAge: 7 * 24 * 60 * 60 * 1000,
};
res.cookie('access_token', tokens.accessToken, cookieConfig);
res.cookie('refresh_token', tokens.refreshToken, cookieConfig);
};
Rationale: Separating access and refresh tokens limits the blast radius of token compromise. maxAge aligns with the refresh token TTL. Environment-aware cookie flags ensure development convenience without sacrificing production security.
Step 4: Route Orchestration & Flow Control
Routes should delegate to Passport for the initial redirect and callback validation, then hand off to a controller for token issuance.
import { Router } from 'express';
import passport from 'passport';
import { issueTokenPair, attachAuthCookies } from '../utils/token.manager';
import { UserModel } from '../models/user.schema';
const authRouter = Router();
authRouter.get(
'/google/initiate',
passport.authenticate('google', { scope: GOOGLE_SCOPES, session: false })
);
authRouter.get(
'/google/callback',
passport.authenticate('google', { session: false, failureRedirect: '/auth/login?error=oauth_failed' }),
async (req, res) => {
const user = req.user as IUser;
const tokens = issueTokenPair(user);
await UserModel.findByIdAndUpdate(user._id, { refreshToken: tokens.refreshToken });
attachAuthCookies(res, tokens);
res.status(200).json({
status: 'success',
data: {
id: user._id,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
},
});
}
);
export default authRouter;
Rationale: session: false is critical. It prevents Passport from attempting to serialize the user into express-session, which conflicts with stateless JWT architectures. The callback handler persists the refresh token to the database, enabling token rotation and revocation. The response strips sensitive fields, returning only client-safe profile data.
Pitfall Guide
1. Callback URL Protocol/Port Mismatch
Explanation: Google strictly validates the redirect URI. http://localhost:3000/callback differs from http://localhost:3000/callback/. Trailing slashes, HTTP vs HTTPS, and port changes cause immediate redirect_uri_mismatch errors.
Fix: Store the callback URL in a single environment variable. Use it identically in the Google Cloud Console, Passport configuration, and .env file. Never hardcode it in routes.
2. Session Middleware Interference
Explanation: Leaving app.use(session(...)) enabled alongside session: false in Passport causes middleware queue conflicts. Passport attempts to serialize state, leading to memory leaks and unpredictable authentication behavior.
Fix: Remove express-session entirely for stateless APIs. If you must support legacy session routes, isolate them behind a separate router prefix and disable session middleware globally.
3. Email Scope Omission
Explanation: The profile scope alone does not guarantee email delivery. Google requires explicit email scope declaration. Missing it results in profile.emails being undefined, breaking user creation logic.
Fix: Always pass scope: ['profile', 'email'] in the initial authenticate call. Validate email presence in the strategy callback before proceeding.
4. Hybrid Auth Password Collision
Explanation: When a user signs up via Google, then attempts password login, the system throws validation errors because passwordHash is undefined. Conversely, forcing password requirements breaks social-only accounts.
Fix: Make passwordHash optional in the schema. In local login controllers, explicitly check if (!user.passwordHash) throw new AuthError('Account linked to social provider'). Provide UI guidance for account linking.
5. Missing SameSite/Secure Flags
Explanation: Cookies without SameSite attributes are vulnerable to cross-site request forgery. Without Secure, tokens transmit over unencrypted HTTP in development or misconfigured staging environments.
Fix: Implement environment-aware cookie configuration. Use strict for production, lax for development. Always enforce httpOnly: true to block JavaScript access.
6. Unhandled Passport Errors
Explanation: Swallowing errors in the strategy callback or failing to pass them to done() causes silent failures. The client receives generic 500 errors or hangs indefinitely.
Fix: Wrap database operations in try/catch. Always invoke done(error, false) on failure. Implement a global error handler that maps Passport errors to appropriate HTTP status codes.
7. Token Rotation Neglect
Explanation: Refresh tokens stored in the database but never rotated become permanent keys. If compromised, attackers maintain indefinite access until manual revocation.
Fix: Implement a /auth/refresh endpoint that validates the refresh token, issues a new pair, and updates the database record. Invalidate the old token immediately after rotation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| SPA + Mobile App Backend | JWT in httpOnly Cookies | Stateless, XSS-resistant, works across platforms | Low (no session store required) |
| Legacy Server-Rendered App | Server-Side Sessions | Simpler CSRF handling, built-in Passport support | Medium (requires Redis/Memcached for scale) |
| Internal Admin Dashboard | JWT in LocalStorage + CSRF Tokens | Faster JS access, acceptable in controlled environments | Low |
| High-Security Financial App | Short-Lived JWT + Refresh Rotation + Device Fingerprinting | Minimizes token window, adds behavioral verification | High (infrastructure + monitoring) |
Configuration Template
.env
NODE_ENV=production
PORT=4000
# Database
MONGODB_URI=mongodb://localhost:27017/auth_pipeline
# JWT Secrets (generate with: openssl rand -base64 64)
JWT_ACCESS_SECRET=your_access_secret_here
JWT_REFRESH_SECRET=your_refresh_secret_here
# Google OAuth
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret_here
GOOGLE_CALLBACK_URL=https://api.yourdomain.com/auth/google/callback
src/middleware/passport.init.ts
import passport from 'passport';
import './strategies/google.strategy';
export const initializePassport = () => {
passport.serializeUser((user: any, done) => done(null, user._id));
passport.deserializeUser(async (id: string, done) => {
try {
const { UserModel } = await import('../models/user.schema');
const user = await UserModel.findById(id);
done(null, user);
} catch (err) {
done(err, false);
}
});
};
src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import passport from 'passport';
import { initializePassport } from './middleware/passport.init';
import authRouter from './routes/auth.routes';
const app = express();
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
initializePassport();
app.use(passport.initialize());
app.use('/auth', authRouter);
export default app;
Quick Start Guide
- Provision Credentials: Navigate to Google Cloud Console β APIs & Services β Credentials. Create an OAuth 2.0 Client ID, set application type to Web Application, and add your callback URL.
- Seed Environment: Copy the
.env template, paste your client credentials, and generate strong JWT secrets using a cryptographic random generator.
- Initialize Database: Ensure MongoDB is running. The schema uses sparse indexes, so no manual migration is required on first run.
- Launch & Verify: Start the Express server. Visit
http://localhost:4000/auth/google/initiate in your browser. Confirm the Google consent screen appears, redirect completes successfully, and cookies are set in DevTools Application tab.
- Validate Statelessness: Restart the server. Confirm that existing cookies still authenticate requests without server-side session storage, verifying the stateless JWT architecture.