1---
2title: GitHub
3description: Add GitHub 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; paths are relative to your project root.
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.
15
16## Read the GitHub 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 githubCredentials = Effect.gen(function* () {
26 const enabled = yield* Config.Boolean("GITHUB_ENABLED").pipe(
27 Config.withDefault(false),
28 );
29 if (!enabled) return undefined;
30
31 return {
32 clientId: yield* Config.String("GITHUB_CLIENT_ID"),
33 clientSecret: Redacted.value(
34 yield* Config.Redacted("GITHUB_CLIENT_SECRET"),
35 ),
36 };
37});
38```
39
40GitHub 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 { githubCredentials } from "./credentials.ts";
54
55const makeAuth = Effect.gen(function* () {
56 const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option);
57 const github = yield* githubCredentials;
58 return yield* BetterAuth({
59 basePath: "/api/auth",
60 baseURL: Option.getOrUndefined(baseURL),
61 socialProviders: github ? { github } : {},
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 "GitHubAuth",
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>GitHub sign-in</title>
172 </head>
173 <body>
174 <h1>GitHub sign-in</h1>
175 <button id="sign-in" type="button">Sign in with GitHub</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 GitHub 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: "github",
215 callbackURL: "/",
216 disableRedirect: true,
217 });
218 if (error) return "Could not start GitHub 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 GitHub…";
222}
223
224document.querySelector<HTMLButtonElement>("#sign-in")!
225 .addEventListener("click", () => void run(signIn));
226```
227
228The button requests an authorization URL and navigates to GitHub 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 github-guide
276```
277
278Alchemy allocates an available port and prints the URL; open that exact URL, but leave sign-in alone until GitHub is configured. Keep `GITHUB_ENABLED` and `AUTH_BASE_URL` unset on this first run so OAuth remains disabled and no origin is guessed.
279
280## Register the OAuth App
281
282```text
283/api/auth/callback/github
284```
285
286In GitHub **Settings → Developer settings → OAuth Apps → New OAuth App**, use the printed origin for **Homepage URL** and append the path above for **Authorization callback URL**. Leave device flow disabled for this browser authorization-code flow; GitHub's [registration guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) covers the form.
287
288## Save the server credentials
289
290Create `.env.local`:
291
292```dotenv
293GITHUB_ENABLED=true
294GITHUB_CLIENT_ID=replace-with-your-client-id
295GITHUB_CLIENT_SECRET=replace-with-your-client-secret
296```
297
298Copy the OAuth App's **Client ID**, generate a **Client secret**, and replace the template values. Keep this file gitignored; never put the secret in `VITE_*`, browser code, source control, or logs.
299
300## Enable GitHub locally
301
302Stop the first dev process, then run:
303
304```sh
305bunx alchemy dev --stage github-guide --env-file .env.local
306```
307
308If the printed origin changed, update the OAuth App's homepage and exact callback URL 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.
309
310## Try the sign-in flow
311
312Open the current Worker URL, select **Sign in with GitHub**, and authorize the app. Back on `/`, check the displayed session, then select **Sign out** and **Read session** to confirm it is gone.
313
314## Set a production origin
315
316Use this separate `.env.production` template:
317
318```dotenv
319AUTH_BASE_URL=https://auth.example.com
320GITHUB_ENABLED=true
321GITHUB_CLIENT_ID=replace-with-production-client-id
322GITHUB_CLIENT_SECRET=replace-with-production-client-secret
323```
324
325Replace the origin with the actual HTTPS Worker origin and use a separate OAuth App with callback `https://auth.example.com/api/auth/callback/github`, 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.
326
327## Keep GitHub permissions minimal
328
329Better Auth requests `read:user` and `user:email` by default, including private email verification status; ordinary sign-in needs neither repository scopes nor a personal access token. Resolve missing or unverified email on GitHub instead of weakening verification.
330
331## Diagnose callback failures
332
333Better 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.
334
335## Correct a callback mismatch
336
337Compare the exact scheme, hostname, port, and callback path against the OAuth App and any `AUTH_BASE_URL` override. Restart sign-in after correcting settings instead of replaying an authorization code.
338
339## Check rejected credentials
340
341Check that the client ID and secret belong to the same OAuth App and whether organization OAuth restrictions require approval. Never log full callback URLs, provider tokens, or client secrets.