OAuth2 Social Login in NestJS — Google, GitHub, and LinkedIn With Passport.js

Setting up one OAuth provider in NestJS is copy-paste work. The documentation is decent, the strategy exists, and a single Google sign-in button covers maybe half of your users. Setting up three providers — Google, GitHub, LinkedIn — with account linking and token lifecycle management is where the real engineering starts. Each provider has its own quirks, its own scope conventions, and its own error modes that surface only when a real user hits your callback URL at 2am from an unsupported browser.
This post walks through a complete NestJS OAuth2 social login implementation with all three providers, including the account merging logic that handles the "I already signed up with email" case, the token storage decisions that matter for your API rate limits, and the error handling that turns a 500 into a user-friendly redirect.

The OAuth2 Authorization Code Flow — What Actually Happens
Before writing a single strategy, it is worth understanding the OAuth2 authorization code flow because every provider implements the same RFC 6749 with slightly different defaults and error codes.
- Your application redirects the user to the provider's authorization endpoint with your client ID, requested scopes, redirect URI, and a random state parameter.
- The user logs in on the provider's domain and grants permission.
- The provider redirects back to your callback URL with an authorization code and the state parameter.
- Your server exchanges the authorization code for an access token (and optionally a refresh token) using a server-to-server POST request that includes your client secret.
- Your server uses the access token to fetch the user's profile from the provider's userinfo endpoint.
- You create or look up the user in your database, generate your own JWT or session token, and redirect the authenticated user to your application.
Step 4 is the critical security boundary. The authorization code exchange happens server-side, which means the client secret is never exposed to the browser. This is why you need a backend for social login — a purely client-side flow cannot protect the client secret.
NestJS OAuth2 Social Login: Shared Module Structure
A NestJS OAuth2 social login setup gives each provider its own strategy, guard, and controller, but they share a common service for user lookup and JWT generation. The module structure looks like this:
1src/
2 auth/
3 auth.module.ts
4 auth.service.ts # JWT generation, user lookup
5 strategies/
6 google.strategy.ts
7 github.strategy.ts
8 linkedin.strategy.ts
9 guards/
10 google-oauth.guard.ts
11 github-oauth.guard.ts
12 linkedin-oauth.guard.ts
13 controllers/
14 google-oauth.controller.ts
15 github-oauth.controller.ts
16 linkedin-oauth.controller.ts
17 dto/
18 oauth-user.dto.tsThe AuthService is shared across all three providers. It handles the logic that is the same regardless of which button the user clicked: find or create the user, generate a JWT, store the provider tokens if needed, and redirect.
Base OAuth Controller Pattern
Every provider follows the same controller pattern — a login route that initiates the flow and a callback route that handles the redirect:
1// src/auth/controllers/google-oauth.controller.ts
2import { Controller, Get, UseGuards, Req, Res, Query } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { Request, Response } from 'express';
5import { AuthService } from '../auth.service';
6import { GoogleOauthGuard } from '../guards/google-oauth.guard';
7
8@Controller('auth/google')
9export class GoogleOauthController {
10 constructor(
11 private readonly authService: AuthService,
12 private readonly configService: ConfigService,
13 ) {}
14
15 @Get()
16 @UseGuards(GoogleOauthGuard)
17 async googleAuth() {
18 // Guard handles the redirect to Google
19 }
20
21 @Get('redirect')
22 @UseGuards(GoogleOauthGuard)
23 async googleAuthRedirect(@Req() req: Request, @Res() res: Response) {
24 const result = await this.authService.handleOauthLogin(req.user, 'google');
25 return res.redirect(result.redirectUrl);
26 }
27}The guard extends AuthGuard('google') from @nestjs/passport. The strategy handles the OAuth2 flow and returns a normalized user object that handleOauthLogin processes. This pattern repeats for all three providers with only the strategy configuration changing.
Google OAuth2 Strategy
Google is the most straightforward of the three. The passport-google-oauth20 strategy is well-maintained and the profile response is consistent.
1// src/auth/strategies/google.strategy.ts
2import { PassportStrategy } from '@nestjs/passport';
3import { Strategy, Profile, VerifyCallback } from 'passport-google-oauth20';
4import { Injectable } from '@nestjs/common';
5import { ConfigService } from '@nestjs/config';
6
7@Injectable()
8export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
9 constructor(configService: ConfigService) {
10 super({
11 clientID: configService.get('GOOGLE_CLIENT_ID'),
12 clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
13 callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
14 scope: ['email', 'profile'],
15 });
16 }
17
18 async validate(
19 accessToken: string,
20 refreshToken: string,
21 profile: Profile,
22 done: VerifyCallback,
23 ): Promise<void> {
24 const { id, name, emails, photos } = profile;
25
26 const user = {
27 provider: 'google' as const,
28 providerId: id,
29 email: emails?.[0]?.value ?? null,
30 firstName: name?.givenName ?? null,
31 lastName: name?.familyName ?? null,
32 avatar: photos?.[0]?.value ?? null,
33 accessToken,
34 refreshToken,
35 };
36
37 done(null, user);
38 }
39}The validate method normalizes the provider-specific profile into a common interface that AuthService.handleOauthLogin can process regardless of which provider the user chose. This normalization is the pattern that keeps the auth service clean — it never needs to know whether the profile came from Google, GitHub, or LinkedIn.

