1---
2title: Google
3description: Add Google sign-in to a D1-backed Alchemy Worker, one step at a time.
4---
5
6Start in an existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). This page supplies every application file and uses Google's server-side authorization-code flow, not the Google browser SDK.
7
8## Add the auth dependencies
9
10```sh
11bun add @alchemy.run/better-auth better-auth@1.7.5 kysely
12```
13
14Use an integration release matching your installed Alchemy version. All file paths below are relative to your project root.
15
16## Read the Google credentials
17
18Create `src/credentials.ts`:
19
20```typescript
21import * as Config from "effect/Config";
22import * as Effect from "effect/Effect";
23import * as Redacted from "effect/Redacted";
24
25export const googleCredentials = Effect.gen(function* () {
26 const enabled = yield* Config.Boolean("GOOGLE_ENABLED").pipe(
27 Config.withDefault(false),
28 );
29 if (!enabled) return undefined;
30
31 return {
32 clientId: yield* Config.String("GOOGLE_CLIENT_ID"),
33 clientSecret: Redacted.value(
34 yield* Config.Redacted("GOOGLE_CLIENT_SECRET"),
35 ),
36 };
37});
38```
39
40Google starts disabled so the first run can discover the local URL without credentials. When enabled, this server-only Effect reads both credentials and unwraps the redacted secret for Better Auth.
41
42## Construct Better Auth
43
44Create `src/auth.ts`:
45
46```typescript
47import { BetterAuth } from "@alchemy.run/better-auth";
48import * as Config from "effect/Config";
49import * as Context from "effect/Context";
50import * as Effect from "effect/Effect";
51import * as Layer from "effect/Layer";
52import * as Option from "effect/Option";
53import { googleCredentials } from "./credentials.ts";
54
55const makeAuth = Effect.gen(function* () {
56 const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option);
57 const google = yield* googleCredentials;
58 return yield* BetterAuth({
59 basePath: "/api/auth",
60 baseURL: Option.getOrUndefined(baseURL),
61 socialProviders: google ? { google } : {},
62 account: { accountLinking: { disableImplicitLinking: true } },
63 });
64});
65```
66
67Options stay directly in `BetterAuth`; without `AUTH_BASE_URL`, it derives the origin from the request. The `Context` and `Layer` imports are used by the next addition to this same file.
68
69## Expose the Auth service
70
71Append to `src/auth.ts`:
72
73```typescript
74export class Auth extends Context.Service<
75 Auth,
76 Effect.Success<typeof makeAuth>
77>()("app/Auth") {
78 static readonly layer = Layer.effect(Auth, makeAuth);
79}
80```
81
82The service exposes the inferred Better Auth instance to the Worker. Its layer constructs the instance and reads Config during initialization, not in browser code.
83
84## Declare the database
85
86Create `src/database.ts`:
87
88```typescript
89import * as Cloudflare from "alchemy/Cloudflare";
90
91export const AuthDb = Cloudflare.D1.Database("AuthDb");
92```
93
94This declares the D1 resource used for users, accounts, and sessions. Alchemy supplies its local implementation during `alchemy dev`.
95
96## Supply the database layer
97
98Create `src/auth-live.ts`:
99
100```typescript
101import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
102import * as Layer from "effect/Layer";
103import { Auth } from "./auth.ts";
104import { AuthDb } from "./database.ts";
105
106export const AuthLive = Auth.layer.pipe(
107 Layer.provide(CloudflareD1(AuthDb)),
108);
109```
110
111`CloudflareD1` supplies the native Worker binding and supported automatic schema migrations. Alchemy also provisions Better Auth's stable signing secret automatically; do not create a manual secret or migration script.
112
113## Host the auth endpoints
114
115Create `src/worker.ts`:
116
117```typescript
118import * as Cloudflare from "alchemy/Cloudflare";
119import * as Effect from "effect/Effect";
120import { Auth } from "./auth.ts";
121import { AuthLive } from "./auth-live.ts";
122
123export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()(
124 "AuthWorker",
125 {
126 main: import.meta.url,
127 compatibility: { flags: ["nodejs_compat"] },
128 assets: { directory: "./public", runWorkerFirst: ["/api/*"] },
129 },
130 Effect.gen(function* () {
131 const auth = yield* Auth;
132 return { fetch: auth.fetch };
133 }).pipe(Effect.provide(AuthLive)),
134) {}
135```
136
137Requests under `/api/*` reach Better Auth, including its callback and built-in error page. The static asset handler serves the HTML and browser bundle from `public`.
138
139## Define the stack
140
141Use this as `alchemy.run.ts`:
142
143```typescript
144import * as Alchemy from "alchemy";
145import * as Cloudflare from "alchemy/Cloudflare";
146import * as Effect from "effect/Effect";
147import AuthWorker from "./src/worker.ts";
148
149export default Alchemy.Stack(
150 "GoogleAuth",
151 { providers: Cloudflare.providers(), state: Alchemy.localState() },
152 Effect.gen(function* () {
153 const worker = yield* AuthWorker;
154 return { url: worker.url };
155 }),
156);
157```
158
159This standalone stack exports the Worker's URL. Preserve the local state directory and use the same stage across restarts so resource identities and the signing secret remain stable.
160
161## Add the HTML controls
162
163Create `public/index.html`:
164
165```html
166<!doctype html>
167<html lang="en">
168 <head>
169 <meta charset="utf-8">
170 <meta name="viewport" content="width=device-width, initial-scale=1">
171 <title>Google sign-in</title>
172 </head>
173 <body>
174 <h1>Google sign-in</h1>
175 <button id="sign-in" type="button">Sign in with Google</button>
176 <button id="session" type="button">Read session</button>
177 <button id="sign-out" type="button">Sign out</button>
178 <p id="status" role="status"></p>
179 <script type="module" src="/client.js"></script>
180 </body>
181</html>
182```
183
184These three controls will sign in, read the current session, and sign out. The module script runs after the document is parsed, so the client can find the controls immediately.
185
186## Initialize the browser client
187
188Create `src/client.ts`:
189
190```typescript
191import { createAuthClient } from "better-auth/client";
192
193const authClient = createAuthClient();
194const status = document.querySelector<HTMLParagraphElement>("#status")!;
195
196async function run(action: () => Promise<string>) {
197 try {
198 status.textContent = await action();
199 } catch {
200 status.textContent = "Could not reach the server. Try again.";
201 }
202}
203```
204
205The client uses the current origin and `/api/auth`, with no server imports or credentials. `run` displays results as text and handles network failures for each action below.
206
207## Start Google sign-in
208
209Append to `src/client.ts`:
210
211```typescript
212async function signIn(): Promise<string> {
213 const { data, error } = await authClient.signIn.social({
214 provider: "google",
215 callbackURL: "/",
216 disableRedirect: true,
217 });
218 if (error) return "Could not start Google sign-in. Check your settings.";
219 if (!data?.url) return "No authorization URL was returned.";
220 window.location.assign(data.url);
221 return "Redirecting to Google…";
222}
223
224document.querySelector<HTMLButtonElement>("#sign-in")!
225 .addEventListener("click", () => void run(signIn));
226```
227
228The button requests an authorization URL and navigates to Google only if initiation succeeds. `callbackURL: "/"` is the successful return page, not the provider's registered callback endpoint.
229
230## Read the session
231
232Append to `src/client.ts`:
233
234```typescript
235async function readSession(): Promise<string> {
236 const { data, error } = await authClient.getSession();
237 if (error) return "Could not read your session. Try again.";
238 return data ? `Signed in as ${data.user.name}` : "Not signed in.";
239}
240
241document.querySelector<HTMLButtonElement>("#session")!
242 .addEventListener("click", () => void run(readSession));
243void run(readSession);
244```
245
246The page checks the actual session on load, including after the OAuth redirect. The Read session button repeats that check; a successful redirect alone is not proof of authentication.
247
248## Sign out
249
250Append to `src/client.ts`:
251
252```typescript
253async function signOut(): Promise<string> {
254 const { error } = await authClient.signOut();
255 return error ? "Could not sign out. Try again." : "Signed out.";
256}
257
258document.querySelector<HTMLButtonElement>("#sign-out")!
259 .addEventListener("click", () => void run(signOut));
260```
261
262This asks Better Auth to end the session and reports the result. For account linking beyond this demo, use the upstream [account linking guide](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than bypassing verified-email checks or merging rows.
263
264## Bundle the browser module
265
266```sh
267bun build ./src/client.ts --target browser --outdir ./public
268```
269
270This creates `public/client.js` for the HTML script tag. Rebuild it after editing the client.
271
272## Discover the local origin
273
274```sh
275bunx alchemy dev --stage google-guide
276```
277
278Alchemy allocates an available port and prints the URL; open that exact URL, but leave sign-in alone until Google is configured. Keep `GOOGLE_ENABLED` and `AUTH_BASE_URL` unset on this first run so OAuth remains disabled and no origin is guessed.
279
280## Name the app on the consent screen
281
282In the [Google Cloud console](https://console.cloud.google.com/), select your project and open **Google Auth platform → Branding → Get started**. Enter the app name, support email, and developer contact information; supply the production homepage, privacy policy, and authorized domain before publishing.
283
284## Choose the audience
285
286In **Audience**, use **Internal** only for an eligible Workspace organization whose users should have access; otherwise choose **External**. For an External app in testing, add the accounts that will exercise the integration under **Audience → Test users**.
287
288## Limit the consent scopes
289
290In **Data Access**, configure only `openid`, `email`, and `profile` for ordinary sign-in; Better Auth requests these scopes by default. Google's [OAuth consent guide](https://developers.google.com/workspace/guides/configure-oauth-consent) covers the settings; do not add API scopes or force consent merely to create a session.
291
292## Register a web client
293
294```text
295/api/auth/callback/google
296```
297
298Under **Google Auth platform → Clients → Create client**, choose **Web application** and append the path above to the printed local origin for the **Authorized redirect URI**. Authorized JavaScript origins are not a substitute for redirect URIs, and this flow does not require the Google browser SDK; see Google's [web-server OAuth guide](https://developers.google.com/identity/protocols/oauth2/web-server).
299
300## Save the server credentials
301
302Create `.env.local`:
303
304```dotenv
305GOOGLE_ENABLED=true
306GOOGLE_CLIENT_ID=replace-with-your-client-id
307GOOGLE_CLIENT_SECRET=replace-with-your-client-secret
308```
309
310Copy the web client's ID and secret into these template values. Keep this file gitignored; never put the secret in `VITE_*`, browser code, source control, or logs.
311
312## Enable Google locally
313
314Stop the first dev process, then run:
315
316```sh
317bunx alchemy dev --stage google-guide --env-file .env.local
318```
319
320If the printed origin changed, update Google's exact redirect registration before signing in. An optional `AUTH_BASE_URL` override must equal that origin without a trailing slash or `/api/auth`; leaving it unset avoids pinning an old dev port.
321
322## Try the sign-in flow
323
324Open the current Worker URL, select **Sign in with Google**, and choose an allowed account. Back on `/`, check the displayed session, then select **Sign out** and **Read session** to confirm it is gone.
325
326## Set a production origin
327
328Use this separate `.env.production` template:
329
330```dotenv
331AUTH_BASE_URL=https://auth.example.com
332GOOGLE_ENABLED=true
333GOOGLE_CLIENT_ID=replace-with-production-client-id
334GOOGLE_CLIENT_SECRET=replace-with-production-client-secret
335```
336
337Replace the origin with the actual HTTPS Worker origin and use separate credentials with redirect URI `https://auth.example.com/api/auth/callback/google`, adjusted to match. Keep the file gitignored and load these values for production; `AUTH_BASE_URL` does not attach a custom domain to the Worker.
338
339## Review access before publishing
340
341Before public launch, review publishing status and any required brand or scope verification; testing-mode access is not a production rollout, and Workspace administrators may impose additional restrictions. Google's stable subject identifies the provider account—an email suffix or hosted-domain login hint alone is not an application authorization policy.
342
343## Diagnose callback failures
344
345Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom error query flag is needed. Keep state, PKCE, origin, and CSRF checks enabled.
346
347## Correct a redirect mismatch
348
349For `redirect_uri_mismatch`, compare the registered URI with the exact current scheme, hostname, port, and `/api/auth/callback/google` path, including any `AUTH_BASE_URL` override. Restart sign-in after correcting settings instead of replaying an authorization code.
350
351## Check access restrictions
352
353For `access_denied` or `org_internal`, check audience, test users, publishing status, and Workspace policy. Retry with an allowed account after correcting the configuration.
354
355## Check rejected credentials
356
357For `invalid_client`, verify that the credential pair belongs to the same web client and rotate revoked or expired secrets. Never log credentials, tokens, or complete callback URLs.