OAuth & Social Login
Social login lets users authenticate with an identity they already trust — their Google, GitHub, or Microsoft account — instead of inventing yet another password. Under the hood this is the OAuth2 authorization-code flow, and Passport has battle-tested strategies for every major provider. In NestJS you wrap each provider in a PassportStrategy, expose a redirect endpoint and a callback endpoint, and turn the returned profile into (or link it to) a user in your own database. This page walks through Google and GitHub end to end, including account linking and just-in-time user provisioning.
How the OAuth2 code flow works in NestJS
The browser is redirected to the provider’s consent screen. After the user approves, the provider redirects back to your callbackURL with a short-lived code. Passport exchanges that code for an access token, fetches the user’s profile, and calls your strategy’s validate() method with the profile data. Two endpoints implement this: one that starts the flow (the guard issues the redirect) and one that receives the callback (the guard completes the exchange and populates req.user).
Installing provider strategies
Each provider has its own Passport package. Install the framework wrapper plus the strategies you need.
npm install @nestjs/passport passport passport-google-oauth20 passport-github2
npm install -D @types/passport-google-oauth20 @types/passport-github2
Register the OAuth credentials with each provider’s developer console and store them as environment variables. Never commit them.
# .env
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxxxx
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
GITHUB_CLIENT_ID=Iv1.xxxxx
GITHUB_CLIENT_SECRET=xxxxx
GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback
The Google strategy
The strategy declares its credentials and the scope it wants, then maps the provider’s raw profile into a normalized shape in validate(). The done callback (or a returned value) becomes req.user.
// auth/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback, Profile } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
callbackURL: config.getOrThrow('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
const { id, displayName, emails, photos } = profile;
const user = await this.authService.validateOAuthUser({
provider: 'google',
providerId: id,
email: emails?.[0]?.value,
name: displayName,
avatarUrl: photos?.[0]?.value,
});
done(null, user);
}
}
The GitHub strategy
GitHub is nearly identical — only the package and the scope differ. Request the user:email scope so a verified email is included even when the user’s public profile hides it.
// auth/github.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Profile } from 'passport-github2';
import { AuthService } from './auth.service';
@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: config.getOrThrow('GITHUB_CLIENT_ID'),
clientSecret: config.getOrThrow('GITHUB_CLIENT_SECRET'),
callbackURL: config.getOrThrow('GITHUB_CALLBACK_URL'),
scope: ['user:email'],
});
}
async validate(_accessToken: string, _refreshToken: string, profile: Profile) {
return this.authService.validateOAuthUser({
provider: 'github',
providerId: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName ?? profile.username,
avatarUrl: profile.photos?.[0]?.value,
});
}
}
Always key social accounts by
provider + providerId, never by email alone. Emails change and can be unverified, but the provider’s stable subject id uniquely identifies the account forever.
Provisioning and linking users
validateOAuthUser() is where the business logic lives: find an existing social account, link it to a logged-in user, or create a brand-new user (just-in-time provisioning). The example assumes a users table and a social_accounts table with a unique index on (provider, provider_id).
// auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
export interface OAuthProfile {
provider: 'google' | 'github';
providerId: string;
email?: string;
name?: string;
avatarUrl?: string;
}
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService) {}
async validateOAuthUser(profile: OAuthProfile) {
// 1. Already linked? Return the existing user.
const linked = await this.users.findBySocialAccount(
profile.provider,
profile.providerId,
);
if (linked) return linked;
// 2. A local account with the same verified email? Link it.
if (profile.email) {
const existing = await this.users.findByEmail(profile.email);
if (existing) {
await this.users.linkSocialAccount(existing.id, profile);
return existing;
}
}
// 3. New user — provision from the OAuth profile.
return this.users.createFromOAuth(profile);
}
}
Wiring the controller and module
Two routes per provider: the entry route (the guard redirects to the consent screen) and the callback route (the guard finishes the exchange, then you issue your own session or JWT).
// auth/auth.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Get('google')
@UseGuards(AuthGuard('google'))
googleLogin() {
// The guard redirects to Google; nothing to do here.
}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req) {
return this.auth.issueTokens(req.user); // your JWT/session
}
@Get('github')
@UseGuards(AuthGuard('github'))
githubLogin() {}
@Get('github/callback')
@UseGuards(AuthGuard('github'))
async githubCallback(@Req() req) {
return this.auth.issueTokens(req.user);
}
}
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { GoogleStrategy } from './google.strategy';
import { GithubStrategy } from './github.strategy';
@Module({
imports: [ConfigModule, PassportModule, UsersModule],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy, GithubStrategy],
})
export class AuthModule {}
Output:
GET /auth/google
302 Found -> https://accounts.google.com/o/oauth2/v2/auth?client_id=...
# user approves, Google redirects back
GET /auth/google/callback?code=4/0Ax...&scope=email+profile
200 OK
{ "accessToken": "eyJhbGci...", "user": { "id": 7, "email": "[email protected]" } }
Provider option reference
| Option | GitHub | Purpose | |
|---|---|---|---|
clientID / clientSecret | required | required | App credentials from the provider console |
callbackURL | required | required | Must exactly match a registered redirect URI |
scope | ['email','profile'] | ['user:email'] | Data and permissions requested |
| Strategy package | passport-google-oauth20 | passport-github2 | Concrete Passport strategy |
| Stable id field | profile.id | profile.id | Use for provider + providerId lookup |
Best Practices
- Identify social accounts by
provider + providerIdand store them in a dedicatedsocial_accountstable so one user can link multiple providers. - Only auto-link to an existing local account when the provider reports a verified email; otherwise force an explicit linking step to prevent account takeover.
- Keep client secrets and callback URLs in environment variables and load them via
ConfigService.getOrThrow()so a missing value fails fast at boot. - Register the exact
callbackURLin each provider’s console — a mismatch is the most common cause ofredirect_uri_mismatcherrors. - After a successful callback, issue your own JWT or session rather than handing the provider’s access token to the client.
- Add CSRF protection to the OAuth flow with the strategy’s
stateparameter, and request the narrowest scopes you actually use.