| 1 | # @alchemy.run/better-auth |
| 2 | |
| 3 | [Better Auth](https://better-auth.com) for [alchemy](https://alchemy.run) — an Effect-native wrapper plus a pluggable database layer per platform. |
| 4 | |
| 5 | Follow the [six-part tutorial](https://alchemy.run/better-auth/tutorial/part-1) to build browser sign-in, Effect HTTP API middleware, and GitHub authentication. See [sign-in providers](https://alchemy.run/better-auth/sign-in-providers) and [database guides](https://alchemy.run/better-auth/databases) for other integrations. |
| 6 | |
| 7 | ```typescript |
| 8 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 9 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 10 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 11 | |
| 12 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 13 | |
| 14 | export default class Api extends Cloudflare.Worker<Api>()( |
| 15 | "Api", |
| 16 | { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, |
| 17 | Effect.gen(function* () { |
| 18 | const auth = yield* BetterAuth({ |
| 19 | basePath: "/auth", |
| 20 | emailAndPassword: { enabled: true }, |
| 21 | }); |
| 22 | |
| 23 | return { |
| 24 | fetch: Effect.gen(function* () { |
| 25 | const request = yield* HttpServerRequest; |
| 26 | if (request.url.startsWith("/auth")) { |
| 27 | return yield* auth.fetch; // the Better Auth HTTP surface |
| 28 | } |
| 29 | const session = yield* auth.getSession(); // null when anonymous |
| 30 | return yield* HttpServerResponse.json({ user: session?.user ?? null }); |
| 31 | }), |
| 32 | }; |
| 33 | }).pipe(Effect.provide(CloudflareD1(AuthDb))), |
| 34 | ) {} |
| 35 | ``` |
| 36 | |
| 37 | What you get: |
| 38 | |
| 39 | - **`yield* BetterAuth(options)`** — full `Auth<Options>` type inference: plugins you pass show up on `auth.api`, `Session` types flow from your options. No tags, no casts. |
| 40 | - **`auth.api.*`** — every Better Auth endpoint as an Effect with a typed `BetterAuthApiError` failure (`status`, `statusCode`, `body.code`, and `headers` *including* the `set-cookie` headers better-call hides on a symbol). Non-`APIError` throws are defects. |
| 41 | - **`auth.fetch`** — an alchemy `HttpEffect` serving the Better Auth routes; works on Cloudflare Workers and AWS Lambda unchanged. |
| 42 | - **`auth.getSession()`** — typed session lookup from the ambient request (or explicit `Headers`); resolves `null` for anonymous. |
| 43 | - **`auth.auth`** — the raw per-execution `Auth` instance as an escape hatch. |
| 44 | - **Automatic schema migrations at deploy** — an internal alchemy Action applies Better Auth's schema (plugins included) during `alchemy deploy`, re-running only when the schema or target database changes. Opt out with `migrate: false`. |
| 45 | - **Managed secret** — `secret` defaults to a stable auto-provisioned `Alchemy.Random` value bound into the host environment. |
| 46 | |
| 47 | The database is a Layer you provide on the surrounding impl effect. Pick the optimal layer for your environment → database pair — HTTP/serverless drivers where they exist, TCP drivers as the fallback: |
| 48 | |
| 49 | | Runtime | Database | Layer | Transport | |
| 50 | | --- | --- | --- | --- | |
| 51 | | Cloudflare Worker | D1 | `CloudflareD1` | native binding | |
| 52 | | Cloudflare Worker / AWS Lambda | Neon | `Neon` | serverless driver (WebSocket) | |
| 53 | | AWS Lambda | Aurora | `AuroraDataApi` | RDS Data API (HTTPS, IAM) | |
| 54 | | Cloudflare Worker | any TCP Postgres/MySQL | `CloudflareHyperdrive` | pooled TCP via Hyperdrive | |
| 55 | | anywhere with sockets | any Postgres | `Postgres` | `pg` TCP | |
| 56 | | anywhere with sockets | any MySQL (e.g. PlanetScale) | `MySQL` | `mysql2` TCP | |
| 57 | | bun (dev/tests) | local file | `SQLite` | `bun:sqlite` | |
| 58 | | tests | in-memory | `Memory` | — | |
| 59 | | bring-your-own | your Drizzle db | `Drizzle` | yours | |
| 60 | |
| 61 | Every layer routes runtime access through alchemy's binding system — native bindings (D1, Hyperdrive, RDS Data API IAM grants) or host-environment Output bindings (connection strings) — never hand-wired env vars. |
| 62 | |
| 63 | ## Cloudflare D1 |
| 64 | |
| 65 | Native D1 binding at runtime; migrations run over the D1 HTTP API at deploy (and against the local simulator under `alchemy dev`). |
| 66 | |
| 67 | ```typescript |
| 68 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 69 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 70 | |
| 71 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 72 | |
| 73 | Effect.gen(function* () { |
| 74 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 75 | // ... |
| 76 | }).pipe(Effect.provide(CloudflareD1(AuthDb))); |
| 77 | ``` |
| 78 | |
| 79 | ## Neon |
| 80 | |
| 81 | Neon's serverless driver speaks WebSocket from the edge — no Hyperdrive, no `pg`, no TCP sockets. The optimal pairing for both Workers and Lambda (`@neondatabase/serverless` is an optional peer). |
| 82 | |
| 83 | ```typescript |
| 84 | import { Neon as NeonDatabase } from "@alchemy.run/better-auth/Neon"; |
| 85 | import * as Neon from "alchemy/Neon"; |
| 86 | |
| 87 | export const AuthDb = Neon.Project("AuthDb"); |
| 88 | |
| 89 | Effect.gen(function* () { |
| 90 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 91 | // ... |
| 92 | }).pipe( |
| 93 | Effect.provide( |
| 94 | Layer.unwrap(Effect.map(AuthDb, (db) => NeonDatabase(db.connectionUri))), |
| 95 | ), |
| 96 | ); |
| 97 | ``` |
| 98 | |
| 99 | ## Aurora (RDS Data API) |
| 100 | |
| 101 | SQL over HTTPS with IAM auth — no VPC attachment for the Lambda, no drivers, no pools. Runtime access flows through the `AWS.RDSData.*` bindings (which grant `rds-data:*` + `secretsmanager:GetSecretValue` on the host); deploy-time migrations use the same Data API with the stack's credentials. `kysely` and `@distilled.cloud/aws` are optional peers. |
| 102 | |
| 103 | ```typescript |
| 104 | import { AuroraDataApi } from "@alchemy.run/better-auth/AuroraDataApi"; |
| 105 | import * as AWS from "alchemy/AWS"; |
| 106 | |
| 107 | export const Db = AWS.RDS.Aurora("AuthDb", { |
| 108 | subnetIds: network.privateSubnetIds, |
| 109 | securityGroupIds: [securityGroup.groupId], |
| 110 | // the Data API is enabled by default |
| 111 | }); |
| 112 | |
| 113 | Effect.gen(function* () { |
| 114 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 115 | // ... |
| 116 | }).pipe(Effect.provide(AuroraDataApi(Db, { database: "postgres" }))); |
| 117 | ``` |
| 118 | |
| 119 | ## Cloudflare Hyperdrive |
| 120 | |
| 121 | Pooled Postgres (or MySQL) through a Hyperdrive connection. Hyperdrive's connection string only exists inside the Worker, so pass the *origin* URL for deploy-time migrations (or omit `migrate` to skip them). |
| 122 | |
| 123 | ```typescript |
| 124 | import { CloudflareHyperdrive } from "@alchemy.run/better-auth/CloudflareHyperdrive"; |
| 125 | |
| 126 | const branch = yield* Neon.Branch("auth-db", { project }); |
| 127 | const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("auth-hd", { |
| 128 | origin: branch.origin, |
| 129 | }); |
| 130 | |
| 131 | Effect.gen(function* () { |
| 132 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 133 | // ... |
| 134 | }).pipe( |
| 135 | Effect.provide( |
| 136 | CloudflareHyperdrive(hyperdrive, { migrate: branch.connectionUri }), |
| 137 | ), |
| 138 | ); |
| 139 | ``` |
| 140 | |
| 141 | ## Postgres (generic TCP) |
| 142 | |
| 143 | The fallback for any Postgres you have a connection string for: PlanetScale Postgres (`role.connectionUrl`), Prisma Postgres (`connection.databaseUrl`), RDS inside a VPC, or a literal URL. One `pg` pool per execution, closed when the event settles. `pg` is an optional peer dependency. (Prefer `Neon`/`AuroraDataApi`/`CloudflareHyperdrive` when they match your pair.) |
| 144 | |
| 145 | ```typescript |
| 146 | import { Postgres } from "@alchemy.run/better-auth/Postgres"; |
| 147 | |
| 148 | const project = yield* Neon.Project("AuthDb"); |
| 149 | |
| 150 | Effect.gen(function* () { |
| 151 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 152 | // ... |
| 153 | }).pipe(Effect.provide(Postgres(project.connectionUri))); |
| 154 | ``` |
| 155 | |
| 156 | Resource Outputs are bound into the host environment at deploy and read back at runtime; the same source drives deploy-time migrations (override with `migrate:`, disable with `migrate: false`). |
| 157 | |
| 158 | ## MySQL (generic TCP) |
| 159 | |
| 160 | Same shape as Postgres over `mysql2` (optional peer): PlanetScale MySQL, Hyperdrive MySQL origins, RDS MySQL. PlanetScale requires TLS — pass it in the URL's `ssl` query parameter. |
| 161 | |
| 162 | ```typescript |
| 163 | import { MySQL } from "@alchemy.run/better-auth/MySQL"; |
| 164 | |
| 165 | const password = yield* Planetscale.MySQLPassword("auth-db", { database, role: "admin" }); |
| 166 | const url = `mysql://${password.username}:...@${password.host}/${database.name}?ssl=${encodeURIComponent('{"rejectUnauthorized":true}')}`; |
| 167 | |
| 168 | Effect.gen(function* () { |
| 169 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 170 | // ... |
| 171 | }).pipe(Effect.provide(MySQL(url))); |
| 172 | ``` |
| 173 | |
| 174 | ## AWS Lambda |
| 175 | |
| 176 | No dedicated host layer needed — `auth.fetch` is host-portable. Serve it from a Lambda function URL with the database layer matching your target: `AuroraDataApi` for Aurora, `Neon` for Neon, or generic `Postgres`/`MySQL` (TCP drivers on Lambda need `build: { install: ["pg"] }` so the dynamically-imported driver ships with an npm layout). |
| 177 | |
| 178 | ```typescript |
| 179 | import * as Lambda from "alchemy/AWS/Lambda"; |
| 180 | import { Neon as NeonDatabase } from "@alchemy.run/better-auth/Neon"; |
| 181 | |
| 182 | export const AuthDb = Neon.Project("AuthDb"); |
| 183 | |
| 184 | export class AuthFunction extends Lambda.Function<Lambda.Function>()("AuthFunction") {} |
| 185 | |
| 186 | export default AuthFunction.make( |
| 187 | // scrypt password hashing needs headroom over the 128 MB default |
| 188 | { main, url: true, memorySize: 512, timeout: Duration.seconds(30) }, |
| 189 | Effect.gen(function* () { |
| 190 | const auth = yield* BetterAuth({ |
| 191 | basePath: "/auth", |
| 192 | emailAndPassword: { enabled: true }, |
| 193 | }); |
| 194 | return { fetch: /* route /auth/* to auth.fetch */ }; |
| 195 | }).pipe( |
| 196 | Effect.provide( |
| 197 | Layer.unwrap(Effect.map(AuthDb, (db) => NeonDatabase(db.connectionUri))), |
| 198 | ), |
| 199 | ), |
| 200 | ); |
| 201 | ``` |
| 202 | |
| 203 | ## SQLite (local development) |
| 204 | |
| 205 | `bun:sqlite`-backed layer for development and tests on the bun runtime. |
| 206 | |
| 207 | ```typescript |
| 208 | import { SQLite } from "@alchemy.run/better-auth/SQLite"; |
| 209 | |
| 210 | Effect.gen(function* () { |
| 211 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 212 | // ... |
| 213 | }).pipe(Effect.provide(SQLite(".alchemy/auth.sqlite"))); |
| 214 | ``` |
| 215 | |
| 216 | ## Memory (tests) |
| 217 | |
| 218 | In-memory adapter; nothing persisted, no migrations needed. |
| 219 | |
| 220 | ```typescript |
| 221 | import { Memory } from "@alchemy.run/better-auth"; |
| 222 | |
| 223 | Effect.gen(function* () { |
| 224 | const auth = yield* BetterAuth({ |
| 225 | emailAndPassword: { enabled: true }, |
| 226 | secret: "test-secret", |
| 227 | }); |
| 228 | // ... |
| 229 | }).pipe(Effect.provide(Memory())); |
| 230 | ``` |
| 231 | |
| 232 | ## Drizzle |
| 233 | |
| 234 | Bring your own Drizzle database via Better Auth's Relations v2 `drizzleAdapter` (optional peers `drizzle-orm` and `@better-auth/drizzle-adapter`). Schema is yours — there is no automatic migration for this layer. Configure the CLI's auth instance with `@better-auth/drizzle-adapter/relations-v2`, then run `npx auth@1.7.5 generate --config ./auth.cli.ts`. Passing `--adapter drizzle` selects the legacy generator instead of the configured adapter. |
| 235 | |
| 236 | ```typescript |
| 237 | import { Drizzle } from "@alchemy.run/better-auth/Drizzle"; |
| 238 | import { drizzle } from "drizzle-orm/node-postgres"; |
| 239 | import * as schema from "./auth-schema.ts"; |
| 240 | import { relations } from "./app-schema.ts"; |
| 241 | |
| 242 | const db = drizzle({ |
| 243 | client: pool, |
| 244 | relations: { ...relations, ...schema.authRelations }, |
| 245 | }); |
| 246 | |
| 247 | Effect.gen(function* () { |
| 248 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 249 | // ... |
| 250 | }).pipe(Effect.provide(Drizzle(db, { provider: "pg", schema }))); |
| 251 | ``` |
| 252 | |
| 253 | ## Handling errors |
| 254 | |
| 255 | ```typescript |
| 256 | const result = yield* auth.api |
| 257 | .signInEmail({ body: { email, password } }) |
| 258 | .pipe( |
| 259 | Effect.catchTag("BetterAuthApiError", (error) => |
| 260 | error.statusCode === 401 |
| 261 | ? Effect.succeed(null) // wrong credentials |
| 262 | : Effect.fail(error), |
| 263 | ), |
| 264 | ); |
| 265 | ``` |
| 266 | |
| 267 | `error.toResponse()` renders the failure exactly as Better Auth would have (status + JSON body + merged headers, `set-cookie` included) for pass-through handlers. |
| 268 | |
| 269 | ## Migrations |
| 270 | |
| 271 | SQL layers with migration support apply schema changes during `alchemy deploy` via an internal alchemy Action: |
| 272 | |
| 273 | - runs **only at apply** (never at plan, never inside the deployed runtime — the migration code is dead-code-eliminated from bundles), |
| 274 | - re-runs only when the auth schema (plugins, additional fields, indexes) or the target database changes, |
| 275 | - applies missing tables, columns, and indexes through Better Auth's migrator; unsafe schema changes surface as `BetterAuthMigrationError`. |
| 276 | |
| 277 | For a direct **1.6 → 1.7.5+** upgrade, the core account schema is unchanged: no `issuer` column or identity backfill is needed. Keep the same auth secret and upgrade Better Auth's synchronized packages together. Microsoft identifiers, legacy OIDC/MCP clients, SCIM, and device codes can require manual preparation; Alchemy does not rewrite identities or convert plugin data. See the [upgrade guide](https://alchemy.run/better-auth/upgrades/from-1-6-to-1-7) before deploying an existing database. |
| 278 | |
| 279 | Opt out with `migrate: false` and manage the schema yourself (`npx auth@1.7.5 generate`). Multiple `BetterAuth` instances in one stack: give each a distinct `id` to disambiguate the secret + migration resources. |