Google Workspace SSO for SaaS in NestJS

There's a moment in every B2B sale where the prospect's security team sends a one-line email that stalls the whole deal: "Do you support SSO?" Google Workspace SSO is how you answer yes — it lets enterprise IT manage access to your app from their own admin console, and it's table stakes for selling to any company big enough to have a procurement process. Build it wrong, though, and you ship something that looks like enterprise SSO while quietly letting any @gmail.com address walk in.
The single control that makes the difference: validate the hosted-domain (hd) claim from Google's verified ID token, and map it to a registered tenant. Everything else — OIDC setup, just-in-time provisioning, role mapping — is plumbing around that one check. Skip it and you don't have enterprise SSO; you have a login button with a security hole. Here's the production build in NestJS.

Why OIDC Over SAML
Google Workspace speaks both SAML 2.0 and OpenID Connect, but OIDC is the better fit for modern SaaS:
- SAML — older, XML payloads POSTed through the browser, heavier to parse and configure in a REST API. Some enterprises still mandate it, so keep it on the roadmap.
- OIDC — built on OAuth 2.0, issues compact signed JWTs you verify with a standard signature check. Clean for SPAs and backends alike, and it's the same OAuth flow as any social-login integration, just pointed at a Workspace domain.
Start with OIDC; reach for SAML when a contract specifically requires it. Google's own OpenID Connect docs are the reference for the token shape.
Configure Google as the Identity Provider
In the Google Cloud Console, before any code:
- Create a dedicated project for your app.
- Configure the OAuth consent screen with scopes
openid,profile,email. - Generate OAuth 2.0 web client credentials — your
Client IDandClient Secret. - Set the authorized redirect URI, e.g.
http://localhost:3000/api/auth/google/callbackfor local dev.
Keep the client secret in encrypted runtime config, never in the repo.
The NestJS OIDC Strategy (and the hd Check)
A Passport strategy handles the callback. The security lives in validate, where you read the hosted domain:
1// src/auth/strategies/google-oidc.strategy.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { PassportStrategy } from '@nestjs/passport';
4import { Strategy, Profile } from 'passport-google-oauth20';
5
6@Injectable()
7export class GoogleOidcStrategy extends PassportStrategy(Strategy, 'google') {
8 constructor() {
9 super({
10 clientID: process.env.GOOGLE_CLIENT_ID!,
11 clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
12 callbackURL: process.env.GOOGLE_CALLBACK_URL!,
13 scope: ['openid', 'email', 'profile'],
14 });
15 }
16
17 async validate(_accessToken: string, _refreshToken: string, profile: Profile) {
18 const email = profile.emails?.[0]?.value;
19 const hostedDomain = (profile._json as { hd?: string }).hd; // present only for Workspace accounts
20
21 if (!email) throw new UnauthorizedException('Google profile missing email.');
22 if (!hostedDomain) throw new UnauthorizedException('Personal Google accounts are not allowed.');
23
24 return {
25 email,
26 firstName: profile.name?.givenName,
27 lastName: profile.name?.familyName,
28 domain: hostedDomain, // used to resolve the tenant
29 };
30 }
31}One hardening note: passport-google-oauth20 fetches the profile from Google over TLS, but for defense in depth verify the hd from the signed ID token (via openid-client or by validating the id_token) rather than trusting a profile field alone. The point is the same — no hd, no entry.