GitHub OAuth2 Strategy
GitHub differs from Google in a few important ways. The strategy uses passport-github2, the scopes are different (read:user instead of email and profile), and the profile response includes a username field that Google does not provide.
1// src/auth/strategies/github.strategy.ts
2import { PassportStrategy } from '@nestjs/passport';
3import { Strategy, Profile } from 'passport-github2';
4import { Injectable } from '@nestjs/common';
5import { ConfigService } from '@nestjs/config';
6
7@Injectable()
8export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
9 constructor(configService: ConfigService) {
10 super({
11 clientID: configService.get('GITHUB_CLIENT_ID'),
12 clientSecret: configService.get('GITHUB_CLIENT_SECRET'),
13 callbackURL: configService.get('GITHUB_CALLBACK_URL'),
14 scope: ['read:user', 'user:email'],
15 });
16 }
17
18 async validate(
19 accessToken: string,
20 refreshToken: string,
21 profile: Profile,
22 done: (err: unknown, user?: unknown) => void,
23 ): Promise<void> {
24 const { id, username, displayName, emails, photos } = profile;
25
26 const user = {
27 provider: 'github' as const,
28 providerId: id,
29 username,
30 email: emails?.[0]?.value ?? null,
31 firstName: displayName?.split(' ')[0] ?? username,
32 lastName: displayName?.split(' ').slice(1).join(' ') ?? null,
33 avatar: photos?.[0]?.value ?? null,
34 accessToken,
35 refreshToken,
36 };
37
38 done(null, user);
39 }
40}One important difference: GitHub requires you to explicitly request user:email scope to get the user's primary email address. Without it, the email field in the profile is often null for users who have set their email to private on GitHub. If email-based account linking is critical for your application, make sure this scope is included.
GitHub also has a notable restriction on redirect URIs — they must match exactly. Unlike Google which allows path variation, GitHub rejects callbacks where the redirect URI does not match the registered URI character-for-character.

LinkedIn OAuth2 Strategy
LinkedIn is the most opinionated of the three. It requires OpenID Connect (OIDC) for user profile data, which means the scope and endpoint structure are different from the other two providers.

