Skip to content

Adding OAuth and Social Login

Aman Kumar Singh10 min read
Part 14 of 40From the Full Stack SaaS Masterclass series
Adding OAuth and Social Login — article by Aman Kumar Singh

In Role-Based Access Control (RBAC) in a SaaS we sorted out what a user is allowed to do once they're inside your app. This article backs up a step and looks at how they get in, specifically through Google, GitHub, or Microsoft instead of an email and password you have to store and hash yourself.

This is part of the Full Stack SaaS Masterclass, still in Module 2, the authentication and authorization module. Social login is one of those features that looks like a checkbox on a landing page and turns out to touch account linking, tenant invites, and a few security assumptions that are easy to get wrong the first time.

I'll be upfront about scope. This covers the authorization code flow with PKCE, wiring up a Google OAuth strategy in NestJS, and the account-linking decisions that determine whether "Sign in with Google" is a convenience feature or a quiet source of account-takeover bugs. Enterprise SSO (SAML, OIDC for a customer's own identity provider) is a different problem with different stakes, and it gets its own treatment later in the series.

Why bother with social login at all

The pitch for social login is almost entirely about friction. Every field on a signup form is a chance for someone to bail, and "email plus password plus confirm password plus verify your email" is a lot of fields for someone who just wanted to try your product. A single "Continue with Google" button turns that into one click, and it comes with a bonus: Google, GitHub, and Microsoft have already verified the email address and, in most cases, already have some form of multi-factor authentication protecting that account. You're borrowing their identity verification instead of building your own from scratch.

The tradeoff is that you're also borrowing their availability and their trust model. If Google has an outage, users who only ever signed up through Google can't get into your app either. You didn't cause the incident. You'll get the support tickets anyway. And "verified by Google" isn't the same guarantee as "this person owns this email forever." Accounts get compromised, deleted, and recycled. Treat OAuth as a strong signal about identity, not an unconditional one.

The practical reason to add it now rather than later is usually B2C conversion or reducing password-reset support load, not a security requirement. If your SaaS is purely B2B with buyers who already expect a corporate email and password (or enterprise SSO down the line), the case for consumer OAuth providers is weaker. Add it when it solves a real problem you're seeing, not because every SaaS landing page has the buttons.

How the flow actually works

OAuth 2.0's authorization code flow, with PKCE (Proof Key for Code Exchange) layered on top, is the one you want for a web app. The short version: your app redirects the user to Google, Google authenticates them and asks for consent, then redirects back to your callback URL with a short-lived authorization code. Your backend exchanges that code, server-side, for an access token and an ID token by calling Google directly, never through the browser.

PKCE matters even for a confidential backend client, not just public clients like mobile apps. It ties the authorization request to the token exchange with a one-time secret (the code verifier), so an intercepted authorization code alone can't be redeemed by an attacker. Most modern OAuth libraries, including Passport's OAuth 2.0 strategies, handle PKCE and the state parameter for you. Confirm it's actually enabled rather than assuming; older strategy configurations sometimes leave state verification off by default, which reopens a CSRF hole in the callback.

The ID token, a JWT signed by the provider, is what actually tells you who the user is: email, whether that email is verified, name, and a stable subject identifier (sub) unique to that user on that provider. The access token, by contrast, is for calling the provider's own APIs (say, reading a Google Calendar), which most SaaS apps don't even need. Don't confuse the two, and don't store the provider's access token longer than you need it if you're not using it.

Wiring up Google login in NestJS

Passport's OAuth 2.0 strategies plug into the same guard-based pattern as the JWT strategy from earlier in the series. Install the Google strategy package:

npm install passport-google-oauth20
npm install -D @types/passport-google-oauth20

The strategy handles the redirect and callback exchange, and hands you a normalized profile once Google confirms the user's identity:

// src/auth/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback, Profile } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(
    config: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
      clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
      callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
      scope: ['email', 'profile'],
    });
  }

  async validate(
    _accessToken: string,
    _refreshToken: string,
    profile: Profile,
    done: VerifyCallback,
  ): Promise<void> {
    const email = profile.emails?.[0]?.value;
    const emailVerified = profile.emails?.[0]?.verified ?? false;

    if (!email) {
      return done(new Error('Google account has no email on the profile'), false);
    }

    const user = await this.authService.findOrCreateFromOAuth({
      provider: 'google',
      providerUserId: profile.id,
      email,
      emailVerified: Boolean(emailVerified),
      displayName: profile.displayName,
    });

    done(null, user);
  }
}

