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.
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.
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.
Google APIs are gated by OAuth scopes. Each scope grants access to a specific API surface:
https://www.googleapis.com/auth/gmail.readonly — Read email messages and labelshttps://www.googleapis.com/auth/calendar.events — Read/write calendar eventshttps://www.googleapis.com/auth/drive.readonly — Read Drive files and metadataopenid email profile — Basic identity (always included)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.
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.
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.
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.
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.
hd=example.com, requested scopes, access_type=offline, and prompt=consent@example.com accountssignIn callback verifies the email ends with @example.comjwt callback captures tokens and stores the refresh token in the vault// 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;
},
},
});
// 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";
}
}
// 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,
});
}
}
// 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;
}
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.
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.
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.
prompt=consent is set. If you use prompt=select_account or omit the prompt parameter, returning users get only an access token. Always force consent for applications that need offline access.hd parameter restricts the Google account chooser UI but doesn’t prevent a determined attacker from crafting direct OAuth requests. Always validate the email domain server-side in the signIn callback.