1---
2title: Custom OAuth and OIDC
3description: Add Keycloak OIDC 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 Keycloak and Better Auth 1.7.5's `genericOAuth`; 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 OIDC 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("OIDC_ENABLED").pipe(
55+ Config.withDefault(false),
56+ );
57 return yield* BetterAuth({
58```
59
60OIDC stays disabled until you know the local URL and register its callback. The next addition reads credentials and installs the plugin only when this switch is enabled.
61
62## Register the OIDC provider
63
64Apply both hunks to `src/auth.ts`, adding the imports and the inline plugin configuration:
65
66```diff lang="typescript"
67import * as Option from "effect/Option";
68+import * as Redacted from "effect/Redacted";
69+import { genericOAuth } from "better-auth/plugins/generic-oauth";
70@@
71 baseURL: Option.getOrUndefined(baseURL),
72+ plugins: enabled ? [genericOAuth({
73+ config: [{
74+ providerId: "company",
75+ clientId: yield* Config.String("OIDC_CLIENT_ID"),
76+ clientSecret: Redacted.value(yield* Config.Redacted("OIDC_CLIENT_SECRET")),
77+ discoveryUrl: yield* Config.String("OIDC_DISCOVERY_URL"),
78+ scopes: ["openid", "profile", "email"],
79+ pkce: true,
80+ authentication: "basic",
81+ requireIdTokenVerification: true,
82+ requireEmailVerification: true,
83+ disableProviderLogout: true,
84+ }],
85+ })] : [],
86 account: { accountLinking: { disableImplicitLinking: true } },
87```
88
89In 1.7.5, `genericOAuth` registers a social provider named `company`, using `signIn.social` and `/api/auth/callback/company`, not the older `signIn.oauth2` flow. HTTP Basic client authentication matches Keycloak's client-secret token exchange, while required ID-token verification fails closed when discovery lacks usable verification metadata.
90
91## Keep application logout local
92
93`disableProviderLogout: true` makes sign-out end the application session without terminating a shared Keycloak SSO session. Keep it unless you deliberately configure provider-wide logout and its registered return URLs.
94
95## Declare the database
96
97Create `src/database.ts`:
98
99```typescript
100import * as Cloudflare from "alchemy/Cloudflare";
101
102export const AuthDb = Cloudflare.D1.Database("AuthDb");
103```
104
105This 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.
106
107## Give Auth its database layer
108
109Create `src/auth-layer.ts`:
110
111```typescript
112import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
113import * as Layer from "effect/Layer";
114import { Auth } from "./auth.ts";
115import { AuthDb } from "./database.ts";
116
117export const AuthLive = Auth.layer.pipe(
118 Layer.provide(CloudflareD1(AuthDb)),
119);
120```
121
122`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.
123
124## Host Auth in a Worker
125
126Create `src/worker.ts`:
127
128```typescript
129import * as Cloudflare from "alchemy/Cloudflare";
130import * as Effect from "effect/Effect";
131import { Auth } from "./auth.ts";
132import { AuthLive } from "./auth-layer.ts";
133
134export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()(
135 "AuthWorker",
136 {
137 main: import.meta.url,
138 compatibility: { flags: ["nodejs_compat"] },
139 assets: { directory: "./public", runWorkerFirst: ["/api/*"] },
140 },
141 Effect.gen(function* () {
142 const auth = yield* Auth;
143 return { fetch: auth.fetch };
144 }).pipe(Effect.provide(AuthLive)),
145) {}
146```
147
148The Worker forwards API requests to Better Auth, including its session and callback endpoints. Static assets serve the browser page outside `/api/*`.
149
150## Define the stack
151
152Use this as `alchemy.run.ts` for the walkthrough:
153
154```typescript
155import * as Alchemy from "alchemy";
156import * as Cloudflare from "alchemy/Cloudflare";
157import * as Effect from "effect/Effect";
158import AuthWorker from "./src/worker.ts";
159
160export default Alchemy.Stack(
161 "CompanyAuth",
162 { providers: Cloudflare.providers(), state: Alchemy.localState() },
163 Effect.gen(function* () {
164 const worker = yield* AuthWorker;
165 return { url: worker.url };
166 }),
167);
168```
169
170The output exposes the Worker's actual URL. Preserve the local state directory so subsequent runs retain resource identities and the signing secret.
171
172## Add the browser page
173
174Create `public/index.html`:
175
176```html
177<!doctype html>
178<html lang="en">
179 <head>
180 <meta charset="utf-8">
181 <meta name="viewport" content="width=device-width, initial-scale=1">
182 <title>Company sign-in</title>
183 </head>
184 <body>
185 <h1>Company sign-in</h1>
186 <button id="sign-in" type="button">Sign in with company</button>
187 <button id="read-session" type="button">Read session</button>
188 <button id="sign-out" type="button">Sign out</button>
189 <p id="status" role="status"></p>
190 <script type="module" src="/client.js"></script>
191 </body>
192</html>
193```
194
195The page provides three controls and a text-only status area. The browser bundle will be written to `public/client.js`.
196
197## Create the browser client
198
199Create `src/client.ts`:
200
201```typescript
202import { createAuthClient } from "better-auth/client";
203
204const authClient = createAuthClient();
205const status = document.getElementById("status")!;
206
207async function run(action: () => Promise<string>) {
208 try {
209 status.textContent = await action();
210 } catch {
211 status.textContent = "Could not reach the server. Try again.";
212 }
213}
214```
215
216The client uses the page's origin and the default `/api/auth` path; no `genericOAuthClient` plugin is needed in 1.7.5. It imports no server Auth module or credentials.
217
218## Start company sign-in
219
220Append to `src/client.ts`:
221
222```typescript
223document.getElementById("sign-in")!.onclick = () => void run(async () => {
224 const { data, error } = await authClient.signIn.social({
225 provider: "company",
226 callbackURL: "/",
227 disableRedirect: true,
228 });
229 if (error) return "Could not start company sign-in. Check the configuration.";
230 if (!data?.url) return "No authorization URL was returned.";
231 window.location.assign(data.url);
232 return "Redirecting to company sign-in…";
233});
234```
235
236The provider ID matches the server's `company` registration. Better Auth returns the authorization URL and sends successful callbacks back to `/`.
237
238## Read the current session
239
240Append to `src/client.ts`:
241
242```typescript
243async function readSession(): Promise<string> {
244 const { data, error } = await authClient.getSession();
245 if (error) return "Could not check your session. Try again.";
246 return data ? `Signed in as ${data.user.name}.` : "Not signed in.";
247}
248
249document.getElementById("read-session")!.onclick = () => void run(readSession);
250void run(readSession);
251```
252
253The 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.
254
255## Sign out
256
257Append to `src/client.ts`:
258
259```typescript
260document.getElementById("sign-out")!.onclick = () => void run(async () => {
261 const { error } = await authClient.signOut();
262 return error ? "Could not sign out. Try again." : "Signed out.";
263});
264```
265
266This ends the application session. Because provider logout is disabled, a subsequent sign-in may reuse the user's existing Keycloak SSO session.
267
268## Bundle the browser client
269
270```sh
271bun build ./src/client.ts --target browser --outdir ./public
272```
273
274The 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.
275
276## Discover the local origin
277
278```sh
279bunx alchemy dev --stage company-guide
280```
281
282Run without provider credentials or `OIDC_ENABLED=true` for this first start; Alchemy allocates a port and prints the actual origin. Keep OIDC disabled until the registration below is ready, and rebuild the browser bundle whenever you edit `src/client.ts`.
283
284## Create the Keycloak client
285
286In Keycloak, select your application realm rather than the administration realm, open **Clients → Create client**, choose **OpenID Connect**, and set a client ID such as `alchemy-auth`. Enable **Client authentication** and **Standard flow**, leaving implicit flow and direct access grants disabled.
287
288## Register the callback
289
290```text
291/api/auth/callback/company
292```
293
294In **Login settings**, set the home URL to the printed origin and add that origin plus this path to **Valid redirect URIs**, without wildcards. Save the client and copy its secret from **Credentials** for the server-side token exchange.
295
296## Configure identity claims
297
298Require S256 PKCE in the client's advanced settings where available, and ensure the `profile` and `email` scopes supply `sub`, `name`, `email`, and `email_verified`. Configure real realm email delivery and verification, and use verified realm users rather than marking arbitrary addresses verified.
299
300Keycloak's [server administration guide](https://www.keycloak.org/docs/latest/server_admin/#_oidc_clients) covers client configuration. Its [OIDC guide](https://www.keycloak.org/securing-apps/oidc-layers) describes discovery endpoints.
301
302## Supply server credentials
303
304Create `.env.local.example`, then copy it to a gitignored `.env.local` and replace the placeholder values:
305
306```dotenv
307OIDC_ENABLED=true
308OIDC_CLIENT_ID=alchemy-auth
309OIDC_CLIENT_SECRET=replace-with-your-client-secret
310OIDC_DISCOVERY_URL=https://identity.example.com/realms/my-app/.well-known/openid-configuration
311# Leave AUTH_BASE_URL unset for local request-origin discovery.
312```
313
314Discovery must be reachable from the Worker and publish the expected issuer, authorization/token endpoints, signing algorithms, and `jwks_uri`; use the application realm's real URL. These server-side Config values must stay out of `VITE_*`, browser bundles, source control, and logs.
315
316## Enable OIDC locally
317
318```sh
319bunx alchemy dev --stage company-guide --env-file .env.local
320```
321
322Stop the first dev process before running this command, and update Keycloak's callback if the newly printed origin changes. Open that origin, select **Sign in with company**, then use **Read session** and **Sign out** to exercise the roundtrip.
323
324## Set the production origin
325
326Create `.env.production.example` as a template for your private production configuration:
327
328```dotenv
329AUTH_BASE_URL=https://auth.example.com
330OIDC_ENABLED=true
331OIDC_CLIENT_ID=replace-with-production-client-id
332OIDC_CLIENT_SECRET=replace-with-production-client-secret
333OIDC_DISCOVERY_URL=https://identity.example.com/realms/production/.well-known/openid-configuration
334```
335
336Replace 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 separate production clients or realms, register that origin plus `/api/auth/callback/company`, and supply these values through your deployment's server configuration.
337
338## Preserve provider identity
339
340`company` determines both the callback path and stored provider identity: do not rename it or point it at an unrelated issuer once accounts exist. Discovery-based OIDC uses the provider profile's `sub` as the account subject, not a mutable email address, and this configuration requires verified ID tokens with nonce binding enabled.
341
342Keep implicit linking disabled, verified-email checks, and the same-email restriction 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 adding `company` to `trustedProviders` or merging rows to bypass ownership checks.
343
344## Adapt another provider
345
346Edit the inline `genericOAuth` configuration in `src/auth.ts`, preserving any other plugins when integrating into a larger application. For plain OAuth, use reviewed `authorizationUrl`, `tokenUrl`, and `userInfoUrl` settings instead of OIDC discovery, and verify the vendor's token authentication, PKCE, identity, and email-trust requirements.
347
348Plain OAuth defaults to the raw profile's immutable `id`; nonstandard fields require an `accountSubject` resolver and possibly `getUserInfo` or `mapProfileToUser`. Consult the upstream [Generic OAuth documentation](https://www.better-auth.com/docs/plugins/generic-oauth) rather than merely removing ID-token verification from this OIDC example.
349
350## Diagnose callback failures
351
352Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom application error query is needed. Keep ID-token verification, nonce binding, state, origin, and CSRF checks enabled.
353
354For an unknown provider, check discovery reachability and issuer/JWKS metadata; for callback mismatches, compare the exact `/api/auth/callback/company` registration. For token-exchange failures, compare the secret and authentication method, then start a fresh sign-in instead of replaying codes or logging tokens and full callback URLs.
355
356## Resolve email-verification failures
357
358`requireEmailVerification` checks local user verification and can leave a created or linked account without a session. For `email_not_verified`, review both issuer and local verification state: an existing unverified local user needs a real [email-verification flow](https://www.better-auth.com/docs/concepts/email), not a database edit to mark the user verified.