Social authentication has become the default expectation for modern web apps, and Google login in NestJS is one of the quickest wins you can implement. I've integrated Google OAuth into e-commerce platforms and SaaS dashboards where users expect frictionless signup—this guide shows exactly how to do it safely, using passport-google-oauth20 to handle the OAuth flow while keeping your JWT issuing logic bulletproof.
Why Social Login Matters in Production
When I was building a travel platform, we noticed that email/password signup had a 40% drop-off rate. After adding Google login, first-time user conversion jumped significantly. The reason is simple: users already have a Google account, they trust Google's security, and they don't want to remember another password. From a backend perspective, you're outsourcing authentication complexity to Google while still maintaining full control over your user records and session tokens.
The architecture is straightforward: redirect users to Google, catch the callback with verified credentials, create or fetch the user in your database, then issue your own JWT. This way, your API remains stateless and you control the entire token lifecycle.
Redirect to Google, Handle the Callback
The first step is installing and configuring Passport with the Google OAuth 2.0 strategy. This handles the OAuth dance automatically—you just wire up the routes.
npm install @nestjs/passport passport-google-oauth20 passport @nestjs/jwt
npm install -D @types/passport-google-oauth20Next, create a Google strategy that tells Passport how to communicate with Google:
// auth/strategies/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor() {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL, // e.g., http://localhost:3000/auth/google/callback
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
) {
// This callback is invoked after Google verifies the user
// profile contains: id, emails, displayName, photos
const { id, displayName, emails, photos } = profile;
const email = emails[0].value;
const user = {
googleId: id,
email,
name: displayName,
avatar: photos[0]?.value,
accessToken,
refreshToken,
};
done(null, user);
}
}
Now create the auth controller to handle the OAuth redirects and callback:
// auth/auth.controller.ts
import { Controller, Get, UseGuards, Req, Res } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('google')
@UseGuards(AuthGuard('google'))
async googleAuth() {
// Passport redirects to Google automatically
}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req: Request, @Res() res: Response) {
// After Google redirects back, req.user contains the profile data
const { accessToken, refreshToken } = await this.authService.handleGoogleLogin(
req.user,
);
// Redirect to frontend with tokens in query string or secure cookie
res.redirect(
`${process.env.FRONTEND_URL}/auth/success?accessToken=${accessToken}&refreshToken=${refreshToken}`,
);
}
}
The @UseGuards(AuthGuard('google')) decorator tells Passport to use your Google strategy. On the /google route, it initiates the OAuth flow. On the /google/callback route, it catches Google's response and populates req.user with verified profile data.
Find or Create the User by Verified Email
Here's where you bridge OAuth data with your database. Google provides a verified email address, which is your unique identifier. The logic is simple: check if a user with that email exists. If not, create one. If yes, update any stale profile info.
// auth/auth.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async handleGoogleLogin(googleProfile: any) {
const { email, googleId, name, avatar } = googleProfile;
// Email is verified by Google, so it's safe to use as the lookup key
if (!email) {
throw new BadRequestException('Google profile missing email');
}
let user = await this.usersService.findByEmail(email);
if (!user) {
// Create new user from Google profile
user = await this.usersService.create({
email,
name,
avatar,
googleId,
authProvider: 'google',
});
} else {
// Update existing user with fresh Google data
// (in case they changed profile picture, etc.)
await this.usersService.update(user.id, {
googleId,
name,
avatar,
});
}
// Issue your own JWT tokens
const { accessToken, refreshToken } = this.issueTokens(user);
return { accessToken, refreshToken, user };
}
private issueTokens(user: any) {
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload, {
expiresIn: '15m',
});
const refreshToken = this.jwtService.sign(payload, {
expiresIn: '7d',
});
return { accessToken, refreshToken };
}
}
The key insight: the email verified by Google is your source of truth. You never blindly trust the entire profile—but you do trust the verified email address and the googleId. Store both so you can recognize returning users and prevent account duplication.
Issue Your Own JWT After Login
After finding or creating the user, you immediately issue a JWT. This JWT is your own authentication token, independent of Google. Why? Because your API should never be coupled to Google's session lifetime or API quotas. A user could be using your app for hours without needing to call Google again.
The tokens are typically returned to the frontend via redirect (for web browsers) or direct response (for mobile apps). For web browsers, I prefer sending tokens in secure, httpOnly cookies rather than query strings, to prevent token exposure in logs:
// auth/auth.controller.ts (refined)
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req: Request, @Res() res: Response) {
const { accessToken, refreshToken } = await this.authService.handleGoogleLogin(
req.user,
);
// Set secure cookies instead of query params
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Redirect to a safe success page
res.redirect(`${process.env.FRONTEND_URL}/dashboard`);
}
Your frontend can then use these cookies automatically on subsequent requests. If the accessToken expires, the refresh endpoint can issue a new one using the refreshToken. This keeps your API stateless while maintaining a secure session.
Never Trust Unverified Emails from Providers
This is the biggest security gotcha I've seen in production. Some OAuth providers allow unverified emails. Others return scopes you didn't ask for. Some developers trust the provider's data without validation. Don't do that.
Google's email is verified—but to be defensive, always validate:
async handleGoogleLogin(googleProfile: any) {
const { email, googleId, name, avatar, emails } = googleProfile;
// 1. Verify email exists
if (!email) {
throw new BadRequestException('Google profile missing email');
}
// 2. Verify email is marked as verified by Google
const primaryEmail = emails?.find((e: any) => e.verified === true);
if (!primaryEmail) {
throw new BadRequestException('Google email is not verified');
}
// 3. Validate email format on your side (extra layer)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new BadRequestException('Invalid email format');
}
// 4. Check for suspicious domains (optional but good practice)
const blockedDomains = ['tempmail.com', 'guerrillamail.com'];
const domain = email.split('@')[1];
if (blockedDomains.includes(domain)) {
throw new BadRequestException('Temporary email addresses not allowed');
}
// Proceed with user lookup/creation only after all checks
let user = await this.usersService.findByEmail(email);
// ...rest of logic
}
If you later support Facebook, GitHub, or other providers, each has different verification semantics. Make email verification a hard requirement across all providers, not just Google.
Common Mistakes
- Storing the OAuth access token without encryption: If you persist Google's accessToken (for refreshing user data), encrypt it at rest. Never log it or pass it through logs.
- Trusting provider data without verification: Even Google can have edge cases. Always re-validate emails and check the verified flag.
- Issuing JWTs with excessively long expiry: A 7-day accessToken is a security liability. Keep accessTokens to 15–60 minutes, and use refreshTokens for long-lived sessions.
- Mixing OAuth session management with JWT: Don't keep users "logged in" via Google's session and also issue a JWT. Pick one flow and stick to it. OAuth → verify → issue JWT is clean.
- Forgetting to handle token refresh on the frontend: Your frontend must detect when the accessToken expires (via 401 response) and call the refresh endpoint with the refreshToken. If you don't, users get kicked out after 15 minutes.
- Sending tokens in URLs: Always use secure cookies or Authorization headers. Query parameters are logged and cached.
Key Takeaways
- Use passport-google-oauth20 to handle OAuth redirects and profile verification; Passport does the heavy lifting.
- Google's verified email is your unique identifier. Look up or create the user based on email, then store the googleId for future OAuth attempts.
- Always issue your own JWT after successful OAuth login. This decouples your API from Google's session lifetime and gives you full control over token refresh and revocation.
- Validate email verification status, email format, and provider data on the server side. Never assume the frontend data is correct, and never assume the provider won't send edge cases.
- Use httpOnly, secure cookies for token storage in web browsers. Never expose tokens in URLs, logs, or unencrypted storage.
- Implement refresh token rotation and expiry checks on both access and refresh tokens to maintain a strong security posture.






Comments
Loading comments…