| 1 | --- |
| 2 | title: Microsoft |
| 3 | description: Add Microsoft sign-in to an Alchemy Worker with D1, one small 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 standalone walkthrough uses Better Auth 1.7.5's built-in Microsoft Entra ID provider; all application files are introduced below. |
| 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 `@alchemy.run/better-auth` release matching your installed Alchemy release. Keep your existing `alchemy` and `effect` dependencies. |
| 15 | |
| 16 | ## Create the Auth service |
| 17 | |
| 18 | Create `src/auth.ts`: |
| 19 | |
| 20 | ```typescript |
| 21 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 22 | import * as Config from "effect/Config"; |
| 23 | import * as Context from "effect/Context"; |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Layer from "effect/Layer"; |
| 26 | import * as Option from "effect/Option"; |
| 27 | |
| 28 | const makeAuth = Effect.gen(function* () { |
| 29 | const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); |
| 30 | return yield* BetterAuth({ |
| 31 | basePath: "/api/auth", |
| 32 | baseURL: Option.getOrUndefined(baseURL), |
| 33 | account: { accountLinking: { disableImplicitLinking: true } }, |
| 34 | }); |
| 35 | }); |
| 36 | |
| 37 | export class Auth extends Context.Service< |
| 38 | Auth, |
| 39 | Effect.Success<typeof makeAuth> |
| 40 | >()("app/Auth") { |
| 41 | static readonly layer = Layer.effect(Auth, makeAuth); |
| 42 | } |
| 43 | ``` |
| 44 | |
| 45 | The service keeps its options directly inside `BetterAuth` and reads Config during construction. Leaving `AUTH_BASE_URL` unset lets Better Auth use the incoming request's origin. |
| 46 | |
| 47 | ## Start with Microsoft disabled |
| 48 | |
| 49 | Apply this addition to `src/auth.ts`: |
| 50 | |
| 51 | ```diff lang="typescript" |
| 52 | const makeAuth = Effect.gen(function* () { |
| 53 | const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); |
| 54 | + const enabled = yield* Config.Boolean("MICROSOFT_ENABLED").pipe( |
| 55 | + Config.withDefault(false), |
| 56 | + ); |
| 57 | return yield* BetterAuth({ |
| 58 | ``` |
| 59 | |
| 60 | Microsoft stays disabled until you know the local URL and register its callback. The next addition reads credentials only when this switch is enabled. |
| 61 | |
| 62 | ## Configure the Microsoft provider |
| 63 | |
| 64 | Apply both hunks to `src/auth.ts`, adding the import and the inline provider options: |
| 65 | |
| 66 | ```diff lang="typescript" |
| 67 | import * as Option from "effect/Option"; |
| 68 | +import * as Redacted from "effect/Redacted"; |
| 69 | @@ |
| 70 | baseURL: Option.getOrUndefined(baseURL), |
| 71 | + socialProviders: enabled ? { |
| 72 | + microsoft: { |
| 73 | + clientId: yield* Config.String("MICROSOFT_CLIENT_ID"), |
| 74 | + clientSecret: Redacted.value(yield* Config.Redacted("MICROSOFT_CLIENT_SECRET")), |
| 75 | + tenantId: yield* Config.String("MICROSOFT_TENANT_ID"), |
| 76 | + disableProfilePhoto: true, |
| 77 | + disableDefaultScope: true, |
| 78 | + scope: ["openid", "profile", "email"], |
| 79 | + }, |
| 80 | + } : {}, |
| 81 | account: { accountLinking: { disableImplicitLinking: true } }, |
| 82 | ``` |
| 83 | |
| 84 | This sign-in-only setup omits Graph photos and offline access. The provider defaults also request `User.Read` and `offline_access`; restore those features only with the corresponding delegated permissions and consent. |
| 85 | |
| 86 | ## Declare the database |
| 87 | |
| 88 | Create `src/database.ts`: |
| 89 | |
| 90 | ```typescript |
| 91 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 92 | |
| 93 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 94 | ``` |
| 95 | |
| 96 | This declares the D1 database that will store users, accounts, and sessions. The same resource uses local D1 during `alchemy dev` and Cloudflare D1 when deployed. |
| 97 | |
| 98 | ## Give Auth its database layer |
| 99 | |
| 100 | Create `src/auth-layer.ts`: |
| 101 | |
| 102 | ```typescript |
| 103 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 104 | import * as Layer from "effect/Layer"; |
| 105 | import { Auth } from "./auth.ts"; |
| 106 | import { AuthDb } from "./database.ts"; |
| 107 | |
| 108 | export const AuthLive = Auth.layer.pipe( |
| 109 | Layer.provide(CloudflareD1(AuthDb)), |
| 110 | ); |
| 111 | ``` |
| 112 | |
| 113 | `CloudflareD1` supplies the database binding and automatic supported schema migrations. Alchemy also provisions the stable signing secret automatically; do not invent a manual secret for this example. |
| 114 | |
| 115 | ## Host Auth in a Worker |
| 116 | |
| 117 | Create `src/worker.ts`: |
| 118 | |
| 119 | ```typescript |
| 120 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 121 | import * as Effect from "effect/Effect"; |
| 122 | import { Auth } from "./auth.ts"; |
| 123 | import { AuthLive } from "./auth-layer.ts"; |
| 124 | |
| 125 | export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()( |
| 126 | "AuthWorker", |
| 127 | { |
| 128 | main: import.meta.url, |
| 129 | compatibility: { flags: ["nodejs_compat"] }, |
| 130 | assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, |
| 131 | }, |
| 132 | Effect.gen(function* () { |
| 133 | const auth = yield* Auth; |
| 134 | return { fetch: auth.fetch }; |
| 135 | }).pipe(Effect.provide(AuthLive)), |
| 136 | ) {} |
| 137 | ``` |
| 138 | |
| 139 | The Worker forwards API requests to Better Auth, including its session and callback endpoints. Static assets serve the browser page outside `/api/*`. |
| 140 | |
| 141 | ## Define the stack |
| 142 | |
| 143 | Use this as `alchemy.run.ts` for the walkthrough: |
| 144 | |
| 145 | ```typescript |
| 146 | import * as Alchemy from "alchemy"; |
| 147 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 148 | import * as Effect from "effect/Effect"; |
| 149 | import AuthWorker from "./src/worker.ts"; |
| 150 | |
| 151 | export default Alchemy.Stack( |
| 152 | "MicrosoftAuth", |
| 153 | { providers: Cloudflare.providers(), state: Alchemy.localState() }, |
| 154 | Effect.gen(function* () { |
| 155 | const worker = yield* AuthWorker; |
| 156 | return { url: worker.url }; |
| 157 | }), |
| 158 | ); |
| 159 | ``` |
| 160 | |
| 161 | The output exposes the Worker's actual URL. Preserve the local state directory so subsequent runs retain resource identities and the signing secret. |
| 162 | |
| 163 | ## Add the browser page |
| 164 | |
| 165 | Create `public/index.html`: |
| 166 | |
| 167 | ```html |
| 168 | <!doctype html> |
| 169 | <html lang="en"> |
| 170 | <head> |
| 171 | <meta charset="utf-8"> |
| 172 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 173 | <title>Microsoft sign-in</title> |
| 174 | </head> |
| 175 | <body> |
| 176 | <h1>Microsoft sign-in</h1> |
| 177 | <button id="sign-in" type="button">Sign in with Microsoft</button> |
| 178 | <button id="read-session" type="button">Read session</button> |
| 179 | <button id="sign-out" type="button">Sign out</button> |
| 180 | <p id="status" role="status"></p> |
| 181 | <script type="module" src="/client.js"></script> |
| 182 | </body> |
| 183 | </html> |
| 184 | ``` |
| 185 | |
| 186 | The page provides three controls and a text-only status area. The browser bundle will be written to `public/client.js`. |
| 187 | |
| 188 | ## Create the browser client |
| 189 | |
| 190 | Create `src/client.ts`: |
| 191 | |
| 192 | ```typescript |
| 193 | import { createAuthClient } from "better-auth/client"; |
| 194 | |
| 195 | const authClient = createAuthClient(); |
| 196 | const status = document.getElementById("status")!; |
| 197 | |
| 198 | async function run(action: () => Promise<string>) { |
| 199 | try { |
| 200 | status.textContent = await action(); |
| 201 | } catch { |
| 202 | status.textContent = "Could not reach the server. Try again."; |
| 203 | } |
| 204 | } |
| 205 | ``` |
| 206 | |
| 207 | The client uses the page's origin and the default `/api/auth` path. It imports no server Auth module or credentials. |
| 208 | |
| 209 | ## Start Microsoft sign-in |
| 210 | |
| 211 | Append to `src/client.ts`: |
| 212 | |
| 213 | ```typescript |
| 214 | document.getElementById("sign-in")!.onclick = () => void run(async () => { |
| 215 | const { data, error } = await authClient.signIn.social({ |
| 216 | provider: "microsoft", |
| 217 | callbackURL: "/", |
| 218 | disableRedirect: true, |
| 219 | }); |
| 220 | if (error) return "Could not start Microsoft sign-in. Check the configuration."; |
| 221 | if (!data?.url) return "No authorization URL was returned."; |
| 222 | window.location.assign(data.url); |
| 223 | return "Redirecting to Microsoft…"; |
| 224 | }); |
| 225 | ``` |
| 226 | |
| 227 | Better Auth creates the authorization request, and the browser follows the returned URL. A successful callback returns to `/`. |
| 228 | |
| 229 | ## Read the current session |
| 230 | |
| 231 | Append to `src/client.ts`: |
| 232 | |
| 233 | ```typescript |
| 234 | async function readSession(): Promise<string> { |
| 235 | const { data, error } = await authClient.getSession(); |
| 236 | if (error) return "Could not check your session. Try again."; |
| 237 | return data ? `Signed in as ${data.user.name}.` : "Not signed in."; |
| 238 | } |
| 239 | |
| 240 | document.getElementById("read-session")!.onclick = () => void run(readSession); |
| 241 | void run(readSession); |
| 242 | ``` |
| 243 | |
| 244 | The page checks the session on load, including after the OAuth callback. The button repeats that lookup without confusing a failed request with an anonymous session. |
| 245 | |
| 246 | ## Sign out |
| 247 | |
| 248 | Append to `src/client.ts`: |
| 249 | |
| 250 | ```typescript |
| 251 | document.getElementById("sign-out")!.onclick = () => void run(async () => { |
| 252 | const { error } = await authClient.signOut(); |
| 253 | return error ? "Could not sign out. Try again." : "Signed out."; |
| 254 | }); |
| 255 | ``` |
| 256 | |
| 257 | This ends the application's session. It does not promise to sign the user out of every Microsoft application. |
| 258 | |
| 259 | ## Bundle the browser client |
| 260 | |
| 261 | ```sh |
| 262 | bun build ./src/client.ts --target browser --outdir ./public |
| 263 | ``` |
| 264 | |
| 265 | The final client is the initial `src/client.ts` block followed by the three append blocks, in order. The final `src/auth.ts` is its initial module with both labeled diffs applied; every other module above is complete as shown. |
| 266 | |
| 267 | ## Discover the local origin |
| 268 | |
| 269 | ```sh |
| 270 | bunx alchemy dev --stage microsoft-guide |
| 271 | ``` |
| 272 | |
| 273 | Run without provider credentials or `MICROSOFT_ENABLED=true` for this first start; Alchemy allocates a port and prints the actual origin. Keep Microsoft disabled until the registration below is ready, and rebuild the browser bundle whenever you edit `src/client.ts`. |
| 274 | |
| 275 | ## Register the Entra application |
| 276 | |
| 277 | Open the [Microsoft Entra admin center](https://entra.microsoft.com/), select the intended directory, and choose **Entra ID → App registrations → New registration**. Record the **Application (client) ID** and **Directory (tenant) ID** from **Overview** after choosing your supported account types. |
| 278 | |
| 279 | For company-only sign-in, select the single-tenant option and use the directory's tenant GUID. Microsoft's [app registration guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) covers the registration screens. |
| 280 | |
| 281 | ## Choose the tenant boundary |
| 282 | |
| 283 | Use the tenant GUID for a specific directory; for matching multitenant registrations, `organizations` admits work/school accounts, `consumers` admits personal accounts, and `common` admits both classes. This example requires an explicit tenant instead of silently defaulting to `common`. |
| 284 | |
| 285 | Better Auth 1.7.5 checks a specific tenant's issuer against `https://login.microsoftonline.com/<tenant-guid>/v2.0`; multitenant endpoints bind the issuer to the token's `tid` and enforce the selected account class. A multitenant endpoint is not an organization allowlist: authorize customer tenants and application membership on the server, never from email domains or login hints. |
| 286 | |
| 287 | ## Register the Web callback |
| 288 | |
| 289 | ```text |
| 290 | /api/auth/callback/microsoft |
| 291 | ``` |
| 292 | |
| 293 | Append this path to the printed origin and register the exact URL under **Authentication → Add a platform → Web**, not **Single-page application**. Better Auth exchanges the code server-side with a client secret, so leave implicit grants disabled and do not use wildcard callbacks. |
| 294 | |
| 295 | ## Supply server credentials |
| 296 | |
| 297 | Create `.env.local.example`, then copy it to a gitignored `.env.local` and replace the placeholder values: |
| 298 | |
| 299 | ```dotenv |
| 300 | MICROSOFT_ENABLED=true |
| 301 | MICROSOFT_CLIENT_ID=replace-with-application-client-id |
| 302 | MICROSOFT_CLIENT_SECRET=replace-with-secret-value |
| 303 | MICROSOFT_TENANT_ID=replace-with-directory-tenant-guid |
| 304 | # Leave AUTH_BASE_URL unset for local request-origin discovery. |
| 305 | ``` |
| 306 | |
| 307 | Under **Certificates & secrets → Client secrets → New client secret**, copy the secret **Value**, not its **Secret ID**, and arrange rotation before expiry. These server-side Config values must stay out of `VITE_*`, browser bundles, source control, and logs. |
| 308 | |
| 309 | ## Enable Microsoft locally |
| 310 | |
| 311 | ```sh |
| 312 | bunx alchemy dev --stage microsoft-guide --env-file .env.local |
| 313 | ``` |
| 314 | |
| 315 | Stop the first dev process before running this command, and update the registered callback if the newly printed origin changes. Open that origin, select **Sign in with Microsoft**, then use **Read session** and **Sign out** to exercise the roundtrip. |
| 316 | |
| 317 | ## Set the production origin |
| 318 | |
| 319 | Create `.env.production.example` as a template for your private production configuration: |
| 320 | |
| 321 | ```dotenv |
| 322 | AUTH_BASE_URL=https://auth.example.com |
| 323 | MICROSOFT_ENABLED=true |
| 324 | MICROSOFT_CLIENT_ID=replace-with-production-application-client-id |
| 325 | MICROSOFT_CLIENT_SECRET=replace-with-production-secret-value |
| 326 | MICROSOFT_TENANT_ID=replace-with-production-directory-tenant-guid |
| 327 | ``` |
| 328 | |
| 329 | Replace the example origin with the actual HTTPS Worker origin, without a trailing slash or `/api/auth`; `baseURL` does not attach a domain to the Worker. Use a separate production registration and secret, register that origin plus `/api/auth/callback/microsoft`, and supply these values through your deployment's server configuration. |
| 330 | |
| 331 | ## Preserve provider identity |
| 332 | |
| 333 | The built-in provider derives the account subject from Microsoft's immutable `oid`, not email or `preferred_username`; do not assume one person has the same identity across tenants. Microsoft may omit email or a trustworthy verification signal, so never fabricate an address or force `emailVerified: true` to bypass a failure. |
| 334 | |
| 335 | Keep implicit linking disabled and retain provider-email and same-email checks if you later add account linking. Follow Better Auth's [account-linking guidance](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than trusting Microsoft solely to merge accounts by email. |
| 336 | |
| 337 | ## Diagnose callback failures |
| 338 | |
| 339 | Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom application error query is needed. Keep state, PKCE, origin, and CSRF protections enabled. |
| 340 | |
| 341 | For `AADSTS50011`, compare the exact Web callback with the actual origin and `/api/auth/callback/microsoft`; for client failures, check the client ID, secret value, and expiry. For tenant, consent, or missing-email failures, review supported account types, user assignment, administrator consent, and claim configuration without logging tokens or full callback URLs. |