1---
2title: "Part 4: Give users their own credentials"
3description: Replace the shared credential with Better Auth accounts and individual Git API keys.
4---
5
6import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
7
8One shared credential cannot distinguish one caller from another. Replace it
9with Better Auth accounts and API keys, then use the authenticated user's ID
10when checking access to a named repository.
11
12Keep the host and storage from [Part 3](/git/tutorial/part-3). Existing repositories
13remain in place. This part creates new repositories under user IDs; it does not
14transfer the existing `acme/web` repository to a user.
15
16## Install Better Auth
17
18<Tabs syncKey="pkgManager">
19 <TabItem label="bun" icon="bun">
20 <Code code={`bun add @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" />
21 </TabItem>
22 <TabItem label="npm" icon="npm">
23 <Code code={`npm install @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" />
24 </TabItem>
25 <TabItem label="pnpm" icon="pnpm">
26 <Code code={`pnpm add @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" />
27 </TabItem>
28 <TabItem label="yarn" icon="seti:yarn">
29 <Code code={`yarn add @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" />
30 </TabItem>
31</Tabs>
32
33Better Auth will manage accounts and credentials. Git will continue to store
34repositories.
35
36## Store accounts in D1
37
38Create `src/auth.ts`:
39
40```typescript
41// src/auth.ts
42import { BetterAuth } from "@alchemy.run/better-auth";
43import * as Cloudflare from "alchemy/Cloudflare";
44
45export const AuthDb = Cloudflare.D1.Database("AuthDb");
46
47export const Auth = BetterAuth({
48 basePath: "/api/auth",
49 emailAndPassword: { enabled: true },
50});
51```
52
53This enables email/password accounts under `/api/auth`. The host will supply
54D1 as the database implementation later in this part.
55
56## Enable individual API keys
57
58```diff lang="typescript"
59// src/auth.ts
60import { BetterAuth } from "@alchemy.run/better-auth";
61+import { apiKey } from "@better-auth/api-key";
62```
63
64Register the plugin:
65
66```diff lang="typescript"
67// src/auth.ts
68 emailAndPassword: { enabled: true },
69+ plugins: [apiKey()],
70});
71```
72
73A signed-in user can now mint an API key for a Git client. That key replaces the
74shared credential in the HTTP Basic password field.
75
76## Set a request allowance for Git
77
78```diff lang="typescript"
79// src/auth.ts
80- plugins: [apiKey()],
81+ plugins: [apiKey({
82+ rateLimit: { timeWindow: 60_000, maxRequests: 1_000 },
83+ })],
84```
85
86A push or clone makes multiple authenticated HTTP requests. Set an explicit
87allowance of 1,000 requests per minute for the keys created in this tutorial;
88the plugin's default of ten requests per day is too small for these steps.
89
90## Represent the caller
91
92Create `src/session.ts`:
93
94```typescript
95// src/session.ts
96import * as Context from "effect/Context";
97
98export class Session extends Context.Service<
99 Session,
100 { readonly user: { readonly id: string } | null }
101>()("app/Session") {}
102```
103
104This service carries the caller through one request. A user has an ID;
105`null` represents an anonymous caller reading a public repository.
106
107## Resolve a session cookie
108
109Create `src/credentials.ts`:
110
111```typescript
112// src/credentials.ts
113import * as Effect from "effect/Effect";
114import { Auth } from "./auth.ts";
115
116export const ResolveUser = Effect.gen(function* () {
117 const auth = yield* Auth;
118 return Effect.gen(function* () {
119 const session = yield* auth.getSession().pipe(
120 Effect.catchTag("BetterAuthApiError", () => Effect.succeed(null)),
121 );
122 return session ? { id: session.user.id.toLowerCase() } : null;
123 });
124});
125```
126
127As with `PublicRead`, the outer effect acquires a dependency and returns a check
128that runs per request. Better Auth reads the request's session cookie. Lowercase
129the ID because Git repository owners are normalized to lowercase.
130
131## Accept an API key from Git
132
133Add the HTTP Basic decoder imports:
134
135```diff lang="typescript"
136// src/credentials.ts
137import * as Effect from "effect/Effect";
138+import * as Redacted from "effect/Redacted";
139+import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
140+import * as HttpApiSecurity from "effect/http-api/HttpApiSecurity";
141```
142
143Check for an API key before looking for a session cookie:
144
145```diff lang="typescript"
146// src/credentials.ts
147 const auth = yield* Auth;
148 return Effect.gen(function* () {
149+ const { password } = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.basic);
150+ const key = Redacted.value(password);
151+ if (key !== "") {
152+ const verified = yield* auth.api.verifyApiKey({ body: { key } }).pipe(
153+ Effect.catchTag("BetterAuthApiError", () =>
154+ Effect.succeed({ valid: false as const, key: null }),
155+ ),
156+ );
157+ return verified.valid && verified.key
158+ ? { id: verified.key.referenceId.toLowerCase() }
159+ : null;
160+ }
161 const session = yield* auth.getSession().pipe(
162```
163
164The key identifies its owner. An invalid key produces an anonymous caller, so
165it cannot grant access to a private repository or a write operation.
166
167## Authorize the identified user
168
169Replace `src/middleware.ts` with:
170
171```typescript
172// src/middleware.ts
173import { RuntimeContext } from "alchemy";
174import * as Effect from "effect/Effect";
175import * as HttpRouter from "effect/http/HttpRouter";
176import * as HttpServerResponse from "effect/http/HttpServerResponse";
177import { ResolveUser } from "./credentials.ts";
178import { PublicRead } from "./public-read.ts";
179import { Session } from "./session.ts";
180
181export const Authentication = HttpRouter.middleware<{ provides: Session }>()(
182 Effect.gen(function* () {
183 const resolveUser = yield* ResolveUser;
184 const publicRead = yield* PublicRead;
185 return (httpEffect) =>
186 Effect.gen(function* () {
187 const user = yield* resolveUser;
188 const { owner } = yield* HttpRouter.params;
189 const own = owner === undefined || owner.toLowerCase() === user?.id;
190 if ((user !== null && own) || (yield* publicRead)) {
191 return yield* Effect.provideService(httpEffect, Session, { user });
192 }
193 return HttpServerResponse.jsonUnsafe(
194 { _tag: "Unauthorized" },
195 { status: 401, headers: { "www-authenticate": 'Basic realm="git"' } },
196 );
197 }).pipe(Effect.provide(RuntimeContext.phantom));
198 }),
199);
200```
201
202For routes naming an owner, the user's ID must match that owner unless the
203request is a public read. The middleware also provides `Session` for application
204handlers to use in Part 5. Routes without an owner parameter require a signed-in
205user; they do not acquire automatic tenant filtering. This tutorial creates
206repositories under the caller's ID explicitly.
207
208## Serve Better Auth's routes
209
210Add the auth imports to `src/host.ts`:
211
212```diff lang="typescript"
213// src/host.ts
214import * as HttpRouter from "effect/http/HttpRouter";
215+import { HttpServerRequest } from "effect/http/HttpServerRequest";
216+import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
217+import { Auth, AuthDb } from "./auth.ts";
218```
219
220Initialize Better Auth beside the Git router:
221
222```diff lang="typescript"
223// src/host.ts
224 Effect.gen(function* () {
225+ const auth = yield* Auth;
226 const fetch = yield* HttpRouter.toHttpEffect(
227```
228
229Send `/api/auth` requests to Better Auth. They must be reachable before a user
230has signed in:
231
232```diff lang="typescript"
233// src/host.ts
234- return { fetch };
235+ return {
236+ fetch: Effect.gen(function* () {
237+ const request = yield* HttpServerRequest;
238+ const path = request.url.split("?")[0];
239+ if (path === "/api/auth" || path?.startsWith("/api/auth/")) {
240+ return yield* auth.fetch;
241+ }
242+ return yield* fetch;
243+ }),
244+ };
245```
246
247Supply the D1 database on the Worker's initialization effect:
248
249```diff lang="typescript"
250// src/host.ts
251- }),
252+ }).pipe(Effect.provide(CloudflareD1(AuthDb))),
253) {}
254```
255
256The adapter binds D1 to the Worker and runs Better Auth's schema migrations at
257deploy time. Git's bucket and Durable Objects are unchanged.
258
259## Remove the shared credential
260
261Remove its imports from `alchemy.run.ts`:
262
263```diff lang="typescript"
264// alchemy.run.ts
265-import * as Output from "alchemy/Output";
266-import * as Redacted from "effect/Redacted";
267-import { GitSecret } from "./src/secret.ts";
268```
269
270Return only the URL again:
271
272```diff lang="typescript"
273// alchemy.run.ts
274 const host = yield* GitHost;
275- const secret = yield* GitSecret;
276- return {
277- url: host.url.as<string>(),
278- secret: Output.map(secret.text, Redacted.value),
279- };
280+ return { url: host.url.as<string>() };
281```
282
283`src/secret.ts` is now unused and can be deleted. The old credential will no
284longer authorize requests after the next deploy.
285
286## Deploy accounts and credentials
287
288<Tabs syncKey="pkgManager">
289 <TabItem label="bun" icon="bun">
290 <Code code={`bun alchemy deploy`} lang="sh" />
291 </TabItem>
292 <TabItem label="npm" icon="npm">
293 <Code code={`npx alchemy deploy`} lang="sh" />
294 </TabItem>
295 <TabItem label="pnpm" icon="pnpm">
296 <Code code={`pnpm alchemy deploy`} lang="sh" />
297 </TabItem>
298 <TabItem label="yarn" icon="seti:yarn">
299 <Code code={`yarn alchemy deploy`} lang="sh" />
300 </TabItem>
301</Tabs>
302
303Install `jq` for the following shell checks if you do not already have it.
304Continue using the same `$HOST`.
305
306## Sign up
307
308```sh
309curl --fail-with-body -c dana.cookies \
310 -X POST "$HOST/api/auth/sign-up/email" \
311 -H "Origin: $HOST" -H "Content-Type: application/json" \
312 -d '{"name":"Dana","email":"dana@example.com","password":"tutorial-password-dana"}' \
313 > dana.json
314export OWNER=$(jq -r '.user.id | ascii_downcase' dana.json)
315```
316
317The response gives you Dana's ID and sets a session cookie. `curl -c` saves that
318cookie; `curl -b` will send it on later requests. `Origin` is included for Better
319Auth's checks on requests using cookies.
320
321If you repeat these steps after creating the account, use `/api/auth/sign-in/email`
322with the same email and password to obtain a new cookie.
323
324## Create a private repository for Dana
325
326```sh
327curl --fail-with-body -b dana.cookies -X POST "$HOST/api/v1/repos" \
328 -H "Origin: $HOST" -H "Content-Type: application/json" \
329 -d "{\"owner\":\"$OWNER\",\"name\":\"web\",\"public\":false}"
330```
331
332This is a new repository named `$OWNER/web`. The earlier `acme/web` remains
333public and readable, but neither Dana nor another new account owns its namespace.
334
335## Mint a Git credential
336
337```sh
338export KEY=$(curl --fail-with-body -b dana.cookies \
339 -X POST "$HOST/api/auth/api-key/create" \
340 -H "Origin: $HOST" -H "Content-Type: application/json" \
341 -d '{"name":"laptop"}' | jq -r .key)
342```
343
344The returned key is the Git password for Dana's account. Save it; Better Auth
345shows the complete key when it is created.
346
347## Push as Dana
348
349```sh
350git -C work remote set-url origin "$HOST/$OWNER/web.git"
351git -c credential.helper= -C work push -u origin main
352```
353
354Enter `x` as the username and `$KEY` as the password. Clone the private repository
355with the same key:
356
357```sh
358git -c credential.helper= clone "$HOST/$OWNER/web.git" dana-copy
359git -C dana-copy fsck --strict
360```
361
362## Check isolation between users
363
364Create a second account:
365
366```sh
367curl --fail-with-body -c alex.cookies \
368 -X POST "$HOST/api/auth/sign-up/email" \
369 -H "Origin: $HOST" -H "Content-Type: application/json" \
370 -d '{"name":"Alex","email":"alex@example.com","password":"tutorial-password-alex"}' \
371 > alex.json
372export ALEX=$(jq -r '.user.id | ascii_downcase' alex.json)
373```
374
375Alex can create a separate private repository:
376
377```sh
378curl --fail-with-body -b alex.cookies -X POST "$HOST/api/v1/repos" \
379 -H "Origin: $HOST" -H "Content-Type: application/json" \
380 -d "{\"owner\":\"$ALEX\",\"name\":\"web\",\"public\":false}"
381curl -i -b alex.cookies "$HOST/api/v1/repos/$ALEX/web"
382```
383
384Expect `200` for Alex's own repository. Reading Dana's private repository with
385Alex's cookie must fail:
386
387```sh
388curl -i -b alex.cookies "$HOST/api/v1/repos/$OWNER/web"
389```
390
391Expect `401`. With `dana.cookies` the same request returns `200`.
392
393[Part 5: Add your application's API](/git/tutorial/part-5) uses `Session` in a
394route you implement yourself.