DEV Community

Cover image for Google Login in Express with PassportJS & JWT
NHero
NHero

Posted on • Originally published at nhero.me

Google Login in Express with PassportJS & JWT

⚡ Quick OAuth + JWT Architecture (For Fast Revision)

When handling social logins while maintaining a stateless JWT ecosystem, follow this flow:

[User] --- 1. GET /auth/google ---> [Passport Engine] ---> (Redirects to Google Sign-In)
[User] <--- 2. Grants Permission -- [Google Server]
[Backend Callback] <-- 3. Code/Profile Handshake <-- [Google Server] (Verifies & Upserts User Profile)
[User] <--- 4. Sets Secure Access & Refresh Cookies --- [Backend Controller] (Generates Custom JWTs)

Enter fullscreen mode Exit fullscreen mode

Core Strategy Rules

  • No Server-Side Sessions: We explicitly disable passport session serialization (session: false) because our app uses stateless JWT tokens.

  • User Accounts Linking: If a user registers normally with an email address and later hits the "Sign In with Google" button, we automatically link the identity by pinning the googleId onto the pre-existing document profile.


Prerequisites & Dependencies

📂 Project Structure

└── src/
    ├── config/
    ├── controllers/
    ├── middlewares/
    ├── models/
    ├── routes/
    ├── utils/
    ├── app.ts
    └── index.ts
├── .env

Enter fullscreen mode Exit fullscreen mode

📥 Install Required Packages

Execute the following installation string to fetch Passport.js, the Google OAuth2.0 strategy token extensions, and their respective type-hint definitions:

npm install passport passport-google-oauth20 jsonwebtoken cookie-parser bcrypt mongoose
npm install -D @types/passport-google-oauth20

Enter fullscreen mode Exit fullscreen mode

Step 1: Cloud Console Configurations

Before writing software, you need application credentials from the Google Cloud Dashboard.

  1. Navigate to the Google Cloud Console.

  2. Create a New Project using the project selection drop-down layout.
    create a new project

  3. Configure your OAuth Consent Screen and designate the publishing status as External.
    Configure OAuth Consent Screen

  4. Head to the Clients page, choose Create Clients, and click OAuth Client ID.
    go to clients
    create oauth2.0 client

  5. Set the application type to Web Application and add your explicit callback mapping:
    create oauth2.0 client

  • Authorized Redirect URIs: http://localhost:3000/api/v1/auth/google/callback
  1. Save your changes and copy your Client ID and Client Secret tokens. copy credentials

🌐 Environment Setup

Append these key-value configurations to your root .env file environment block:

GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
GOOGLE_CALLBACK_URL=http://localhost:3000/api/v1/auth/google/callback

Enter fullscreen mode Exit fullscreen mode

Step 2: Adapting the Database Schema

To support alternative OAuth logins alongside traditional password profiles, update your Mongoose Model configuration.

🔒 Critical Security Rule
When integrating third-party OAuth provider chains, you must make your schema's password string optional (required: false). This allows users who signed up with Google to create accounts without passwords.

Make sure these key fields are mapped out in your user schema definitions:

const userSchema = new Schema({
  username: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },

  // Make password optional for OAuth registrations!
  password: { type: String, required: false }, 

  avatarUrl: { type: String },
  refreshToken: { type: String },
  googleId: { type: String } // Keeps track of mapped Google profiles
});

Enter fullscreen mode Exit fullscreen mode

To maintain security, place an evaluation guard inside your traditional login controllers so social-only accounts cannot be hijacked through brute-force attempts:

if (!user.password) {
  throw new ApiError(400, "This account was registered via Google Sign-In. Please log in using Google.");
}

Enter fullscreen mode Exit fullscreen mode

Step 3: Architecting the Passport Strategy

Now we configure Passport to handle Google authentication.

Here we:

  • receive the Google profile
  • check if the user already exists
  • create a new account if needed
  • link existing accounts using email
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
import { User } from "../models/User.model.js";
import { generateUsername } from "../utils/usernameGen.js";

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      callbackURL: process.env.GOOGLE_CALLBACK_URL,
    },
    async (_accessToken, _refreshToken, profile, done) => {
      try {
        const email = profile.emails?.[0]?.value;

        if (!email) {
          return done(new Error("Google account profile must yield a primary email address"));
        }

        // Look for an existing account matching either the googleId OR the email address
        let user = await User.findOne({
          $or: [{ googleId: profile.id }, { email }],
        });

        // Case 1: Account exists but lacks a googleId link (First-time social login for an existing user)
        if (user && !user.googleId) {
          user.googleId = profile.id;
          await user.save();
        }

        // Case 2: No account exists under this email - Create a brand new user profile
        if (!user) {
          const uniqueUsername = await generateUsername(profile.displayName);

          user = await User.create({
            username: uniqueUsername,
            fullName: profile.displayName,
            email,
            googleId: profile.id,
            avatarUrl: profile.photos?.[0]?.value || "",
          });
        }

        // Remove sensitive fields before returning the user
        const sanitizedUser = await User.findById(user._id).select("-password -refreshToken -googleId");
        if (!sanitizedUser) {
          return done(new Error("User not found after creation"));
        }

        return done(null, sanitizedUser);
      } catch (error) {
        return done(error as Error);
      }
    }
  )
);