The controller side is deliberately thin, since the strategy does the redirect and callback handling:

// src/auth/auth.controller.ts
import { Controller, Get, UseGuards, Req, Res } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import type { Response } from 'express';
import { AuthService } from './auth.service';

@Controller('auth/google')
export class GoogleAuthController {
  constructor(private readonly authService: AuthService) {}

  @Get()
  @UseGuards(AuthGuard('google'))
  googleLogin() {
    // Passport redirects to Google; this handler body never runs.
  }

  @Get('callback')
  @UseGuards(AuthGuard('google'))
  async googleCallback(@Req() req: any, @Res() res: Response) {
    const { accessToken, refreshToken } = await this.authService.issueSessionTokens(req.user);
    res.cookie('refresh_token', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
    });
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`);
  }
}

That last redirect deserves a second look. Passing the access token as a query parameter is convenient but leaks it into browser history and server logs. A safer pattern is to set the access token as a short-lived, httpOnly cookie too, and have the frontend read a lightweight "am I logged in" endpoint on load rather than parsing a token out of the URL. It costs one extra request; it closes a real leak.

Account linking, the part that actually needs design

The redirect dance is the easy part. The interesting engineering problem is what happens the second time someone shows up. Say a user signed up with jane@company.com and a password, then later clicks "Continue with Google" using the same email. Do you silently merge those into one account, reject the login, or ask her to confirm?

The schema that holds up is a separate oauth_identities table rather than a provider column on users, because one person can and does connect more than one provider over time:

create table oauth_identities (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references users(id) on delete cascade,
  provider text not null, -- 'google', 'github', 'microsoft'
  provider_user_id text not null,
  email text not null,
  created_at timestamptz not null default now(),
  unique (provider, provider_user_id)
);

findOrCreateFromOAuth then follows a deliberate order: look up an existing identity by (provider, provider_user_id) first, since that's the one truly stable key a provider gives you. Only if that misses do you fall back to matching on email, and only if the provider reports that email as verified. An unverified email match is exactly how account takeover happens: an attacker registers a Google account using a victim's email before the victim ever signs up, then logs into your app as them the moment the victim's real account gets linked by email alone.

async findOrCreateFromOAuth(input: {
  provider: string;
  providerUserId: string;
  email: string;
  emailVerified: boolean;
  displayName?: string;
}) {
  const existingIdentity = await this.oauthIdentities.findByProvider(
    input.provider,
    input.providerUserId,
  );
  if (existingIdentity) {
    return this.usersService.findById(existingIdentity.userId);
  }

  if (!input.emailVerified) {
    throw new UnauthorizedException(
      'Your provider account email is not verified. Please verify it and try again.',
    );
  }

  const existingUser = await this.usersService.findByEmail(input.email);
  if (existingUser) {
    await this.oauthIdentities.link(existingUser.id, input.provider, input.providerUserId, input.email);
    return existingUser;
  }

  const newUser = await this.usersService.createFromOAuth(input);
  await this.oauthIdentities.link(newUser.id, input.provider, input.providerUserId, input.email);
  return newUser;
}

For a stricter posture, some teams skip the automatic email-based link entirely and instead require a logged-in user to explicitly connect a new provider from their account settings page. That removes the ambiguity completely at the cost of one extra step for legitimate users who forgot which method they used originally. Which side you land on depends on how sensitive the data behind the account is; a project management tool can afford the convenience, a finance app probably shouldn't.

Tradeoffs: build it yourself versus Auth.js, Auth0, or Clerk

You don't have to write the strategy and account-linking logic yourself. Auth.js (formerly NextAuth), Auth0, Clerk, and similar providers handle the OAuth dance, session management, and often account linking out of the box, and they're a completely reasonable default for a lot of SaaS products, especially early on.

The case for building it yourself, as shown above, is control: you own the account-linking rules, you're not paying per monthly active user once you have real volume, and you're not adding an external dependency to your login path that, if it has an outage, takes your entire signup and login flow down with it. The case against building it yourself is exactly the flip side: you're now responsible for keeping up with provider API changes, handling edge cases like revoked consent, and the security review burden of a hand-rolled auth flow.

My honest read: if you're already deep in a NestJS backend with your own user table (as this series has been building), implementing OAuth directly is a manageable few hundred lines and keeps auth logic in one place with the rest of your authorization system from the RBAC article. If your team is small and moving fast on a greenfield product, a managed provider buys back real engineering time you can spend elsewhere. Neither choice is wrong; the mistake is switching between them repeatedly without a real reason to.

Production pitfalls

Skipping the state parameter. It exists specifically to prevent CSRF on the OAuth callback: without it, an attacker can trick a victim's browser into completing an OAuth flow initiated by the attacker, linking the attacker's provider account to the victim's session. Confirm your Passport strategy version generates and verifies state by default; some older configurations require it to be wired up explicitly.

Trusting unverified provider emails. Covered above, but worth repeating as its own item because it's the single most common OAuth security bug: never link or create an account by email match without checking the provider's email_verified claim first.

Redirect URI mismatches breaking silently in production. OAuth providers require an exact, pre-registered callback URL. It's common for GOOGLE_CALLBACK_URL to work fine locally against localhost and then fail in staging or production because nobody registered the new domain's callback URL in the Google Cloud Console. Keep this in your deployment checklist, not just your .env.example.

No path to unlink or manage connected accounts. Users will eventually want to disconnect a provider, especially after leaving a job tied to their work Google account. If your oauth_identities table is the only login method for some users, make sure removing one doesn't lock them out entirely; require at least one remaining login method (password or another provider) before allowing an unlink.

Forgetting refresh tokens for the provider's own APIs. If you actually need to call Google's APIs on the user's behalf later (calendar sync, for example), you need the access_type=offline and prompt=consent parameters on the initial authorization request to get a refresh token, because Google won't hand you one on subsequent logins otherwise. If you don't need provider API access, don't request extra scopes or store tokens you'll never use; it's needless attack surface.

Key takeaways

  • Social login trades a password field for borrowed identity verification from the provider; it reduces signup friction but makes your login availability partly dependent on theirs.
  • Use the authorization code flow with PKCE and a verified `state` parameter; never do the token exchange in the browser.
  • Match returning users by `(provider, provider_user_id)` first, and only fall back to email matching when the provider confirms the email is verified, to prevent account-takeover via unverified email collision.
  • A separate `oauth_identities` table, not a `provider` column on `users`, supports the common case of one person connecting multiple providers over time.
  • Building OAuth yourself is manageable once you already have a custom auth system; a managed provider like Auth.js or Clerk is a reasonable default when you want to spend less engineering time on auth specifically.
  • Give users a way to manage and unlink connected providers, and never let an unlink action leave an account with zero remaining login methods.

Frequently asked questions

Is OAuth login more secure than a password?

It shifts the responsibility for password strength, breach detection, and often multi-factor authentication onto the provider, which for most users is a security upgrade over a password they reuse elsewhere. It's not an absolute guarantee, since a compromised or recycled provider account carries that risk into your app too.

Should I automatically link a Google login to an existing password-based account with the same email?

Only if the provider confirms the email is verified. Linking on an unverified email is a known account-takeover vector, since an attacker can register a provider account with someone else's email before that person ever signs up with you.

Do I need PKCE if my backend is a confidential client with a client secret?

Yes. PKCE protects the authorization code exchange itself, independent of whether your client can hold a secret. Most current Passport OAuth 2.0 strategies and provider SDKs support it by default; verify it's actually active rather than assuming.

Should I use NextAuth (Auth.js), Auth0, or Clerk instead of building this myself?

Any of them are reasonable, especially early on or if your team wants to spend less time on auth specifically. Building it yourself makes more sense once you already have a custom user table and authorization system you want OAuth to plug into directly, as this series does.

What's the difference between the OAuth access token and the ID token?

The ID token is a signed JWT that tells you who the user is (email, verification status, a stable subject ID). The access token is for calling the provider's own APIs on the user's behalf. Most SaaS apps only need the ID token for login and can discard the access token if they never call the provider's APIs.

How do I let a user disconnect a social login without locking themselves out?

Track how many active login methods (password, each connected provider) an account has, and refuse an unlink request that would bring that count to zero. Surface the remaining methods clearly in account settings so users understand why an unlink is blocked.

Related articles

JWT Authentication in NestJS — Aman Kumar Singh
Authentication with Passport and JWT — Aman Kumar Singh
Guards and Authorization — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.