1---
2title: Microsoft
3description: Add Microsoft sign-in to an Alchemy Worker with D1, one small 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 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
11bun add @alchemy.run/better-auth better-auth@1.7.5 kysely
12```
13
14Use 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
18Create `src/auth.ts`:
19
20```typescript
21import { BetterAuth } from "@alchemy.run/better-auth";
22import * as Config from "effect/Config";
23import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27
28const 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
37export 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
45The 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
49Apply this addition to `src/auth.ts`:
50
51```diff lang="typescript"
52const 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
60Microsoft 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
64Apply both hunks to `src/auth.ts`, adding the import and the inline provider options:
65
66```diff lang="typescript"
67import * 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
84This 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
88Create `src/database.ts`:
89
90```typescript
91import * as Cloudflare from "alchemy/Cloudflare";
92
93export const AuthDb = Cloudflare.D1.Database("AuthDb");
94```
95
96This 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
100Create `src/auth-layer.ts`:
101
102```typescript
103import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
104import * as Layer from "effect/Layer";
105import { Auth } from "./auth.ts";
106import { AuthDb } from "./database.ts";
107
108export 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
117Create `src/worker.ts`:
118
119```typescript
120import * as Cloudflare from "alchemy/Cloudflare";
121import * as Effect from "effect/Effect";
122import { Auth } from "./auth.ts";
123import { AuthLive } from "./auth-layer.ts";
124
125export 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
139The 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
143Use this as `alchemy.run.ts` for the walkthrough:
144
145```typescript
146import * as Alchemy from "alchemy";
147import * as Cloudflare from "alchemy/Cloudflare";
148import * as Effect from "effect/Effect";
149import AuthWorker from "./src/worker.ts";
150
151export 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
161The 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
165Create `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
186The 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
190Create `src/client.ts`:
191
192```typescript
193import { createAuthClient } from "better-auth/client";
194
195const authClient = createAuthClient();
196const status = document.getElementById("status")!;
197
198async 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
207The 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
211Append to `src/client.ts`:
212
213```typescript
214document.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
227Better Auth creates the authorization request, and the browser follows the returned URL. A successful callback returns to `/`.
228
229## Read the current session
230
231Append to `src/client.ts`:
232
233```typescript
234async 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
240document.getElementById("read-session")!.onclick = () => void run(readSession);
241void run(readSession);
242```
243
244The 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
248Append to `src/client.ts`:
249
250```typescript
251document.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
257This 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
262bun build ./src/client.ts --target browser --outdir ./public
263```
264
265The 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
270bunx alchemy dev --stage microsoft-guide
271```
272
273Run 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
277Open 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
279For 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
283Use 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
285Better 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
293Append 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
297Create `.env.local.example`, then copy it to a gitignored `.env.local` and replace the placeholder values:
298
299```dotenv
300MICROSOFT_ENABLED=true
301MICROSOFT_CLIENT_ID=replace-with-application-client-id
302MICROSOFT_CLIENT_SECRET=replace-with-secret-value
303MICROSOFT_TENANT_ID=replace-with-directory-tenant-guid
304# Leave AUTH_BASE_URL unset for local request-origin discovery.
305```
306
307Under **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
312bunx alchemy dev --stage microsoft-guide --env-file .env.local
313```
314
315Stop 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
319Create `.env.production.example` as a template for your private production configuration:
320
321```dotenv
322AUTH_BASE_URL=https://auth.example.com
323MICROSOFT_ENABLED=true
324MICROSOFT_CLIENT_ID=replace-with-production-application-client-id
325MICROSOFT_CLIENT_SECRET=replace-with-production-secret-value
326MICROSOFT_TENANT_ID=replace-with-production-directory-tenant-guid
327```
328
329Replace 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
333The 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
335Keep 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
339Better 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
341For `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.