export default passport;

Enter fullscreen mode Exit fullscreen mode

Dynamic Namespace Deduplication Utility

When creating users via OAuth, Google provides full display names, which aren't guaranteed to be unique, so we generate a fallback username if needed.:

src/utils/usernameGen.ts

import { User } from "../models/User.model.js";

export const generateUsername = async (
  displayName: string
): Promise<string> => {
  const cleaned = displayName
    .toLowerCase()
    .replace(/[^a-z0-9]/g, "");

  const baseUsername =
    cleaned.length > 0
      ? cleaned.slice(0, 15)
      : "user";

  let username = "";
  let exists = true;

  while (exists) {
    const suffix = Math.floor(
      1000 + Math.random() * 9000
    );

    username = `${baseUsername}${suffix}`;

    exists = !!(await User.exists({
      username,
    }));
  }

  return username;
};

Enter fullscreen mode Exit fullscreen mode

Step 4: Building the Callback Controller & Routes

Once Passport successfully authenticates the user, control moves to our controller.. Here, we generate our custom app cookies and pass down the response payload.

The Controller Handlers

src/controllers/auth.controller.ts

import { type Request, type Response } from "express";
import { generateTokens } from "../utils/generateTokens.js";

export const googleAuthCallback = async (req: Request, res: Response) => {
  // Passport injects the sanitized profile info onto the req.user property
  const user = req.user!;

  // Generate our system's regular custom JWT tokens
  const { accessToken, refreshToken } = await generateTokens(user._id);

  const cookieOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict" as const,
  };

  return res
    .status(200)
    .cookie("accessToken", accessToken, cookieOptions)
    .cookie("refreshToken", refreshToken, cookieOptions)
    .json({
      success: true,
      message: "Google authentication handshake completed successfully",
      user,
    });
};

Enter fullscreen mode Exit fullscreen mode

Defining Route Registrations

src/routes/auth.routes.ts

import { Router } from "express";
import passport from "passport";
import { googleAuthCallback } from "../controllers/auth.controller.js";

const router = Router();

// Route 1: Initial redirect request loop trigger
router.route("/google").get(
  passport.authenticate("google", {
    scope: ["profile", "email"], // Target scope values required from Google Cloud console
    session: false, // Ensures stateless JWT operations
  })
);

// Route 2: Target route intercept landing zone for redirect returns from Google
router.route("/google/callback").get(
  passport.authenticate("google", {
    failureRedirect: "/login",
    session: false,
    failureMessage: "Failed to login with Google credentials",
  }),
  googleAuthCallback
);

export default router;

Enter fullscreen mode Exit fullscreen mode

Step 5: Mounting Initializations

Finally, register and load the Passport setup directly within your core runtime file (app.ts) before mounting your routes.

src/app.ts

import express from "express";
import cookieParser from "cookie-parser";
import passport from "./config/passport.js"; // Loads strategy definitions
import authRouter from "./routes/auth.routes.js";

const app = express();

app.use(express.json());
app.use(cookieParser());

// Initialize Passport Engine
app.use(passport.initialize());

// App Routes
app.use("/api/v1/auth", authRouter);

export { app };

Enter fullscreen mode Exit fullscreen mode

🛠️ Diagnostics & Troubleshooting Checkpoints

⚠️ Common Bug: Redirection URI Mismatch Errors
If Google dumps a configuration block error message on your display screen during testing, double-check that your callback strings match exactly across all three of these locations:

  1. The Allowed Callback parameter mapped inside your Cloud Dashboard Console.

  2. The GOOGLE_CALLBACK_URL literal configuration inside your .env workspace variables.

  3. The callbackURL property parameter initialized inside your Passport strategy constructor instantiation block.


Summary Checklist

  • Made backend password strings optional (required: false) on database models.

  • Set session: false across all routing hooks to stay completely stateless.

  • Bound fallback profile configurations matching profile.emails?.[0]?.value queries.

  • Handled unique namespace fallback conflicts using clean alphanumeric deduplication utility logic.

Happy coding! 🚀

Top comments (0)