Domain-Scoped NextAuth with Workspace Tokens

Authentication & Authorization intermediate Building with AI ~25 min read

Overview

Internal tools that interact with Google Workspace APIs (Gmail, Calendar, Drive) face a recurring challenge: how do you authenticate users, restrict access to your organization, and persist OAuth tokens so that background sync jobs can call Google APIs without repeatedly prompting users to re-authorize? NextAuth.js solves the authentication layer, but configuring it for domain-scoped access with long-lived token persistence requires careful orchestration of OAuth scopes, callback handling, and secure token storage.

The pattern is straightforward in concept: only accept Google logins from a specific domain (@example.com), request the necessary API scopes (Gmail read, Calendar events, Drive file access) during the OAuth flow, capture the refresh token on first login, and persist it to a local token vault so that server-side cron jobs and sync pipelines can authenticate as the user without interactive consent. The complexity lies in the details—Google only issues refresh tokens on the first authorization, scope changes require re-consent, and tokens must be stored securely with proper encryption at rest.

As a practical example, consider an internal productivity tool configured to accept only @example.com Google logins, request Gmail/Calendar/Drive scopes, and persist refresh tokens in a local .app-tokens vault. Downstream sync jobs read tokens from this vault to call Google APIs on behalf of users without re-prompting, enabling automated email processing, calendar synchronization, and document indexing pipelines.

Core Concepts

Domain-Scoped OAuth

Google’s OAuth 2.0 supports a hd (hosted domain) parameter that restricts the account chooser to accounts from a specific Google Workspace domain. However, this is only a UI hint—the actual enforcement must happen server-side by verifying the authenticated user’s email domain in the NextAuth callback. Trusting the hd parameter alone without server-side validation is a common security vulnerability.

Key Concept: Server-Side Domain Validation

Never rely solely on Google’s hd parameter for domain restriction. An attacker can bypass the account chooser by crafting OAuth requests directly. Always validate the user’s email domain in the signIn callback and reject logins from unauthorized domains before creating a session.

OAuth Scopes and Incremental Authorization

Google APIs are gated by OAuth scopes. Each scope grants access to a specific API surface:

Google supports incremental authorization, where you can request additional scopes later without revoking existing grants. However, each scope change triggers a new consent screen, and the user must re-authorize. For internal tools, it’s simpler to request all needed scopes upfront during the initial login flow.

Refresh Token Lifecycle

Google only issues a refresh token on the first authorization for a given client/user/scope combination, or when the user explicitly re-consents (via prompt=consent and access_type=offline). If you miss capturing the refresh token on the first login, subsequent logins only return short-lived access tokens (1 hour). This is the single most common mistake when building Google OAuth integrations.

Key Concept: Capture Refresh Tokens on First Login

Set access_type: "offline" and prompt: "consent" in your OAuth configuration. Google issues refresh tokens only when both are present. Store the refresh token immediately—if you lose it, the user must revoke access in their Google Account settings and re-authorize from scratch.

Token Vault Architecture

Refresh tokens are long-lived credentials that grant persistent API access. They must be stored with encryption at rest, scoped access controls, and audit logging. The .app-tokens vault pattern uses the local filesystem with AES-256-GCM encryption, where the encryption key is derived from an environment variable or fetched from AWS Secrets Manager at startup.

Key Concept: Token Vault vs. Database Storage

Storing refresh tokens in a general-purpose database mixes authentication credentials with application data, increasing blast radius if the database is compromised. A dedicated token vault (encrypted file store or secrets manager) isolates credentials, enables independent access controls, and simplifies compliance auditing.

How It Works

Authentication Flow

  1. User visits the app and clicks “Sign in with Google”
  2. NextAuth redirects to Google OAuth with hd=example.com, requested scopes, access_type=offline, and prompt=consent
  3. Google shows consent screen restricted to @example.com accounts
  4. User authorizes; Google redirects back with an authorization code
  5. NextAuth exchanges the code for access token + refresh token
  6. The signIn callback verifies the email ends with @example.com
  7. The jwt callback captures tokens and stores the refresh token in the vault
  8. A session is created; the user is logged in
  9. Background sync jobs read the refresh token from the vault to call Google APIs

NextAuth Configuration

// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { TokenVault } from "@/lib/token-vault";

const vault = new TokenVault(process.env.TOKEN_VAULT_PATH!);

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: {
        params: {
          scope: [
            "openid",
            "email",
            "profile",
            "https://www.googleapis.com/auth/gmail.readonly",
            "https://www.googleapis.com/auth/calendar.events",
            "https://www.googleapis.com/auth/drive.readonly",
          ].join(" "),
          access_type: "offline",
          prompt: "consent",
          hd: "example.com",
        },
      },
    }),
  ],
  callbacks: {
    async signIn({ user, account }) {
      // Server-side domain enforcement
      if (!user.email?.endsWith("@example.com")) {
        return false; // Reject non-example.com logins
      }

      // Persist refresh token to vault on first login
      if (account?.refresh_token) {
        await vault.store(user.email, {
          refreshToken: account.refresh_token,
          accessToken: account.access_token!,
          expiresAt: account.expires_at!,
          scopes: account.scope!.split(" "),
        });
      }

      return true;
    },
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at;
      }
      return token;
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken as string;
      return session;
    },
  },
});

Token Vault Implementation

// lib/token-vault.ts
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";

interface StoredToken {
  refreshToken: string;
  accessToken: string;
  expiresAt: number;
  scopes: string[];
  storedAt: string;
}

