Better Auth on Cloudflare Workers + D1
Both of the user's auth-bearing sites declare better-auth ^1.6.25 on a Worker
with a D1 binding. This skill is the delta between "the docs" and "what actually
broke."
Read the traps before writing code. Every one below cost at least one deploy cycle, and each carries the command that proves it rather than an assertion.
Current state (verify, don't trust this table — it is a cache)
Verified 2026-08-05.
| improvebayarea.com | example.com | |
|---|---|---|
| Repo | ~/tools/improvebayarea |
~/AIVA-Frontend |
| Auth file | src/auth.ts |
src/worker/auth.ts |
| Shape | Hono SSR, inline client JS in src/ui.ts |
React SPA + Hono SSR |
| Installed better-auth | 1.6.26 | 1.6.25 |
| Plugins | bearer, magicLink, emailOTP, twoFactor, passkey |
bearer, emailOTP, twoFactor, passkey |
| Native social | yes — @capgo/capacitor-social-login, live |
plugin installed + SPM-linked, no JS call site |
| Native audience fix | Google + Apple (Trap 1) | Apple present; Google still absent — no iOS OAuth client exists yet |
⚠️ Identical ^1.6.25 ranges, different installed versions. Adding
@better-auth/passkey@^1.6.26 to IBA let npm re-resolve the caret and float core
to 1.6.26; AIVA never re-installed and sits at 1.6.25. package.json therefore
cannot tell you what is running — the plugin schema you transcribe a migration
from (Trap 8) is the installed one. Always read node_modules.
Re-check with:
node -e "const p=require('./package.json').dependencies;
console.log('range:',p['better-auth'],'| installed:',require('better-auth/package.json').version)"
grep -nE "socialProviders|plugins: \[" -A 20 <auth-file>
⚠️ npm latest has moved past this table — re-check before pinning a NEW project (added 2026-08-30).
Verified live on 2026-08-30: better-auth latest on npm is 1.7.2 (published 2026-08-26), while
both sites above are still on 1.6.26 / 1.6.25 — a minor version behind. The table is accurate about
these two sites; it is NOT a version recommendation for a greenfield build, and reading it as one is
exactly the mistake it invites. This bit for real: on 2026-08-30 a new Workers project was briefed with
better-auth ^1.6.26 copied straight out of this file, 25 days stale, and the pin had to be corrected
mid-build.
So for any NEW site, resolve the version from the registry, not from here:
curl -s https://registry.npmjs.org/better-auth | python3 -c "import json,sys;d=json.load(sys.stdin);v=d['dist-tags']['latest'];print(v, d['time'][v][:10])"
Then install, and transcribe every plugin migration from the schema in node_modules (Trap 8) — a
1.6.x → 1.7.x jump can move a core or plugin table, and this file documents the 1.6.x shape.
Standing up a NEW site (do it in this order)
Most of this skill is written from two live migrations, so it reads as "fix the thing that broke." On a greenfield site nothing has broken yet — follow this order and most traps never fire. Traps 7 (id preservation) and the decommission section do not apply at all; skip them.
| # | Step | The trap it pre-empts |
|---|---|---|
| 1 | Create the D1 database + binding DB; write the core schema migration (below) and apply it --remote |
Plugin/core code writes these tables on first use; a missing column is a generic 500 (Trap 11) |
| 2 | openssl rand -base64 32 | wrangler secret put BETTER_AUTH_SECRET |
— |
| 3 | createAuth(env) per request + mount both verbs on /api/auth/* |
Trap 2 |
| 4 | Prove it before adding anything: /api/auth/ok → 200, wrong password → 401 |
A green build is not a working auth route |
| 5 | Add providers ONE at a time, each with its own migration when it has tables | Trap 11 — transcribe DDL from the installed package |
| 6 | Add the Capacitor shell LAST, if there is one | Traps 1, 3, 4, 5 all live here and all need a new binary |
Decide two things on day one, because changing them later invalidates live
credentials: advanced.cookiePrefix (renaming it signs every user out) and
PASSKEY_RP_ID (changing it invalidates every registered passkey — they are bound
to the RP id).
The core schema — verified against prod, not transcribed from docs
⚠️ THIS DDL IS 1.6.x AND IS WRONG FOR better-auth 1.7.x (added 2026-08-30). Measured by reading the INSTALLED 1.7.2 package —
@better-auth/core/dist/db/get-tables.mjs(buildAuthTables) and.../db/schema/account.mjs(accountSchema) — not from docs:
- NEW REQUIRED COLUMN on
account:issuer text NOT NULL(accountSchemadeclaresissuer: z.string()with no.nullish(), so it is mandatory).- NEW UNIQUE INDEX
(issuer, accountId).- Field order changed:
issuer, accountId, providerId, userId, ….Apply the DDL below unmodified against 1.7.x and every account write 500s with
no such column: issuer— visible only inwrangler tail, never in the response body. That is Trap 11 biting the core schema rather than a plugin's.So do what Trap 11 already tells you to do for plugins, and do it for core too: transcribe from
node_modules, not from this file. The block below is a reference for shape and constraints, not a copy-paste source. Re-derive before every new build:node -e "console.log(require('better-auth/package.json').version)" grep -rn 'issuer' node_modules/@better-auth/core/dist/db/schema/account.mjs
better-auth needs four tables before any plugin. This is improvebayarea's applied
migration, confirmed live on 2026-08-05 via
wrangler d1 execute <db> --remote --command 'PRAGMA table_info("user")':
CREATE TABLE IF NOT EXISTS "user" (
"id" text NOT NULL PRIMARY KEY, "name" text NOT NULL,
"email" text NOT NULL UNIQUE, "emailVerified" integer NOT NULL,
"image" text, "createdAt" date NOT NULL, "updatedAt" date NOT NULL
);
CREATE TABLE IF NOT EXISTS "session" (
"id" text NOT NULL PRIMARY KEY, "expiresAt" date NOT NULL,
"token" text NOT NULL UNIQUE, "createdAt" date NOT NULL, "updatedAt" date NOT NULL,
"ipAddress" text, "userAgent" text,
"userId" text NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "account" (
"id" text NOT NULL PRIMARY KEY, "accountId" text NOT NULL, "providerId" text NOT NULL,
"userId" text NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"accessToken" text, "refreshToken" text, "idToken" text,
"accessTokenExpiresAt" date, "refreshTokenExpiresAt" date,
"scope" text, "password" text, "createdAt" date NOT NULL, "updatedAt" date NOT NULL
);
CREATE TABLE IF NOT EXISTS "verification" (
"id" text NOT NULL PRIMARY KEY, "identifier" text NOT NULL, "value" text NOT NULL,
"expiresAt" date NOT NULL, "createdAt" date NOT NULL, "updatedAt" date NOT NULL
);
CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId");
CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId");
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");
ON DELETE CASCADE on session.userId/account.userId is what makes account
deletion actually delete — same reasoning as Trap 11, one level down.
🛑 Plugins ADD COLUMNS TO user, they do not only add tables. The prod user
table has 8 columns against the 7 above: twoFactor() appends
twoFactorEnabled. So a column-count assertion against this DDL will "fail" on a
correct database once a plugin is enabled — count against the installed plugin
set, not this snippet. That is also why the ALTER TABLE "user" ADD COLUMN in a
plugin migration must go LAST (Trap 11): SQLite has no ADD COLUMN IF NOT EXISTS.
trustedOriginsFor — referenced everywhere, so here it is
export function trustedOriginsFor(env: AuthEnv): string[] {
return [
"https://example.com",
"https://www.example.com",
"capacitor://localhost", // Capacitor webview — see below
];
}
trustedOrigins and PASSKEY_ORIGINS are different lists and must stay
different. trustedOrigins is better-auth's CSRF/redirect allowlist and legitimately
includes capacitor://localhost. passkey({ origin }) is WebAuthn's expectedOrigin
and must contain only secure HTTPS origins whose registrable domain matches
rpID — putting capacitor://localhost there is a lie the browser rejects anyway,
and it hides the real failure (Trap 9). Include www. in both.
The working baseline
export function createAuth(env: AuthEnv) {
if (!env.BETTER_AUTH_SECRET) throw new Error("BETTER_AUTH_SECRET not set");
if (!env.DB) throw new Error("D1 binding DB is not configured");
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL || "https://example.com",
basePath: "/api/auth",
database: env.DB, // see Trap 2
telemetry: { enabled: false },
trustedOrigins: trustedOriginsFor(env),
advanced: { cookiePrefix: "app" },
plugins: [bearer(), /* … */],
});
}
Mount in Hono — both verbs, the whole subtree:
app.on(["GET", "POST"], "/api/auth/*", async (c) => createAuth(c.env).handler(c.req.raw));
Secrets: BETTER_AUTH_SECRET, plus GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET,
APPLE_CLIENT_ID/APPLE_CLIENT_SECRET per provider. wrangler secret put each —
never vars. Obtaining them is the actual work — see the next section.
Provisioning the credentials (the part the docs skip)
Getting these is most of the setup cost. Order matters; each step has a trap.
0. BETTER_AUTH_SECRET
openssl rand -base64 32 | wrangler secret put BETTER_AUTH_SECRET
Never echo it into the transcript. Verify shape only: wrangler secret list shows
secret_text.
1. Google — you need TWO clients, and only one has a secret
| Client type | Used by | Has a secret? |
|---|---|---|
| Web application | browser redirect flow; this is GOOGLE_CLIENT_ID |
yes → GOOGLE_CLIENT_SECRET |
| iOS | native GIDSignIn in the Capacitor shell |
no — public by design, ships in the bundle |
Console: https://console.cloud.google.com/apis/credentials?authuser=1
(authuser=1 matters — authuser=0 can land on an MFA-enrollment gate.)
- Web client → Authorized redirect URI
https://<site>/api/auth/callback/google. - iOS client → bind to the bundle id + team id. Its reversed id is the
CFBundleURLScheme(Trap 3). Put both ids in one shared module so the shell-side id and the server-side accepted audience (Trap 1) can never drift.
🛑 Google caps a client at TWO client secrets, and delete requires disable first. The trash icon is inert until the secret is disabled — that is a two-step UI, not a broken button. So when rotating: confirm the stale secret is unused, disable it, then delete, then add the new one. Plan this before you hit the cap.
Never let a secret value into the transcript. Pipe it:
pbpaste | tr -d '\n' | wrangler secret put GOOGLE_CLIENT_SECRET
Verify by shape, never by printing: wrangler secret list | grep GOOGLE_CLIENT_SECRET.
If you must confirm a value came through, use a boolean test (does string X appear:
true/false) — never return the surrounding text, "scrubbed" or otherwise.
2. Apple — a Services ID plus a JWT you mint yourself
APPLE_CLIENT_ID is the Services ID (e.g. com.improvebayarea.signin), not
the app bundle id. APPLE_APP_BUNDLE_ID is separately needed for the native
audience (Trap 1).
Register the return URL under the Services ID → Sign in with Apple → Configure:
the ⊕ button REGISTERS a new Website URL; the dropdown only LISTS what is already
attached. Editing the visible field and pressing Save is a silent no-op — this
looks exactly like a broken form. Add https://<site>/api/auth/callback/apple, then
reload and re-read the list to confirm it stuck.
APPLE_CLIENT_SECRET is not issued by Apple — it is a self-signed ES256 JWT
you generate from a .p8 Sign-in-with-Apple key, and Apple caps it at 6 months.
The JWT signature must be raw 64-byte r||s (ieee-p1363), not DER — hand-rolling
that is where people lose an afternoon.
Drive the whole Apple side with siwa (~/tools/siwa/siwa, on PATH), which
wraps every step below. Add the site once to ~/.config/siwa/sites.json
(host, services_id, team_id, key_id, p8, worker_repo, worker_name), then:
siwa portal <site> # prints the 2 web-only steps (Services ID + Return URL)
siwa domain-file <site> ~/Downloads/apple-developer-domain-association.txt
siwa secret <site> # mint JWT -> both APPLE_* secrets -> deploy -> register expiry
siwa probe <site> # ask APPLE whether the credential chain is valid
siwa verify <site> # live: assoc file + Apple button + flow -> appleid.apple.com
siwa rotate <site> # the 6-month re-mint (same as secret + probe)
siwa secret shells out to the same apple-client-secret.mjs generator (one
implementation, one place to fix) and never prints the JWT — only its length and
expiry. betterauth stays read-only and siwa owns the mutations; that split
is deliberate, so don't teach betterauth to write secrets.
🛑 siwa probe is the cheapest real proof the credential chain works, and its
PASS is an ERROR string. It POSTs a deliberately-bogus authorization code to
https://appleid.apple.com/auth/token:
Apple's error |
Means |
|---|---|
invalid_grant |
✅ Apple accepted the client secret and refused only the fake code — the .p8 / key_id / team_id / Services-ID chain is CORRECT |
invalid_client |
❌ Apple rejected the credential itself |
Same shape as the APNs BadDeviceToken trick: make the credential the only thing
under test. No browser, no sign-in, no side effect — so it is free to run often.
⚠️ The Worker's auth gate will break Apple's domain verification if you let it.
Apple fetches /.well-known/apple-developer-domain-association.txt
unauthenticated. A session gate that allows only /login and /api/auth/*
redirects that path to /login, and Apple reads the 302 as a failure with nothing
in your logs explaining why. Allow the directory through explicitly:
if (path.startsWith('/.well-known/')) return next(); // BEFORE the session check
Proof it worked, on imessage-bridge: that path went 302 → 404 after the fix
(404 = reaching the asset layer, file not installed yet). Same allowance covers
security.txt and ACME challenges. Safe to expose — the directory only ever holds
values that are public by design.
⚠️ A SIWA .p8 is a TEAM-level key. One key signs JWTs for any Services ID
in that team, so an existing key (e.g. ~/.asc/keys/AuthKey_Z5JJTW7C94-siwa-improvebayarea.p8,
team 2KJ8W6N44B) is reusable for a new site — a new Apple Developer account or key
is NOT required. The Services ID goes in the JWT's sub; the key only signs.
3. Register every expiring secret for rotation — MANDATORY
Apple gives no warning and the failure is opaque: sign-in simply stops, site-wide
and in the app. Add an entry to ~/tools/secret-expiry/secrets.json with name,
expires, owner, impact, and a copy-pasteable renew command, then scp it to
the mac-mini — the mini is the cron host, so an entry only on the laptop never
alerts. Both sites are already registered; copy an existing entry's shape.
Derive expires from the earlier plausible mint date, so the alert fires early
rather than late.
4. Decommission the old provider — no half-configs
After cutover, delete the old secret (wrangler secret delete CLERK_SECRET_KEY),
strip its hosts from the CSP, remove the packages, and drop the DNS record. Audit
with wrangler secret list — a leftover key is both a live credential and a lie
about which system is in charge.
Verified 2026-08-05: both sites are now clean.
CLERK_SECRET_KEYdeleted from both Workers (wrangler secret listshows it absent whileBETTER_AUTH_SECRETremains — always assert the control, or you cannot tell "deleted" from "list broke"). All 10 dangling DNS records removed across both zones;dig @1.1.1.1returns NXDOMAIN forclerk,accounts,clkmail,clk._domainkey,clk2._domainkeyon each.Two lessons worth more than the result:
- A source grep is the WRONG instrument for decommissioning. Both codebases had zero
@clerk/*packages and zeroenv.CLERK_*reads — a grep said "clean" while a live credential was still bound to production and 10 CNAMEs still pointed into a deleted third-party tenant (a textbook subdomain-takeover setup). The binding and the zone are the ground truth; the imports are not. Audit withwrangler secret listanddig, notgrep.- Check the DKIM/mail records too, not just the auth hosts. The obvious two are
clerkandaccounts; the easy misses areclkmail+ the twoclk*._domainkeyCNAMEs. Before deleting them, confirm the domain's SPF does not reference the vendor and that the real senders' selectors still resolve — then removing them breaks no mail.
Trap 1 — Native iOS social sign-in: the id token audience is NOT your web client
The single most expensive trap. It has bitten Apple and Google separately.
Google/Apple mint an id token whose aud is whichever client asked for it. The
browser flow asks as your web client; a native iOS sheet asks as the iOS client
(Google) or the bundle id (Apple). Better Auth's default verifiers check
aud === options.clientId and nothing else, so a fully successful OAuth
round-trip is then rejected by your own server.
The symptom is maximally misleading: the user completes the Google sheet, Google emails "You shared some Google Account data with ", and the app shows "Could not sign in with Google. Invalid token."
🛑 Corrected 2026-08-05 — the earlier advice here was wrong, and it shipped
the bug to production. This skill used to say Apple had a "first-class option"
(appBundleIdentifier) while Google did not. Reading the installed source,
@better-auth/core/dist/social-providers/apple.mjs:63:
audience: options.audience?.length ? options.audience
: options.appBundleIdentifier ? options.appBundleIdentifier
: options.clientId,
appBundleIdentifier REPLACES the audience — it does not add one. Setting it
makes the bundle id the only valid audience and silently drops the web Services
ID. The audience ARRAY takes precedence over both and is the only form that
accepts each. So Apple and Google get the same shape, not different ones:
apple: {
clientId: env.APPLE_CLIENT_ID as string,
clientSecret: env.APPLE_CLIENT_SECRET as string,
audience: [env.APPLE_CLIENT_ID as string, APPLE_APP_BUNDLE_ID], // ← BOTH
},
google: {
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
async verifyIdToken(token: string, nonce?: string) {
const claims = await verifyGoogleIdToken({
token,
audience: [env.GOOGLE_CLIENT_ID as string, GOOGLE_IOS_CLIENT_ID], // jose takes an array
nonce,
});
return Boolean(claims);
},
},
import { verifyGoogleIdToken } from "better-auth/social-providers".
Two failure modes this exact trap produced on improvebayarea (fixed e4a486f):
- An OPTIONAL env var made a REQUIRED audience vanish. The provider spread
appBundleIdentifierconditionally onenv.APPLE_APP_BUNDLE_ID— a var never set on the Worker. The spread no-opped, the comment above it described a configuration that did not exist, and every native Apple sign-in was rejected. A bundle id is public and must always be present, so it belongs in a constant, not a secret.wrangler secret listis what proves an env-gated config is actually live; the source alone cannot. - Apple can be LIVE-broken while Google is only latent. Capability gating is
per-provider: on IBA
nativeSocialAvailable('google')checks a shell UA token, butnativeSocialAvailable('apple')is justBoolean(AUTH_SOCIAL.apple)(src/ui.ts:1919) — so Apple takes the native id-token path in every in-app webview, unconditionally. Check each provider's gate separately; do not assume the one you audited represents the others.
This is not a weakening. It calls Better Auth's own verifier — JWKS signature, issuer, expiry, 1h max age and nonce all still enforced. Only the audience allowlist widens, to your own second client. That is Google's documented pattern for a backend serving several platforms. Assert the set is exactly your clients so a wildcard can never creep in.
Do NOT "fix" it with the plugin's iOSServerClientId. That maps to GIDSignIn's
serverClientID, which the SDK sends as the OAuth audience request parameter
(GIDSignIn.m:896) to obtain a serverAuthCode; whether it also rewrites the id
token's aud is undocumented — the plugin's own docs tie the field to offline mode.
Accepting both audiences is correct regardless, which is why it is the right fix.
Probe (proves the deployed verifier, costs nothing — a failed sign-in has no side effect):
HDR=$(printf '{"alg":"RS256","kid":"k"}' | base64 | tr '+/' '-_' | tr -d '=')
PAY=$(printf '{"aud":"PROBE.apps.googleusercontent.com","iss":"https://accounts.google.com","exp":%d}' $(($(date +%s)+600)) | base64 | tr '+/' '-_' | tr -d '=')
curl -s -X POST https://<site>/api/auth/sign-in/social -H 'Content-Type: application/json' \
-d "{\"provider\":\"google\",\"idToken\":{\"token\":\"$HDR.$PAY.sig\"}}"
Expect 401. Then read wrangler tail for the discriminator (Trap 6).
Trap 2 — Instantiate PER REQUEST; hand D1 in raw
env.DB is scoped to one invocation. A module-level singleton captures the first
request's binding and silently breaks on every later one. createAuth(env) does no
I/O, so per-request is cheap.
Pass the binding directly as database: env.DB — @better-auth/kysely-adapter
detects the batch/exec/prepare surface and wraps it in its own D1SqliteDialect
(@better-auth/kysely-adapter/dist/index.mjs:67-69). No dialect construction needed.
Trap 3 — Capacitor: no URL scheme means the app CRASHES, not "fails"
Native Google requires the reversed client id in Info.plist:
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLSchemes</key>
<array><string>com.googleusercontent.apps.NNNN-xxxx</string></array>
</dict></array>
Without it GoogleSignIn raises NSInvalidArgumentException (GIDSignIn.m:733)
— an uncaught ObjC exception that kills the app. There is no alternative path and
no graceful degradation: enabling the button in a build lacking the scheme crashes
every install that taps it.
The scheme is compiled in, so this always needs a new TestFlight build.
⚠️ mobile/ios/App/App/capacitor.config.json is gitignored and regenerated by
npx cap sync ios. Archiving without syncing produces a binary missing whatever you
just added to capacitor.config.ts — with no error anywhere. Verify in the archive:
/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:0:CFBundleURLSchemes:0" <App>.app/Info.plist
grep -o '"appendUserAgent"[^,]*' <App>.app/capacitor.config.json
Trap 4 — Remote-URL wrap: capability tokens, never a boolean flag
When server.url points at the live site, the web layer is served to every
install at once, old and new. A site-side const NATIVE_READY = true therefore
switches the feature on inside already-installed builds that lack the native
capability — the exact dead-end (or crash) the flag was meant to prevent.
Invert it: the shell declares what it can do, the site reacts.
// capacitor.config.ts — add ONLY in the commit that adds the capability to the binary
appendUserAgent: "MyApp nativeGoogleAuth/1",
function shellDeclaresNativeGoogle() {
try { return (navigator.userAgent || '').includes('nativeGoogleAuth/1'); }
catch (e) { return false; }
}
A build without the capability never emits the token, so a stale install cannot opt in. No version arithmetic, no deploy ordering. Safari lacks it too — correct, since browsers use the web flow.
Prove the token actually reaches the page (server-side is ground truth):
wrangler tail --format json # then load the app; read event.request.headers['user-agent']
Trap 5 — A JS gate is not enough: CSS can override it
Sites that hide social buttons inside webviews often do it in CSS:
html[data-native-shell] #btn-social-google { display: none !important; }
That beats any JS capability check. On improvebayarea the JS gate returned true,
every condition passed, and the button still did not render — display:none !important won, and a test asserted the unconditional rule, locking the bug in.
Fix by excluding capable shells at the selector level and setting the attribute only when the capability is real:
html[data-native-shell]:not([data-native-google]) #btn-social-google { display: none !important; }
Never conclude a social button works from source review. Run it and look.
Trap 6 — "Invalid token" is one string for many causes: ship a discriminator
Better Auth collapses audience mismatch, bad signature, and expiry into one
{"message":"Invalid token","code":"INVALID_TOKEN"}. Every single-cause fix then
looks byte-identical to no fix — Pattern #32.
Log the measurable properties on the rejection path. Non-PII only — an id token is full of user data:
if (!claims) {
let aud = "<unparseable>", iss = "<unparseable>";
let expired: boolean | "unknown" = "unknown";
try {
const j = JSON.parse(atob((token.split(".")[1] ?? "").replace(/-/g,"+").replace(/_/g,"/")));
if (typeof j.aud === "string") aud = j.aud;
if (typeof j.iss === "string") iss = j.iss;
if (typeof j.exp === "number") expired = j.exp * 1000 < Date.now();
} catch { /* placeholders ARE the signal */ }
console.warn(JSON.stringify({ event: "google_id_token_rejected", aud, iss, expired,
audAccepted: [webId, iosId].includes(aud), nonceSupplied: Boolean(nonce), tokenLen: token.length }));
}
Log aud/iss/exp — never sub, email, name, or the token. The unverified
decode is for logging only; trust still comes solely from the verifier above it.
Trap 7 — Migrating off Clerk: preserve the user ids
App data is FK'd to the old provider's user id. Import users with
id = <old provider id> via direct D1 inserts — zero FK re-keying, and social
accounts relink on first sign-in because the OAuth apps (and therefore sub) are
unchanged. Set account.accountLinking.trustedProviders for providers that verify
the email, or the true owner hits account_not_linked.
Password hashes are dashboard-export only (the Backend API never exposes
password_digest), and Clerk's password_hasher is an enum of ~15 algorithms, not
just bcrypt. Import a credential row only for hashes you can actually verify;
for anything else send a reset email rather than writing a credential that can never
match — that failure surfaces as a generic "invalid password" with nothing in the logs.
A provider migration silently drops credential types while leaving their
infrastructure standing. On IBA the Clerk-era AASA + Associated-Domains
entitlement for passkeys (dadfdf3) survived the cutover; the credentials did
not — 2ba98c0 carried bearer/magicLink/emailOTP and nothing else. The
honest short-term fix then hid the gap: b96dd0f rewrote the privacy page to
stop claiming passkeys, which was correct (never overclaim) but left a product
regression looking like a settled decision. After any auth migration, diff the
OLD provider's enabled credential types against your new plugins: array — the
dashboard's feature list, not your memory of it.
Trap 8 — twoFactor() defaults to demanding a password almost nobody has
/two-factor/enable calls shouldRequirePassword
(node_modules/better-auth/dist/utils/password.mjs) and, by default, refuses to
enrol an account that cannot supply its password. On a deployment where accounts
arrive by Clerk import (password_enabled=false), social, magic link, email OTP,
or passkey — i.e. essentially all of them — 2FA is rendered visible in the UI and
permanently unreachable. Nothing errors at deploy time; the button simply never
works, for everyone.
twoFactor({
issuer: APP_DISPLAY_NAME,
allowPasswordless: true, // ← load-bearing, not a convenience
}),
This is not a weakening. The flag's other branch still demands the password whenever a credential account has one — it relaxes the check only where there is nothing to check. Assert both halves, or a future default-flip silently becomes an account-takeover:
enrol with NO password → 200, secret written
enrol with a WRONG password → 400, nothing written ← the half that matters
anonymous caller → 401
Also assert, against a real instance: the stored secret does not contain the
plaintext handed to the app (D1 is a separate blast radius from the Worker), and
minting a secret does not flip twoFactorEnabled (a mistyped setup must not
lock the user out of their own password sign-in).
Trap 9 — passkey({ origin }) left unset is attacker-controlled
Omit origin and the plugin falls back to the request's own Origin header — and
that value is handed straight to the verifier as expectedOrigin. Verified in the
installed source (@better-auth/passkey/dist/index.mjs, 1.6.26):
321: const origin = options?.origin || ctx.headers?.get("origin") || ""; // register
337: expectedOrigin: origin,
414: const origin = options?.origin || ctx.headers?.get("origin") || ""; // authenticate
436: expectedOrigin: origin,
So the check becomes "does the origin match the origin the caller sent" — which is not a check. Pin it to a literal allowlist:
export const PASSKEY_RP_ID = "improvebayarea.com";
export const PASSKEY_ORIGINS = [
"https://improvebayarea.com",
"https://www.improvebayarea.com", // registrable suffix matches the RP id
] as const;
passkey({ rpID: PASSKEY_RP_ID, rpName: APP_DISPLAY_NAME, origin: [...PASSKEY_ORIGINS] }),
Passkey origins are NOT trustedOrigins, and the difference is load-bearing.
WebAuthn requires a secure HTTPS origin whose registrable domain matches rpID.
capacitor://localhost belongs in trustedOrigins and must never appear here
— the browser rejects it anyway, so listing it only hides the real failure. Omitting
www. is the opposite mistake: a passkey registered there fails verification with an
opaque 400.
Probe the deployed config for free — a failed ceremony has no side effect:
curl -s -o /dev/null -w '%{http_code}\n' \
https://<site>/api/auth/passkey/generate-register-options
# 404 = plugin NOT loaded · 401 = loaded, demanding auth (correct)
That 404 → 401 transition is the cheapest proof a plugin actually shipped — far better than reading the diff. Capture the 404 before deploying so you have a before/after, and re-check after >30s (Worker propagation false-negatives an immediate curl).
🛑 The probe's HTTP method is PER-ROUTE — a GET-only sweep reports working plugins
as missing. Corrected 2026-08-05 after this exact false negative: GET /two-factor/enable returns 404, POST returns 401. Three live plugins on IBA
read as "not loaded" under a GET-only probe. Use the method the route actually
declares (betterauth health encodes the per-route table), and treat a 404 from the
wrong verb as no information, not evidence of absence.
Trap 10 — Enabling 2FA makes a CORRECT password look like a broken session
Once 2FA is on, /sign-in/email answers 200 with no session and a
twoFactorRedirect payload; better-auth deletes the half-made session
(.../plugins/two-factor/index.mjs:266). Client code that infers "signed in" from
res.ok therefore tells a user who typed the right password that the session
did not stick — an opaque failure on the happy path. Handle twoFactorRedirect
explicitly, and surface backup codes from the challenge itself, since that is the
only screen a locked-out user can still reach.
Scope it honestly in product copy. better-auth challenges only
/sign-in/email (.../two-factor/index.mjs:222). Magic link, email code, Apple,
Google and passkey sign-in are not second-factored. Say the code guards the
password, not the account — and lock the phrasing with a test so a later
copy-edit cannot widen the claim.
Trap 11 — Plugin tables: transcribe the DDL from installed source, and CASCADE
A plugin's storage is your migration to write. Getting a column wrong does not
fail loudly: the adapter throws inside the handler, better-auth returns a generic
500, and no such column appears only in wrangler tail under logs[] — never
exceptions[]. Transcribe from the installed package (Trap: the two sites are on
different versions):
| Table | Source of truth |
|---|---|
passkey |
node_modules/@better-auth/passkey/dist/index.mjs (#region src/schema.ts) |
twoFactor |
node_modules/better-auth/dist/plugins/two-factor/schema.mjs |
user.twoFactorEnabled |
same twoFactor schema, user.fields block |
Type mapping (established empirically against better-auth driving SQLite):
string→text, number→integer, boolean→integer (0/1, never a boolean
literal), date→date (ISO-8601 TEXT at rest).
ON DELETE CASCADE is required, not decorative. internalAdapter.deleteUser
(better-auth/dist/db/internal-adapter.mjs:164-175) deletes only session/account/
user rows and knows nothing about plugin tables. Without it, "delete my account"
leaves a live credentialID behind — breaking the erasure your privacy page
promises, and 500ing /passkey/verify-authentication when it matches a row whose
owner is gone.
Put the one non-idempotent statement LAST. SQLite has no
ADD COLUMN IF NOT EXISTS, so ALTER TABLE "user" ADD COLUMN "twoFactorEnabled"
aborts the batch on a re-run (duplicate column name — harmless, but it stops
everything after it). Everything above it is IF NOT EXISTS, so a first run has
already created every table and index before this can fail.
Tests must read migrations/ — never a hand-copied CREATE TABLE. A second
source of truth in the test file had already drifted a column on IBA, which is how
this change first surfaced as table user has no column named twoFactorEnabled.
Trap 12 — "single-tenant" is a comment, not a control: enabled: true ships an OPEN SIGNUP route, and authenticating is not authorizing
Found 2026-08-30 building agentprofile. It was a HIGH-severity unauthenticated
full-owner compromise, and the code carried a comment asserting the opposite.
Two independent halves. Either one alone is the whole vulnerability.
Half 1 — emailAndPassword: { enabled: true } publishes POST /sign-up/email.
The only gate in the installed package is:
// node_modules/better-auth/dist/api/routes/sign-up.mjs
if (!ctx.context.options.emailAndPassword?.enabled ||
ctx.context.options.emailAndPassword?.disableSignUp) throw APIError.from("BAD_REQUEST", ...)
So enabled: true without disableSignUp: true is a live, public account-creation
endpoint. And it hands back a usable session immediately — createSession then
setSessionCookie then a body containing token — because autoSignIn defaults on and
requireEmailVerification is unset. With the bearer() plugin that token also works as
an Authorization header.
trustedOrigins does NOT save you. The CSRF middleware short-circuits:
if (headers.has("cookie")) return await validateOrigin(ctx) — a plain curl sends no
cookie — and it then only validates when a Sec-Fetch-*, origin, or referer header is
present. A bare curl supplies none of those, so the function returns having validated
nothing and trustedOrigins is never consulted.
Half 2 — a middleware that only checks session !== null authenticates without
authorizing. This shape is the bug:
const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
if (session === null) return jsonError(401, ...);
c.set('owner', { userId: session.user.id }); // <-- ANY valid session is now "the owner"
There is no comparison against a designated owner. On a single-tenant app that means the second account created — by anyone — has full owner authority: read every stored record, mint API tokens with every scope, and approve its own pending approvals, which defeats any human-in-the-loop gate you built on top of it.
The probe (three greps, ~20 seconds)
grep -rn 'disableSignUp' src/ # ZERO hits + enabled:true => signup is LIVE
grep -rn 'session\.user\.id' src/ # only an assignment, no comparison => no owner
grep -rniE 'owner_?user_?id|ownerUserId' src/ wrangler.jsonc # ZERO => no owner is configured
The fix — apply BOTH
emailAndPassword: { enabled: true, disableSignUp: true },
then seed the owner out of band (a one-shot CLI insert of user + account with a hashed
password), and make the middleware authorize:
if (session === null || session.user.id !== c.get('config').ownerUserId) {
return jsonError(401, 'unauthenticated', 'Owner session required.');
}
Also narrow the mount — app.on([...], ${AUTH_BASE_PATH}/*, ...) republishes every route
any future plugin adds. Allowlist /sign-in/email, /sign-out, /get-session.
Arm the tests by deleting the guard
POST /api/auth/sign-up/email must return 4xx — delete disableSignUp and it must go red.
A session for a non-owner id must be rejected on every protected route — delete the
ownerUserId comparison and it must go red.
The meta-lesson
The source comment said "Single-tenant by construction … There is no public signup route to leave open," the README said "There is no signup route and no second user. That is the product, not a gap," and the middleware docblock called itself "the one place where 'is this the owner' is decided." All three were false, and each read as verification while verifying nothing. A comment asserting an invariant is not an invariant. If a property matters, there is a test that fails when it stops holding — otherwise it is a wish.
Passkeys in a Capacitor shell: usually NO new build (contrast Traps 3–4)
Native social needs a compiled-in URL scheme, so it always needs a new archive.
Passkeys usually do not — and the distinction is the shell's server.url:
- Remote-URL wrap (
server.url: "https://<site>"): the webview's WebAuthn origin is the real HTTPS origin, so the whole ceremony is site-side and reaches installed binaries on a Worker deploy. - The only native prerequisite is the Associated Domains entitlement
(
webcredentials:<domain>) plus the AASA file. Both are relying-party based, so they survive an auth-provider migration untouched — Clerk-era bindings work for Better Auth with no change.
Verify the binding before debugging any JS ceremony (30 seconds, catches the "registration was cancelled or timed out" class):
curl -s https://<site>/.well-known/apple-app-site-association | python3 -m json.tool | grep -A3 webcredentials
grep -A3 associated-domains mobile/ios/App/App/App.entitlements
These are THREE different things — name which you verified. Entitlement present in the repo ≠ present in the signed build ≠ the capability enabled on the bundle id.
🛑 **The portal capability IS API-checkable — do not claim it is bro
…(truncated)