diff --git a/README.md b/README.md
index bdab4c0..d7d4ec3 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,43 @@ In the [WorkOS dashboard](https://dashboard.workos.com), go to **Redirects** and
> [!IMPORTANT]
> The Sign-in URL is required for features like [impersonation](https://workos.com/docs/user-management/impersonation) to work correctly. Without it, WorkOS-initiated flows (such as impersonating a user from the dashboard) will fail because they cannot complete the PKCE/CSRF verification that this library enforces on every callback.
+### Google One Tap
+
+Google One Tap posts its signed ID token to your application so the token and the resulting WorkOS session stay server-side.
+
+Configure Google OAuth with your own credentials in the WorkOS Dashboard. In Google Cloud, add your application origin to **Authorized JavaScript origins** and the handler URL below to **Authorized redirect URIs**. WorkOS sandbox demo credentials cannot be used for One Tap. This flow requires `@workos-inc/node` 10.12 or newer.
+
+```tsx
+// app/page.tsx
+import { GoogleOneTap } from '@workos-inc/authkit-nextjs/components';
+
+export default function Page() {
+ return (
+
+ );
+}
+```
+
+```ts
+// app/auth/google-one-tap/route.ts
+import { handleGoogleOneTap } from '@workos-inc/authkit-nextjs';
+
+export const POST = handleGoogleOneTap({
+ returnPathname: '/dashboard',
+ // Set this when request.url uses an internal proxy or container hostname.
+ baseURL: 'https://example.com',
+});
+```
+
+`handleGoogleOneTap` verifies Google's double-submit CSRF token, exchanges the ID token through WorkOS, and stores the normal encrypted AuthKit session. The token never enters a URL. If One Tap cannot complete—for example, because another authentication step is required—the handler sets the PKCE cookie and falls back to Hosted AuthKit. Authentication errors are logged with the `[AuthKit Google One Tap error]` prefix and can be handled with the `onError` option.
+
+Google's HTML API scans the fixed `#g_id_onload` element when its script executes. Render one `GoogleOneTap` instance in an initially loaded page or layout; mounting it only after a client-side navigation may not show the prompt. The optional `nonce` prop is passed to the Google script for strict CSP deployments.
+
+Keep the standard sign-in button visible because browsers can suppress the prompt, and this identity-only flow does not return Google access or refresh tokens for additional scopes.
+
### Proxy / Middleware
This library relies on Next.js proxy (called "middleware" in Next.js ≤15) to provide session management for routes.
diff --git a/examples/next/.env.local.example b/examples/next/.env.local.example
index d742eb2..2c1d901 100644
--- a/examples/next/.env.local.example
+++ b/examples/next/.env.local.example
@@ -1,4 +1,6 @@
WORKOS_CLIENT_ID=
WORKOS_API_KEY=
-NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
WORKOS_COOKIE_PASSWORD=
+NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
+NEXT_PUBLIC_APP_URL=https://your-app.example
+NEXT_PUBLIC_GOOGLE_CLIENT_ID=
diff --git a/examples/next/README.md b/examples/next/README.md
index 8d0b4a1..f1d3f98 100644
--- a/examples/next/README.md
+++ b/examples/next/README.md
@@ -26,9 +26,13 @@ You will need a [WorkOS account](https://dashboard.workos.com/signup).
WORKOS_COOKIE_PASSWORD=
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
+ NEXT_PUBLIC_APP_URL=https://your-app.example
+ NEXT_PUBLIC_GOOGLE_CLIENT_ID=
```
-5. Run the following command and navigate to [http://localhost:3000](http://localhost:3000).
+5. To enable Google One Tap, configure Google OAuth with your own credentials in the WorkOS Dashboard. In Google Cloud, add your HTTPS application origin as an **Authorized JavaScript origin** and `/auth/google-one-tap` as an **Authorized redirect URI**. Copy the same Google client ID into `NEXT_PUBLIC_GOOGLE_CLIENT_ID`. For local testing, expose the application through an HTTPS development tunnel and use that origin for `NEXT_PUBLIC_APP_URL`.
+
+6. Run the following command and navigate to [http://localhost:3000](http://localhost:3000).
```bash
pnpm dev
diff --git a/examples/next/src/app/auth/google-one-tap/route.ts b/examples/next/src/app/auth/google-one-tap/route.ts
new file mode 100644
index 0000000..11c3afa
--- /dev/null
+++ b/examples/next/src/app/auth/google-one-tap/route.ts
@@ -0,0 +1,3 @@
+import { handleGoogleOneTap } from '@workos-inc/authkit-nextjs';
+
+export const POST = handleGoogleOneTap({ returnPathname: '/account' });
diff --git a/examples/next/src/app/page.tsx b/examples/next/src/app/page.tsx
index 276568d..b69bdd6 100644
--- a/examples/next/src/app/page.tsx
+++ b/examples/next/src/app/page.tsx
@@ -1,10 +1,13 @@
import NextLink from 'next/link';
import { withAuth } from '@workos-inc/authkit-nextjs';
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
+import { GoogleOneTap } from '@workos-inc/authkit-nextjs/components';
import { SignInButton } from './components/sign-in-button';
export default async function HomePage() {
const { user } = await withAuth();
+ const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
return (
{user ? (
@@ -27,6 +30,7 @@ export default async function HomePage() {
Sign in to view your account details
+ {googleClientId && }
>
)}
diff --git a/src/components/google-one-tap.spec.tsx b/src/components/google-one-tap.spec.tsx
new file mode 100644
index 0000000..c2e66c9
--- /dev/null
+++ b/src/components/google-one-tap.spec.tsx
@@ -0,0 +1,28 @@
+import '@testing-library/jest-dom';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { GoogleOneTap } from './google-one-tap.js';
+
+describe('GoogleOneTap', () => {
+ it('renders the Google Identity Services configuration', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(document.querySelector('script')).toHaveAttribute('src', 'https://accounts.google.com/gsi/client');
+ expect(document.querySelector('script')).toHaveAttribute('nonce', 'csp-nonce');
+ expect(container.querySelector('#g_id_onload')).toHaveAttribute('data-client_id', 'google-client-id');
+ expect(container.querySelector('#g_id_onload')).toHaveAttribute(
+ 'data-login_uri',
+ 'https://example.com/auth/google-one-tap',
+ );
+ expect(container.querySelector('#g_id_onload')).toHaveAttribute('data-context', 'signup');
+ expect(container.querySelector('#g_id_onload')).toHaveAttribute('data-cancel_on_tap_outside', 'false');
+ });
+});
diff --git a/src/components/google-one-tap.tsx b/src/components/google-one-tap.tsx
new file mode 100644
index 0000000..2dcad41
--- /dev/null
+++ b/src/components/google-one-tap.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import React from 'react';
+
+export interface GoogleOneTapProps {
+ clientId: string;
+ loginUri: string;
+ context?: 'signin' | 'signup' | 'use';
+ cancelOnTapOutside?: boolean;
+ nonce?: string;
+}
+
+export function GoogleOneTap({
+ clientId,
+ loginUri,
+ context = 'signin',
+ cancelOnTapOutside = true,
+ nonce,
+}: GoogleOneTapProps) {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index f05b105..e7f8287 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,7 +1,9 @@
+import { GoogleOneTap } from './google-one-tap.js';
import { Impersonation } from './impersonation.js';
import { AuthKitProvider, useAuth } from './authkit-provider.js';
import { useAccessToken } from './useAccessToken.js';
import { useTokenClaims } from './useTokenClaims.js';
import { useRecentAuth } from './useRecentAuth.js';
-export { Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims, useRecentAuth };
+export { GoogleOneTap, Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims, useRecentAuth };
+export type { GoogleOneTapProps } from './google-one-tap.js';
diff --git a/src/google-one-tap-route.spec.ts b/src/google-one-tap-route.spec.ts
new file mode 100644
index 0000000..3bc1d4d
--- /dev/null
+++ b/src/google-one-tap-route.spec.ts
@@ -0,0 +1,201 @@
+import { NextRequest } from 'next/server';
+import { getAuthorizationUrl } from './get-authorization-url.js';
+import { handleGoogleOneTap } from './google-one-tap-route.js';
+import { getPKCECookieNameForState } from './pkce.js';
+import { saveSession } from './session.js';
+
+const { fakeWorkosInstance } = vi.hoisted(() => ({
+ fakeWorkosInstance: {
+ userManagement: {
+ authenticateWithGoogleIdToken: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('./workos', () => ({
+ getWorkOS: vi.fn(() => fakeWorkosInstance),
+}));
+
+vi.mock('./session', () => ({
+ saveSession: vi.fn(),
+}));
+
+vi.mock('./get-authorization-url', () => ({
+ getAuthorizationUrl: vi.fn().mockResolvedValue({
+ url: 'https://auth.example.com/authorize',
+ sealedState: 'sealed-state',
+ }),
+}));
+
+const authenticationResponse = {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ user: {
+ id: 'user_123',
+ email: 'ada@example.com',
+ emailVerified: true,
+ profilePictureUrl: null,
+ name: 'Ada Lovelace',
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ object: 'user' as const,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ lastSignInAt: '2024-01-01T00:00:00Z',
+ externalId: null,
+ metadata: {},
+ locale: null,
+ },
+};
+
+type RequestOptions = {
+ csrfCookie?: string | null;
+ csrfBody?: string | null;
+ credential?: string | null;
+};
+
+const request = ({
+ csrfCookie = 'csrf-token',
+ csrfBody = 'csrf-token',
+ credential = 'google-id-token',
+}: RequestOptions = {}) => {
+ const headers: Record = {
+ accept: 'text/html',
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'user-agent': 'Mozilla/5.0',
+ 'x-forwarded-for': '203.0.113.42, 10.0.0.1',
+ };
+ if (csrfCookie !== null) headers.cookie = `g_csrf_token=${csrfCookie}`;
+
+ const body = new URLSearchParams();
+ if (credential !== null) body.set('credential', credential);
+ if (csrfBody !== null) body.set('g_csrf_token', csrfBody);
+
+ return new NextRequest('https://example.com/auth/google-one-tap', {
+ method: 'POST',
+ headers,
+ body,
+ });
+};
+
+describe('handleGoogleOneTap', () => {
+ beforeAll(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterAll(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('validates CSRF, authenticates, saves the session, and redirects', async () => {
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockResolvedValue(authenticationResponse);
+ const onSuccess = vi.fn();
+
+ const response = await handleGoogleOneTap({
+ returnPathname: '/dashboard?from=one-tap',
+ onSuccess,
+ })(request());
+
+ expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).toHaveBeenCalledWith({
+ clientId: process.env.WORKOS_CLIENT_ID,
+ token: 'google-id-token',
+ ipAddress: '203.0.113.42',
+ userAgent: 'Mozilla/5.0',
+ });
+ expect(saveSession).toHaveBeenCalledWith(authenticationResponse, expect.any(NextRequest));
+ expect(onSuccess).toHaveBeenCalledWith(authenticationResponse);
+ expect(response.status).toBe(303);
+ expect(response.headers.get('location')).toBe('https://example.com/dashboard?from=one-tap');
+ expect(response.headers.get('cache-control')).toContain('no-store');
+ });
+
+ it('uses baseURL for the success redirect', async () => {
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockResolvedValue(authenticationResponse);
+
+ const response = await handleGoogleOneTap({ baseURL: 'https://public.example.com', returnPathname: '/dashboard' })(
+ request(),
+ );
+
+ expect(response.headers.get('location')).toBe('https://public.example.com/dashboard');
+ });
+
+ it('rejects an invalid baseURL before handling requests', () => {
+ expect(() => handleGoogleOneTap({ baseURL: 'invalid-url' })).toThrow('Invalid baseURL: invalid-url');
+ });
+
+ it('rejects a mismatched CSRF token before authentication', async () => {
+ const response = await handleGoogleOneTap()(request({ csrfCookie: 'cookie', csrfBody: 'body' }));
+
+ expect(response.status).toBe(400);
+ expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
+ expect(saveSession).not.toHaveBeenCalled();
+ });
+
+ it('rejects a missing CSRF cookie before authentication', async () => {
+ const response = await handleGoogleOneTap()(request({ csrfCookie: null }));
+
+ expect(response.status).toBe(400);
+ expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
+ });
+
+ it('rejects a missing credential before authentication', async () => {
+ const response = await handleGoogleOneTap()(request({ credential: null }));
+
+ expect(response.status).toBe(400);
+ expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
+ });
+
+ it('sets the PKCE cookie before falling back to Hosted AuthKit', async () => {
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockRejectedValue(new Error('MFA required'));
+
+ const response = await handleGoogleOneTap({ returnPathname: '/dashboard' })(request());
+
+ expect(response.status).toBe(303);
+ expect(response.headers.get('location')).toBe('https://auth.example.com/authorize');
+ expect(response.headers.get('set-cookie')).toContain(`${getPKCECookieNameForState('sealed-state')}=sealed-state`);
+ expect(getAuthorizationUrl).toHaveBeenCalledWith({ returnPathname: '/dashboard' });
+ expect(saveSession).not.toHaveBeenCalled();
+ expect(console.error).toHaveBeenCalledWith('[AuthKit Google One Tap error]', expect.any(Error));
+ });
+
+ it('uses onError instead of the Hosted AuthKit fallback when provided', async () => {
+ const error = new Error('Authentication failed');
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockRejectedValue(error);
+ const onError = vi.fn(() => new Response('custom error', { status: 418 }));
+
+ const response = await handleGoogleOneTap({ onError })(request());
+
+ expect(response.status).toBe(418);
+ expect(onError).toHaveBeenCalledWith({ error, request: expect.any(NextRequest) });
+ expect(getAuthorizationUrl).not.toHaveBeenCalled();
+ });
+
+ it('surfaces an unsupported Node SDK instead of silently falling back', async () => {
+ const authenticateWithGoogleIdToken = fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken;
+ Object.assign(fakeWorkosInstance.userManagement, { authenticateWithGoogleIdToken: undefined });
+
+ try {
+ await expect(handleGoogleOneTap()(request())).rejects.toThrow(
+ '@workos-inc/node 10.12 or newer is required for Google One Tap.',
+ );
+ } finally {
+ Object.assign(fakeWorkosInstance.userManagement, { authenticateWithGoogleIdToken });
+ }
+
+ expect(getAuthorizationUrl).not.toHaveBeenCalled();
+ });
+
+ it('does not turn an onSuccess failure into a second authentication flow', async () => {
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockResolvedValue(authenticationResponse);
+ const error = new Error('Side effect failed');
+
+ await expect(handleGoogleOneTap({ onSuccess: () => Promise.reject(error) })(request())).rejects.toThrow(error);
+
+ expect(saveSession).toHaveBeenCalled();
+ expect(getAuthorizationUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/google-one-tap-route.ts b/src/google-one-tap-route.ts
new file mode 100644
index 0000000..dd6de6a
--- /dev/null
+++ b/src/google-one-tap-route.ts
@@ -0,0 +1,108 @@
+import type { AuthenticationResponse } from '@workos-inc/node';
+import { NextRequest } from 'next/server';
+import { WORKOS_CLIENT_ID } from './env-variables.js';
+import { getAuthorizationUrl } from './get-authorization-url.js';
+import type { HandleGoogleOneTapOptions } from './interfaces.js';
+import { appendPKCESetCookieHeader } from './pkce.js';
+import { saveSession } from './session.js';
+import { setCachePreventionHeaders } from './utils.js';
+import { getWorkOS } from './workos.js';
+
+const nodeSdkVersionError = '@workos-inc/node 10.12 or newer is required for Google One Tap.';
+
+class UnsupportedNodeSdkError extends Error {}
+
+type AuthenticateWithGoogleIdToken = (options: {
+ clientId: string;
+ token: string;
+ ipAddress?: string;
+ userAgent?: string;
+}) => Promise;
+
+const authenticate = async (request: NextRequest, token: string): Promise => {
+ const userManagement = getWorkOS().userManagement;
+ const authenticateWithGoogleIdToken = (
+ userManagement as typeof userManagement & {
+ authenticateWithGoogleIdToken?: AuthenticateWithGoogleIdToken;
+ }
+ ).authenticateWithGoogleIdToken;
+
+ if (typeof authenticateWithGoogleIdToken !== 'function') {
+ throw new UnsupportedNodeSdkError(nodeSdkVersionError);
+ }
+
+ return authenticateWithGoogleIdToken.call(userManagement, {
+ clientId: WORKOS_CLIENT_ID,
+ token,
+ ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
+ userAgent: request.headers.get('user-agent') ?? undefined,
+ });
+};
+
+export function handleGoogleOneTap(options: HandleGoogleOneTapOptions = {}) {
+ const { returnPathname = '/', baseURL, onError, onSuccess } = options;
+
+ if (baseURL) {
+ try {
+ new URL(baseURL);
+ } catch (error) {
+ throw new Error(`Invalid baseURL: ${baseURL}`, { cause: error });
+ }
+ }
+
+ return async function POST(request: NextRequest): Promise {
+ let authenticationResponse: AuthenticationResponse;
+
+ try {
+ const formData = await request.formData();
+ const token = formData.get('credential');
+ const bodyCsrfToken = formData.get('g_csrf_token');
+ const cookieCsrfToken = request.cookies.get('g_csrf_token')?.value;
+
+ if (
+ typeof token !== 'string' ||
+ typeof bodyCsrfToken !== 'string' ||
+ !cookieCsrfToken ||
+ bodyCsrfToken !== cookieCsrfToken
+ ) {
+ return noStore(new Response('Invalid Google One Tap request.', { status: 400 }));
+ }
+
+ authenticationResponse = await authenticate(request, token);
+ await saveSession(authenticationResponse, request);
+ } catch (error) {
+ console.error('[AuthKit Google One Tap error]', error);
+
+ if (onError) {
+ return noStore(await onError({ error, request }));
+ }
+
+ if (error instanceof UnsupportedNodeSdkError) {
+ throw error;
+ }
+
+ const { url, sealedState } = await getAuthorizationUrl({ returnPathname });
+ const response = noStore(redirectAfterPost(url));
+ appendPKCESetCookieHeader(request, response.headers, sealedState);
+ return response;
+ }
+
+ await onSuccess?.(authenticationResponse);
+
+ const redirectUrl = baseURL ? new URL(baseURL) : new URL(request.url);
+ const parsedReturnUrl = new URL(returnPathname, 'https://placeholder.com');
+ redirectUrl.pathname = parsedReturnUrl.pathname;
+ redirectUrl.search = parsedReturnUrl.search;
+ return noStore(redirectAfterPost(redirectUrl.toString()));
+ };
+}
+
+function redirectAfterPost(url: string): Response {
+ return new Response(null, { status: 303, headers: { Location: url } });
+}
+
+function noStore(response: Response): Response {
+ response.headers.set('Vary', 'Cookie');
+ setCachePreventionHeaders(response.headers);
+ return response;
+}
diff --git a/src/index.ts b/src/index.ts
index 7d3766b..2a74b5a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
import { handleAuth } from './authkit-callback-route.js';
+import { handleGoogleOneTap } from './google-one-tap-route.js';
import { AuthKitError, CallbackError, TokenRefreshError } from './errors.js';
import { authkit, authkitMiddleware, authkitProxy } from './middleware.js';
export {
@@ -37,6 +38,7 @@ export {
getTokenClaims,
getWorkOS,
handleAuth,
+ handleGoogleOneTap,
refreshSession,
saveSession,
signOut,
diff --git a/src/interfaces.ts b/src/interfaces.ts
index c0d05b2..abe6434 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -21,6 +21,13 @@ export interface HandleAuthOptions {
onError?: (params: { error?: unknown; request: NextRequest }) => Response | Promise;
}
+export interface HandleGoogleOneTapOptions {
+ returnPathname?: string;
+ baseURL?: string;
+ onSuccess?: (data: AuthenticationResponse) => void | Promise;
+ onError?: (params: { error?: unknown; request: NextRequest }) => Response | Promise;
+}
+
export interface HandleAuthSuccessData extends Session {
oauthTokens?: OauthTokens;
organizationId?: string;