1// src/auth/strategies/linkedin.strategy.ts
2import { PassportStrategy } from '@nestjs/passport';
3import { Strategy, Profile } from 'passport-linkedin-oauth2';
4import { Injectable } from '@nestjs/common';
5import { ConfigService } from '@nestjs/config';
6
7@Injectable()
8export class LinkedinStrategy extends PassportStrategy(Strategy, 'linkedin') {
9 constructor(configService: ConfigService) {
10 super({
11 clientID: configService.get('LINKEDIN_CLIENT_ID'),
12 clientSecret: configService.get('LINKEDIN_CLIENT_SECRET'),
13 callbackURL: configService.get('LINKEDIN_CALLBACK_URL'),
14 scope: ['openid', 'profile', 'email'],
15 });
16 }
17
18 async validate(
19 accessToken: string,
20 refreshToken: string,
21 profile: Profile,
22 done: (err: unknown, user?: unknown) => void,
23 ): Promise<void> {
24 const { id, name, emails, photos } = profile;
25
26 const user = {
27 provider: 'linkedin' as const,
28 providerId: id,
29 email: emails?.[0]?.value ?? null,
30 firstName: name?.givenName ?? null,
31 lastName: name?.familyName ?? null,
32 avatar: photos?.[0]?.value ?? null,
33 accessToken,
34 refreshToken,
35 };
36
37 done(null, user);
38 }
39}The critical difference with LinkedIn is the API version. LinkedIn deprecated their v1 API in 2023 and moved to v2 with OpenID Connect. The passport-linkedin-oauth2 strategy handles this if you use version 2.0.0 or later, but the scope format changed from r_liteprofile and r_emailaddress to openid, profile, and email. If you are following an outdated tutorial that uses the old scope names, the authorization screen will display incorrectly and the profile response may be empty.
LinkedIn also has the most restrictive rate limits of the three providers — roughly 100 calls per user per day for the profile endpoint. Cache the profile response rather than fetching it on every login.
Auth Service: User Lookup, Account Linking, and JWT Generation
The AuthService is the shared component that all three provider controllers call after the OAuth flow completes. This is where the account linking logic lives.
1// src/auth/auth.service.ts
2import { Injectable } from '@nestjs/common';
3@Injectable()
4export class AuthService {
5 constructor(
6 private readonly usersService: UsersService,
7 private readonly jwtService: JwtService,
8 private readonly configService: ConfigService,
9 ) {}
10
11 async handleOauthLogin(
12 oauthUser: OauthUserDto,
13 provider: string,
14 ): Promise<{ redirectUrl: string }> {
15 // Try to find existing user by provider ID
16 let user = await this.usersService.findByProvider(
17 provider,
18 oauthUser.providerId,
19 );
20
21 if (user) {
22 // Existing user — update profile and tokens
23 await this.usersService.updateProviderTokens(
24 user.id,
25 provider,
26 oauthUser.accessToken,
27 oauthUser.refreshToken,
28 );
29 } else if (oauthUser.email) {
30 // Check if user exists with this email from another provider
31 user = await this.usersService.findByEmail(oauthUser.email);
32
33 if (user) {
34 // Link the new provider to the existing account
35 await this.usersService.linkProvider(
36 user.id,
37 provider,
38 oauthUser.providerId,
39 oauthUser.accessToken,
40 oauthUser.refreshToken,
41 );
42 } else {
43 // Create new user
44 user = await this.usersService.createFromProvider(oauthUser);
45 }
46 } else {
47 // No email — create user with provider ID only
48 user = await this.usersService.createFromProvider(oauthUser);
49 }
50
51 // Generate JWT
52 const token = this.jwtService.sign(
53 { sub: user.id, email: user.email },
54 { expiresIn: '15m' },
55 );
56
57 const refreshToken = this.jwtService.sign(
58 { sub: user.id, type: 'refresh' },
59 { expiresIn: '7d' },
60 );
61
62 // Store refresh token
63 await this.usersService.storeRefreshToken(user.id, refreshToken);
64
65 // Redirect to frontend with JWT
66 const frontendUrl = this.configService.get('FRONTEND_URL');
67 return {
68 redirectUrl: `${frontendUrl}/auth/callback?token=${token}&refreshToken=${refreshToken}`,
69 };
70 }
71}The account linking logic handles three scenarios:
- User already has this provider linked — update their profile and tokens, no new database record needed.
- User exists with this email from another provider or email signup — link the new provider to the existing account so they can sign in with either method.
- New user entirely — create a new user record with the provider information.
Without this linking logic, a user who signs up with Google on Monday and tries GitHub on Tuesday would end up with two separate accounts. That is a support ticket waiting to happen.
For the full JWT implementation including refresh token rotation, see our JWT authentication with refresh tokens guide.
Database Schema for Provider Accounts
The database schema mirrors the account linking logic. A user_providers table stores the mapping between providers and user accounts.
1CREATE TABLE users (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 email TEXT UNIQUE,
4 first_name TEXT,
5 last_name TEXT,
6 avatar TEXT,
7 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9);
10
11CREATE TABLE user_providers (
12 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14 provider TEXT NOT NULL, -- 'google', 'github', 'linkedin'
15 provider_id TEXT NOT NULL, -- the ID from the provider
16 access_token TEXT, -- encrypted at rest
17 refresh_token TEXT, -- encrypted at rest
18 token_expires_at TIMESTAMPTZ,
19 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
20 UNIQUE(provider, provider_id)
21);
22
23CREATE INDEX idx_user_providers_user_id ON user_providers(user_id);
24CREATE INDEX idx_user_providers_provider ON user_providers(provider, provider_id);One user can have multiple provider records. When they sign in with any linked provider, the system finds the user record through the provider mapping and issues a JWT for the same account.
When to Store OAuth Tokens — and When Not To
Not every application needs to store the access token and refresh token returned by the OAuth flow. The decision depends on whether your application needs to call the provider's APIs on behalf of the user.
Store the tokens when your application needs to:
- Import contacts from Google or LinkedIn
- Create issues or PRs on GitHub for the user
- Post content to LinkedIn on behalf of the user
- Access any provider API that the user authorized
Do not store the tokens when the OAuth flow is purely for authentication — you only need the provider to confirm the user's identity, and all application data lives in your own database.
If you do store tokens, encrypt them at rest. OAuth tokens grant access to the user's data on the provider's platform, and a database breach would leak those tokens to an attacker. Use your application's encryption key to encrypt the access_token and refresh_token columns before writing them to the database.
1// Encrypt tokens before storing
2import { createCipheriv, randomBytes } from 'node:crypto';
3
4function encryptToken(token: string, encryptionKey: Buffer): string {
5 const iv = randomBytes(16);
6 const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv);
7 const encrypted = Buffer.concat([
8 cipher.update(token, 'utf8'),
9 cipher.final(),
10 ]);
11 const tag = cipher.getAuthTag();
12 return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
13}Handling OAuth Errors Gracefully
OAuth flows fail in predictable ways, and the error handling pattern is the same across all three providers. The callback URL receives error parameters when the flow fails:
1// google-oauth.controller.ts — the hardened redirect handler (replaces the basic
2// googleAuthRedirect above; uses the injected authService and configService)
3@Get('redirect')
4@UseGuards(GoogleOauthGuard)
5async googleAuthRedirect(
6 @Req() req: Request,
7 @Res() res: Response,
8 @Query('error') error?: string,
9 @Query('error_description') errorDescription?: string,
10) {
11 if (error) {
12 // User denied permission or an error occurred
13 const frontendUrl = this.configService.get('FRONTEND_URL');
14 const redirectUrl = error === 'access_denied'
15 ? `${frontendUrl}/auth/login?error=permission_denied`
16 : `${frontendUrl}/auth/login?error=oauth_failed&description=${encodeURIComponent(errorDescription || error)}`;
17
18 return res.redirect(redirectUrl);
19 }
20
21 const result = await this.authService.handleOauthLogin(req.user, 'google');
22 return res.redirect(result.redirectUrl);
23}Common error scenarios and how to handle them:
| Error | Cause | Handling |
|---|---|---|
access_denied | User clicked "Cancel" on the provider's consent screen | Redirect to login with a neutral message — user chose not to proceed |
server_error | Provider had a transient internal error | Redirect to login with a retry suggestion |
temporarily_unavailable | Provider rate limit or maintenance | Implement exponential backoff and retry |
invalid_request | Missing or invalid parameters (usually a code bug) | Log the full request details and redirect to an error page |
| State parameter mismatch | CSRF attack or expired state | Log a security warning and redirect to login |
The state parameter is your CSRF protection. Generate a random string, store it in the session or a short-lived cookie before redirecting to the provider, and verify it matches when the callback arrives:
1import { AuthGuard } from '@nestjs/passport';
2import { Injectable } from '@nestjs/common';
3
4@Injectable()
5export class GoogleOauthGuard extends AuthGuard('google') {
6 async authenticate(req: any) {
7 // Generate and store state parameter
8 const state = crypto.randomBytes(16).toString('hex');
9 req.session.oauthState = state;
10
11 super.authenticate(req, {
12 state,
13 accessType: 'offline',
14 prompt: 'select_account',
15 });
16 }
17}Registering All Strategies in the Module
All three strategies and their corresponding guards are registered as providers in the auth module:
1// src/auth/auth.module.ts
2import { Module } from '@nestjs/common';
3import { JwtModule } from '@nestjs/jwt';
4import { PassportModule } from '@nestjs/passport';
5import { ConfigModule, ConfigService } from '@nestjs/config';
6
7import { AuthService } from './auth.service';
8import { GoogleStrategy } from './strategies/google.strategy';
9import { GithubStrategy } from './strategies/github.strategy';
10import { LinkedinStrategy } from './strategies/linkedin.strategy';
11import { GoogleOauthController } from './controllers/google-oauth.controller';
12import { GithubOauthController } from './controllers/github-oauth.controller';
13import { LinkedinOauthController } from './controllers/linkedin-oauth.controller';
14import { UsersModule } from '../users/users.module';
15
16@Module({
17 imports: [
18 PassportModule.register({ defaultStrategy: 'jwt' }),
19 JwtModule.registerAsync({
20 imports: [ConfigModule],
21 inject: [ConfigService],
22 useFactory: (config: ConfigService) => ({
23 secret: config.get('JWT_SECRET'),
24 signOptions: { expiresIn: '15m' },
25 }),
26 }),
27 UsersModule,
28 ],
29 controllers: [
30 GoogleOauthController,
31 GithubOauthController,
32 LinkedinOauthController,
33 ],
34 providers: [
35 AuthService,
36 GoogleStrategy,
37 GithubStrategy,
38 LinkedinStrategy,
39 ],
40})
41export class AuthModule {}The PassportModule registers 'jwt' as the default strategy for your API routes, while the OAuth strategies are used explicitly in their respective controllers. The Passport.js documentation covers the full set of configuration options for each strategy type.
Environment Variables
Each provider requires its own set of credentials from their respective developer consoles:
1# Google OAuth
2GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
3GOOGLE_CLIENT_SECRET=your-client-secret
4GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
5
6# GitHub OAuth
7GITHUB_CLIENT_ID=your-client-id
8GITHUB_CLIENT_SECRET=your-client-secret
9GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/redirect
10
11# LinkedIn OAuth
12LINKEDIN_CLIENT_ID=your-client-id
13LINKEDIN_CLIENT_SECRET=your-client-secret
14LINKEDIN_CALLBACK_URL=http://localhost:3000/auth/linkedin/redirect
15
16# JWT
17JWT_SECRET=your-jwt-secret
18
19# Frontend
20FRONTEND_URL=http://localhost:3001The callback URLs must match exactly what you register in each provider's developer console. For local development, use http://localhost — not https — because OAuth callbacks to localhost over HTTP are accepted by all three providers.
Each provider's credential setup process is different. For Google, visit the Google API Console and create an OAuth 2.0 Client ID. For GitHub, go to Settings > Developer settings > OAuth Apps. For LinkedIn, go to the LinkedIn Developer Portal and create an app.
Testing the Full Flow Locally
End-to-end testing of OAuth flows requires a browser, but you can test the token processing logic in isolation with integration tests:
1describe('AuthService', () => {
2 it('should link provider to existing user by email', async () => {
3 const existingUser = await usersService.create({
4 email: 'test@example.com',
5 password: 'hashed-password',
6 });
7
8 const result = await authService.handleOauthLogin(
9 {
10 provider: 'google',
11 providerId: 'google-123',
12 email: 'test@example.com',
13 firstName: 'Test',
14 lastName: 'User',
15 avatar: null,
16 accessToken: 'at-123',
17 refreshToken: 'rt-123',
18 },
19 'google',
20 );
21
22 expect(result.redirectUrl).toContain('token=');
23
24 const providers = await usersService.getProviders(existingUser.id);
25 expect(providers).toHaveLength(1);
26 expect(providers[0].provider).toBe('google');
27 });
28});Test every account linking scenario: new user, existing email but new provider, existing provider but re-auth, and the edge case where a user has both an email account and a provider with the same email.
Conclusion
A three-provider NestJS OAuth2 social login implementation is not complicated — it is about thirty lines of code per strategy, a shared auth service for account linking, and a database table for provider mappings. The complexity is in the edge cases: the user who signs in with Google on their phone and GitHub on their laptop and expects the same account, the LinkedIn API version change that broke the scope format, the GitHub user whose email is private and the user:email scope was not requested.
The pattern works regardless of which providers you add. Each new provider is a new strategy file following the same normalization interface, a new guard, a new controller, and zero changes to the auth service. Add Facebook by installing passport-facebook and writing a strategy. Add Apple by installing passport-apple and handling the privateKey configuration. The account linking logic stays the same.
If you are currently supporting only one OAuth provider and wondering whether the account-linking investment is worth it — it is. The second provider you add will take about an hour. The third one will take thirty minutes. The first support ticket from a user who cannot merge their Google and GitHub identities will take longer than all three combined.
For the authentication layer that protects your API routes after OAuth login, see the NestJS JWT implementation guide. For structuring the auth module within your project, see our NestJS project structure guide. And if you would rather have someone sanity-check your account-linking logic before it ships, we are happy to look it over.
Frequently Asked Questions
OAuth2 social login in NestJS lets users authenticate with their existing Google, GitHub, or LinkedIn accounts instead of creating a new username and password. Passport.js strategies handle the OAuth2 flow — redirecting to the provider, receiving an authorization code, exchanging it for tokens, and returning the user profile. NestJS integrates Passport through the @nestjs/passport module.
Install passport-google-oauth20, create a strategy class extending PassportStrategy with your client ID, client secret, callback URL, and email/profile scopes. Create a guard extending AuthGuard('google'), set up a controller with a login route and a callback route, and register the strategy as a provider in your module.
GitHub OAuth uses the passport-github2 strategy instead of passport-google-oauth20. The main differences are: GitHub requires a user-agent header in the token exchange, uses a different scope naming convention (read:user, repo vs email, profile), and the profile response includes a username field that Google does not provide. GitHub also enforces stricter redirect URI matching.
Account linking is the process of associating multiple OAuth provider identities (Google, GitHub, LinkedIn) to a single user account in your database. When a user signs in with Google and they already have an account created via email or GitHub, you match them by email address and merge the provider identities rather than creating a duplicate account.
Handle OAuth errors by capturing error query parameters in the callback route (error, error_description), differentiating between user-cancelled auth (access_denied) and system errors (server_error, temporarily_unavailable), redirecting to appropriate frontend error pages, and logging all failures for debugging. Include CSRF protection using the OAuth state parameter.
