| 1 | --- |
| 2 | title: GitHub |
| 3 | description: Add GitHub sign-in to a D1-backed Alchemy Worker, one step at a time. |
| 4 | --- |
| 5 | |
| 6 | Start 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 |
| 11 | bun add @alchemy.run/better-auth better-auth@1.7.5 kysely |
| 12 | ``` |
| 13 | |
| 14 | Use an integration release matching your installed Alchemy version. |
| 15 | |
| 16 | ## Read the GitHub credentials |
| 17 | |
| 18 | Create `src/credentials.ts`: |
| 19 | |
| 20 | ```typescript |
| 21 | import * as Config from "effect/Config"; |
| 22 | import * as Effect from "effect/Effect"; |
| 23 | import * as Redacted from "effect/Redacted"; |
| 24 | |
| 25 | export 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 | |
| 40 | GitHub 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 | |
| 44 | Create `src/auth.ts`: |
| 45 | |
| 46 | ```typescript |
| 47 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 48 | import * as Config from "effect/Config"; |
| 49 | import * as Context from "effect/Context"; |
| 50 | import * as Effect from "effect/Effect"; |
| 51 | import * as Layer from "effect/Layer"; |
| 52 | import * as Option from "effect/Option"; |
| 53 | import { githubCredentials } from "./credentials.ts"; |
| 54 | |
| 55 | const 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 | |
| 67 | Options 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 | |
| 71 | Append to `src/auth.ts`: |
| 72 | |
| 73 | ```typescript |
| 74 | export 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 | |
| 82 | The 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 | |
| 86 | Create `src/database.ts`: |
| 87 | |
| 88 | ```typescript |
| 89 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 90 | |
| 91 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 92 | ``` |
| 93 | |
| 94 | This 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 | |
| 98 | Create `src/auth-live.ts`: |
| 99 | |
| 100 | ```typescript |
| 101 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 102 | import * as Layer from "effect/Layer"; |
| 103 | import { Auth } from "./auth.ts"; |
| 104 | import { AuthDb } from "./database.ts"; |
| 105 | |
| 106 | export 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 | |
| 115 | Create `src/worker.ts`: |
| 116 | |
| 117 | ```typescript |
| 118 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 119 | import * as Effect from "effect/Effect"; |
| 120 | import { Auth } from "./auth.ts"; |
| 121 | import { AuthLive } from "./auth-live.ts"; |
| 122 | |
| 123 | export 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 | |
| 137 | Requests 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 | |
| 141 | Use this as `alchemy.run.ts`: |
| 142 | |
| 143 | ```typescript |
| 144 | import * as Alchemy from "alchemy"; |
| 145 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 146 | import * as Effect from "effect/Effect"; |
| 147 | import AuthWorker from "./src/worker.ts"; |
| 148 | |
| 149 | export 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 | |
| 159 | This 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 | |
| 163 | Create `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 | |
| 184 | These 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 | |
| 188 | Create `src/client.ts`: |
| 189 | |
| 190 | ```typescript |
| 191 | import { createAuthClient } from "better-auth/client"; |
| 192 | |
| 193 | const authClient = createAuthClient(); |
| 194 | const status = document.querySelector<HTMLParagraphElement>("#status")!; |
| 195 | |
| 196 | async 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 | |
| 205 | The 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 | |
| 209 | Append to `src/client.ts`: |
| 210 | |
| 211 | ```typescript |
| 212 | async 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 | |
| 224 | document.querySelector<HTMLButtonElement>("#sign-in")! |
| 225 | .addEventListener("click", () => void run(signIn)); |
| 226 | ``` |
| 227 | |
| 228 | The 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 | |
| 232 | Append to `src/client.ts`: |
| 233 | |
| 234 | ```typescript |
| 235 | async 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 | |
| 241 | document.querySelector<HTMLButtonElement>("#session")! |
| 242 | .addEventListener("click", () => void run(readSession)); |
| 243 | void run(readSession); |
| 244 | ``` |
| 245 | |
| 246 | The 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 | |
| 250 | Append to `src/client.ts`: |
| 251 | |
| 252 | ```typescript |
| 253 | async function signOut(): Promise<string> { |
| 254 | const { error } = await authClient.signOut(); |
| 255 | return error ? "Could not sign out. Try again." : "Signed out."; |
| 256 | } |
| 257 | |
| 258 | document.querySelector<HTMLButtonElement>("#sign-out")! |
| 259 | .addEventListener("click", () => void run(signOut)); |
| 260 | ``` |
| 261 | |
| 262 | This 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 |
| 267 | bun build ./src/client.ts --target browser --outdir ./public |
| 268 | ``` |
| 269 | |
| 270 | This creates `public/client.js` for the HTML script tag. Rebuild it after editing the client. |
| 271 | |
| 272 | ## Discover the local origin |
| 273 | |
| 274 | ```sh |
| 275 | bunx alchemy dev --stage github-guide |
| 276 | ``` |
| 277 | |
| 278 | Alchemy 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 | |
| 286 | In 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 | |
| 290 | Create `.env.local`: |
| 291 | |
| 292 | ```dotenv |
| 293 | GITHUB_ENABLED=true |
| 294 | GITHUB_CLIENT_ID=replace-with-your-client-id |
| 295 | GITHUB_CLIENT_SECRET=replace-with-your-client-secret |
| 296 | ``` |
| 297 | |
| 298 | Copy 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 | |
| 302 | Stop the first dev process, then run: |
| 303 | |
| 304 | ```sh |
| 305 | bunx alchemy dev --stage github-guide --env-file .env.local |
| 306 | ``` |
| 307 | |
| 308 | If 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 | |
| 312 | Open 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 | |
| 316 | Use this separate `.env.production` template: |
| 317 | |
| 318 | ```dotenv |
| 319 | AUTH_BASE_URL=https://auth.example.com |
| 320 | GITHUB_ENABLED=true |
| 321 | GITHUB_CLIENT_ID=replace-with-production-client-id |
| 322 | GITHUB_CLIENT_SECRET=replace-with-production-client-secret |
| 323 | ``` |
| 324 | |
| 325 | Replace 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 | |
| 329 | Better 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 | |
| 333 | Better 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 | |
| 337 | Compare 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 | |
| 341 | Check 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. |