Just-In-Time Provisioning
Don't make IT pre-create accounts. Once the domain checks out, upsert the user and bind them to the tenant on first login:
1// src/auth/auth.service.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { PrismaService } from '../prisma/prisma.service';
4
5@Injectable()
6export class AuthService {
7 constructor(private readonly prisma: PrismaService) {}
8
9 async loginWithGoogle(p: { email: string; firstName?: string; lastName?: string; domain: string }) {
10 // Resolve the tenant from the verified hosted domain.
11 const tenant = await this.prisma.tenant.findUnique({ where: { registeredDomain: p.domain } });
12 if (!tenant) {
13 throw new UnauthorizedException('Your domain is not registered for this workspace.');
14 }
15
16 // Create on first login; update on subsequent ones.
17 return this.prisma.user.upsert({
18 where: { email: p.email },
19 update: { lastLoginAt: new Date() },
20 create: {
21 email: p.email,
22 firstName: p.firstName,
23 lastName: p.lastName,
24 tenantId: tenant.id, // scopes the user to the right company
25 isActive: true,
26 },
27 });
28 }
29}The tenant.findUnique on registeredDomain is what stops a valid Google user from a different company logging into a tenant they don't belong to — the hd check and the tenant lookup work as a pair.
Map Workspace Groups to Roles
You can map Google Workspace groups to your internal roles, but be honest about the cost: reading group membership needs the Admin SDK Directory API with admin-granted scopes (often domain-wide delegation), not just the login scopes. When it's worth it, the mapping is straightforward:
dev-leads@enterprise.com→Adminstaff@enterprise.com→User
For many products it's simpler to provision users via SSO and manage elevation inside your own role-based access control, syncing groups only for the enterprises that ask. Don't take the heavy Admin SDK scopes you don't need — every extra scope is something the customer's security review will question.
Offboarding: Honor the Off Switch Fast
SSO's whole promise to IT is one place to cut access. When they disable a terminated employee's Google account, your app has to notice quickly. Keep access tokens short (~15 minutes) and re-check account status on every refresh-token rotation against Redis, so a disabled user is locked out within minutes, not whenever a long-lived token expires. Stolen or stale credentials drive about 24% of breaches (Verizon DBIR 2024); fast revocation is exactly the mitigation enterprise buyers are checking for.

Test Google Workspace SSO Without a Live Tenant
You don't need a paid enterprise tenant to test the logic — mock the strategy and assert the domain handling:
1// test/auth.e2e-spec.ts
2describe('Google Workspace SSO', () => {
3 it('accepts a user whose hosted domain matches a registered tenant', async () => {
4 const strategy = {
5 validate: jest.fn().mockResolvedValue({
6 email: 'engineer@targetcorp.com',
7 domain: 'targetcorp.com',
8 }),
9 };
10 expect(await strategy.validate()).toHaveProperty('domain', 'targetcorp.com');
11 });
12});Build It or Buy It?
Rolling your own with openid-client gives you full control and no per-user fees — sensible if SSO is core and you have the security depth to verify tokens correctly. Managed providers like WorkOS, Auth0, or Clerk handle multi-tenant routing, SAML, and the security maintenance for you, which gets you to an enterprise "yes" faster. Default to managed unless you have a concrete reason to own the flow — this is auth, and you don't roll your own crypto without a good reason.
| Layer | Consumer default | Enterprise practice |
|---|---|---|
| Protocol | Plain OAuth grant | OIDC ID tokens, verified |
| Domain scope | Any Google email | hd claim + tenant lookup |
| Provisioning | Manual accounts | Just-in-time on first login |
| Offboarding | Long sessions | Short tokens + Redis status check |
Get the hd check right and Google Workspace SSO turns a stalled security review into a signature. Skip it and you've built the convenience without the control — which the customer's IT team will find, usually in the demo, usually right before they were going to say yes.
Frequently Asked Questions
The hd (hosted domain) claim is a value in Google's verified ID token that names the Workspace domain a user belongs to, like company.com. It's the control that turns 'sign in with Google' into real enterprise SSO: without checking it, anyone with a personal @gmail.com account could log into an enterprise tenant. Verify hd from the signed ID token, confirm it matches a registered tenant domain, and reject everything else.
OIDC for most modern SaaS. It's built on OAuth 2.0, issues compact signed JWTs your server verifies with a standard signature check, and fits REST APIs and SPAs cleanly. SAML is the older XML-over-browser-POST protocol — still required by some enterprise buyers, but heavier to parse and configure. Start with OIDC, and add SAML only when a specific deal demands it.
JIT provisioning creates the user record on their first successful SSO login instead of making an admin pre-create accounts. When the token's domain matches a registered tenant, you upsert the user and link them to that tenant. It removes onboarding friction — IT enables your app in their Google admin console and employees just log in — while the hd check keeps provisioning scoped to the right company.
Keep access tokens short (around 15 minutes) and re-check account status on every refresh against a fast store like Redis. When IT disables the employee's Google account, your next refresh fails and they're locked out within minutes rather than whenever a long-lived token happens to expire. SSO centralizes the off switch — your job is to honor it quickly.
Building with openid-client or Passport gives you full control, no per-user fees, and no third-party dependency — reasonable if SSO is core and you have the security depth. Managed providers (WorkOS, Auth0, Clerk) handle multi-tenant routing, SAML, and security updates out of the box, which gets you to an enterprise 'yes' faster. Default to managed unless you have a concrete reason to own it.
