Skip to content
Express.js ex auth 5 min read

OAuth & Social Login

Social login lets users sign in with an account they already have — Google, GitHub, Microsoft — instead of creating yet another password on your site. Under the hood this is the OAuth 2.0 authorization code flow: your app redirects the user to the provider, the provider authenticates them and redirects back with a one-time code, and your server exchanges that code for the user’s profile. Passport’s per-provider strategies (passport-google-oauth20, passport-github2, and friends) handle the redirect, the code exchange, and the token requests for you, so you only write the part that creates or links a local user. This page walks the full flow end to end and then shows how to finish in either a session or a JWT.

The OAuth 2.0 code flow

There are three actors: the user’s browser, your Express server, and the provider (Google, GitHub, etc.). The flow has two server routes — one to start login and one to receive the callback.

  1. The browser hits /auth/google. Passport redirects it to Google’s consent screen with your client ID, requested scopes, and a redirect_uri.
  2. The user approves. Google redirects the browser back to your redirect_uri (/auth/google/callback) with a short-lived code.
  3. Passport’s strategy POSTs that code plus your client secret to Google’s token endpoint and receives an access token.
  4. Passport calls Google’s userinfo endpoint with the token and hands the resulting profile to your verify callback, where you create or link a user.

The client secret only ever leaves your server, never the browser — that is what makes the authorization-code flow safe for web apps.

Registering an OAuth client

Before any code, create an OAuth app in the provider’s developer console (Google Cloud Console, GitHub Developer Settings). You receive a client ID and client secret, and you must register the exact callback URL. A mismatch here is the single most common cause of redirect_uri_mismatch errors.

# .env
GOOGLE_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=replace-with-secret
OAUTH_CALLBACK_BASE=http://localhost:3000
SESSION_SECRET=a-long-random-string

Warning: The callback URL registered with the provider must match callbackURL below byte for byte, including scheme, host, port, and path. In production register your HTTPS domain, e.g. https://app.example.com/auth/google/callback.

Installing the packages

npm install passport passport-google-oauth20 express-session

passport is the core middleware, passport-google-oauth20 is the Google strategy, and express-session stores the logged-in user across requests for the session-based finish shown later.

Configuring the strategy

The strategy takes an options object (your client credentials, callback URL, and scopes) and a verify callback. The verify callback is where the OAuth flow meets your database: it receives the accessToken, an optional refreshToken, the provider profile, and a done function. Call done(null, user) once you have resolved a local user, or done(err) on failure.

const passport = require("passport");
const { Strategy: GoogleStrategy } = require("passport-google-oauth20");

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: `${process.env.OAUTH_CALLBACK_BASE}/auth/google/callback`,
      scope: ["profile", "email"],
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        const user = await findOrCreateUser({
          provider: "google",
          providerId: profile.id,
          email: profile.emails?.[0]?.value,
          name: profile.displayName,
        });
        return done(null, user);
      } catch (err) {
        return done(err);
      }
    }
  )
);

module.exports = passport;

Creating or linking the user

findOrCreateUser is the heart of social login. Match first on the (provider, providerId) pair, which is stable and unique. If no such account exists, fall back to matching by verified email so a user who previously signed up locally is linked rather than duplicated. Only create a brand-new user when neither match succeeds.

async function findOrCreateUser({ provider, providerId, email, name }) {
  let user = await db.users.findByProvider(provider, providerId);
  if (user) return user;

  if (email) {
    user = await db.users.findByEmail(email);
    if (user) {
      await db.users.linkProvider(user.id, provider, providerId);
      return user;
    }
  }

  return db.users.create({ email, name, provider, providerId });
}

Tip: Only auto-link by email if the provider marks it verified. Linking on an unverified email lets an attacker who controls a same-named address hijack an existing account.

Wiring the routes

You need two routes. The first kicks off the redirect; Passport sends a 302 to Google and your handler never returns a body. The second is the callback, where Passport finishes the exchange before running your final handler.

const express = require("express");
const session = require("express-session");
const passport = require("./auth/google-strategy");

const app = express();
app.use(
  session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
  })
);
app.use(passport.initialize());
app.use(passport.session());

// 1. Start the OAuth flow
app.get("/auth/google", passport.authenticate("google"));

// 2. Provider redirects back here with ?code=...
app.get(
  "/auth/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => {
    res.redirect("/dashboard"); // req.user is now set
  }
);

Finishing with a session

For a server-rendered app, persist the user in the session. Passport calls serializeUser to decide what to store in the cookie (store only the id) and deserializeUser to reload the full user on later requests, populating req.user.

passport.serializeUser((user, done) => done(null, user.id));

passport.deserializeUser(async (id, done) => {
  try {
    const user = await db.users.findById(id);
    done(null, user);
  } catch (err) {
    done(err);
  }
});

Output:

$ curl -i http://localhost:3000/auth/google
HTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&scope=profile%20email&redirect_uri=...

Finishing with a JWT instead

For an SPA or mobile client, skip sessions and issue your own JWT in the callback. Authenticate with { session: false } so Passport does not try to serialize a session, then sign a token and return it (or redirect with it).

const jwt = require("jsonwebtoken");

app.get(
  "/auth/google/callback",
  passport.authenticate("google", { session: false, failureRedirect: "/login" }),
  (req, res) => {
    const token = jwt.sign(
      { sub: req.user.id, email: req.user.email },
      process.env.JWT_SECRET,
      { expiresIn: "15m" }
    );
    res.redirect(`myapp://auth?token=${token}`);
  }
);

Adding more providers

Every provider is just another strategy with the same shape, so adding GitHub is a copy-paste with new credentials and route paths. The verify callback’s provider value keeps the accounts distinct in your database.

ProviderPackageStrategy nameTypical scopes
Googlepassport-google-oauth20googleprofile, email
GitHubpassport-github2githubuser:email
Microsoftpassport-microsoftmicrosoftuser.read
Facebookpassport-facebookfacebookemail

Note: In Express 5, passport.authenticate works the same, but the router rejects bare * wildcards — use a named splat like /auth/*splat if you proxy provider paths.

Best Practices

  • Keep the client secret server-side only and load it from the environment; never ship it to the browser.
  • Register the exact HTTPS callback URL in production and match callbackURL to it byte for byte.
  • Use a state parameter (Passport adds one when sessions are enabled) to defend against CSRF on the callback.
  • Link existing accounts only on a provider-verified email, and store the stable (provider, providerId) pair as the primary match key.
  • Request the minimum scopes you actually need; broad scopes scare users and widen your blast radius if tokens leak.
  • Choose one finish — sessions for server-rendered apps, a short-lived JWT for SPAs/mobile — and pass { session: false } when issuing tokens.
  • Encrypt any stored OAuth refreshToken and rotate your client secret if it is ever exposed.
Last updated June 14, 2026
Was this helpful?