export class TokenVault {
  private vaultDir: string;
  private key: Buffer;

  constructor(vaultPath: string) {
    this.vaultDir = vaultPath;
    if (!existsSync(vaultPath)) {
      mkdirSync(vaultPath, { recursive: true, mode: 0o700 });
    }
    // Derive encryption key from environment secret
    const secret = process.env.TOKEN_VAULT_SECRET!;
    this.key = scryptSync(secret, "token-vault-salt", 32);
  }

  async store(email: string, tokens: Omit<StoredToken, "storedAt">): Promise<void> {
    const data: StoredToken = {
      ...tokens,
      storedAt: new Date().toISOString(),
    };

    const iv = randomBytes(16);
    const cipher = createCipheriv("aes-256-gcm", this.key, iv);
    const encrypted = Buffer.concat([
      cipher.update(JSON.stringify(data), "utf8"),
      cipher.final(),
    ]);
    const authTag = cipher.getAuthTag();

    const filePath = join(this.vaultDir, this.emailToFilename(email));
    const payload = Buffer.concat([iv, authTag, encrypted]);
    writeFileSync(filePath, payload, { mode: 0o600 });
  }

  async retrieve(email: string): Promise<StoredToken | null> {
    const filePath = join(this.vaultDir, this.emailToFilename(email));
    if (!existsSync(filePath)) return null;

    const payload = readFileSync(filePath);
    const iv = payload.subarray(0, 16);
    const authTag = payload.subarray(16, 32);
    const encrypted = payload.subarray(32);

    const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
    decipher.setAuthTag(authTag);
    const decrypted = Buffer.concat([
      decipher.update(encrypted),
      decipher.final(),
    ]);

    return JSON.parse(decrypted.toString("utf8"));
  }

  private emailToFilename(email: string): string {
    return email.replace(/[^a-zA-Z0-9]/g, "_") + ".enc";
  }
}

Implementation Patterns

Background Sync Job Using Vault Tokens

// jobs/sync-gmail.ts
import { google } from "googleapis";
import { TokenVault } from "@/lib/token-vault";

const vault = new TokenVault(process.env.TOKEN_VAULT_PATH!);

async function syncGmailForUser(email: string): Promise<void> {
  const stored = await vault.retrieve(email);
  if (!stored) {
    console.warn(`No tokens found for ${email}, skipping`);
    return;
  }

  const oauth2Client = new google.auth.OAuth2(
    process.env.GOOGLE_CLIENT_ID,
    process.env.GOOGLE_CLIENT_SECRET
  );

  oauth2Client.setCredentials({
    refresh_token: stored.refreshToken,
    access_token: stored.accessToken,
    expiry_date: stored.expiresAt * 1000,
  });

  // Auto-refresh handles expired access tokens
  const gmail = google.gmail({ version: "v1", auth: oauth2Client });

  const messages = await gmail.users.messages.list({
    userId: "me",
    maxResults: 50,
    q: "newer_than:1d",
  });

  for (const msg of messages.data.messages ?? []) {
    const full = await gmail.users.messages.get({
      userId: "me",
      id: msg.id!,
      format: "metadata",
    });
    // Process message metadata for indexing...
    console.log(`Synced: ${full.data.snippet?.slice(0, 60)}`);
  }

  // Update stored access token if it was refreshed
  const newCreds = oauth2Client.credentials;
  if (newCreds.access_token !== stored.accessToken) {
    await vault.store(email, {
      refreshToken: stored.refreshToken,
      accessToken: newCreds.access_token!,
      expiresAt: Math.floor(newCreds.expiry_date! / 1000),
      scopes: stored.scopes,
    });
  }
}

Token Refresh Middleware

// middleware/ensure-fresh-token.ts
import { google } from "googleapis";
import { TokenVault } from "@/lib/token-vault";

const vault = new TokenVault(process.env.TOKEN_VAULT_PATH!);

export async function getFreshClient(email: string) {
  const stored = await vault.retrieve(email);
  if (!stored) throw new Error(`No tokens for ${email}`);

  const client = new google.auth.OAuth2(
    process.env.GOOGLE_CLIENT_ID,
    process.env.GOOGLE_CLIENT_SECRET
  );

  client.setCredentials({
    refresh_token: stored.refreshToken,
  });

  // Force token refresh if expired
  const now = Math.floor(Date.now() / 1000);
  if (stored.expiresAt < now + 300) {
    const { credentials } = await client.refreshAccessToken();
    await vault.store(email, {
      refreshToken: stored.refreshToken,
      accessToken: credentials.access_token!,
      expiresAt: Math.floor(credentials.expiry_date! / 1000),
      scopes: stored.scopes,
    });
    client.setCredentials(credentials);
  }

  return client;
}

Practical Applications

Automated Email Processing Pipeline

A Gmail sync job can run every 15 minutes, reading new emails for each authorized @example.com user and indexing them into a local search engine. The refresh tokens in the vault allow this to happen without any user interaction after the initial OAuth consent. If a token is revoked or expires, the job logs a warning and the user is prompted to re-authorize on their next interactive login.

Calendar Synchronization

Team availability and meeting schedules are synchronized from Google Calendar into a shared internal view. The same token vault provides Calendar API access, and the sync job handles incremental sync using Google’s syncToken mechanism to only fetch changed events since the last sync.

Drive Document Indexing

Shared Drive documents are indexed for internal search. The Drive API read-only scope allows the indexer to traverse folder structures, read document metadata, and extract text content for full-text search indexing—all running as background jobs authenticated through vault tokens.

Common Pitfalls

Further Reading