Better Auth
Better Auth is zudo-text's identity and session authority. It is mounted inside the sync-server Worker at /; the desktop app and web editor use the same authority and ultimately receive the same RS256 service JWT.
The production web surfaces are:
Web editor: https:
/ / editor. zudo- text. app/ Mock preview: https:
/ / zudo- text- preview. pages. dev/ Developer docs: https:
/ / doc. zudo- text. app/ End-user manual: https:
/ / manual. zudo- text. app/
The former custom domains https: and https: are retired. They are not sign-in return targets, CORS origins, or deployment destinations.
Two credentials, two jobs
Better Auth deliberately separates the long-lived login from the credential accepted by application services:
| Credential | Lifetime and storage | Purpose |
|---|---|---|
| Better Auth session | 7-day sliding session; its opaque session token is persisted per client | The refresh credential. It validates the signed-in user and mints fresh service JWTs. |
| Service JWT | 15 minutes; cached in memory (and in browser sessionStorage) | Bearer credential for sync, publish, notifications, AI, and other protected APIs. |
There is no OAuth refresh-token grant. When a service JWT is near expiry, the client uses its still-live Better Auth session to call GET /. A 401 triggers one forced re-mint. If the session has expired or was revoked, the client clears local auth state and returns to the sign-in screen.
Signing out calls POST / and revokes the server session. An already-minted service JWT can remain valid until its 15-minute expiry; that is the accepted revocation-latency bound.
Desktop: per-app deep-link OTT handoff
Better Auth has no hosted sign-in UI, and the desktop WebView must not receive the browser's cookie directly. The sync server therefore hosts a small handoff page and transfers a single-use one-time token (OTT):
The app generates a random
client_statenonce and resolves its own URL scheme:zudotextfor ROOT orzudotext-<app-name>for a LEAF.It opens the system browser at
/.auth/ desktop- handoff? client_ state= <nonce>& scheme= <scheme> The handoff page signs in — or signs up, or signs in with a configured social provider — on the sync-server origin. A live session cookie skips the form entirely. See Sign-up and the disable-signup kill-switch and the configured social providers (Google sign-in and X sign-in) below for what the form can offer.
The page calls
GET /and redirects toapi/ auth/ one- time- token/ generate <scheme>:./ / auth/ callback? ott= <token>& state= <nonce> The app rejects a callback for another app's scheme or a mismatched state, then posts the approximately three-minute, single-use OTT to
/.api/ auth/ one- time- token/ verify The verified response supplies the Better Auth session token. The app persists that session, calls
/, and arms every service client with the resulting JWT.api/ auth/ token
The custom scheme is not an OAuth redirect URI. Only the short-lived OTT and CSRF state cross from the browser to the app, and replaying an OTT fails.
Browser: allowlisted HTTPS handoff
The web editor uses the same handoff page without a custom URL scheme:
The editor stores a one-shot state nonce in
sessionStorageand redirects to/with an exactauth/ web- handoff return_totarget.The sync-server page establishes its same-origin Better Auth cookie, mints an OTT, and returns to an allowlisted
/URL.callback The editor immediately scrubs the OTT from browser history, validates and consumes the state, verifies the OTT, persists the seven-day session token, and mints a 15-minute service JWT.
Production accepts both https: (canonical) and https:; local REST/web development also accepts the explicit localhost:1422 and localhost:1423 callback URLs. Origin and path are matched exactly, so the handoff is not an open redirect.
Sign-up and the disable-signup kill-switch
Self-service email/password sign-up is enabled by default. This reverses an earlier decision (D12) that locked public account creation once the owner and permanent smoke identities were provisioned; the auth overhaul reopened it as an explicit product decision, made safe by the auto-provisioning hooks below — a subject that signs itself up gets its application-side user row in the same request, so it never reaches / as an unmapped subject.
BETTER_AUTH_DISABLE_SIGNUP="true" restores the old lock without a code change. The check is a strict string compare against "true"; an unset, empty, or misspelled value leaves sign-up open rather than silently closing it. One flag threads into two places, because Better Auth gates email and social sign-up separately:
emailAndPassword.disableSignUp— gatesPOST /only.api/ auth/ sign- up/ email socialProviders.<provider>.disableSignUp— gates each social callback's handling of an unknown email. Better Auth reads this from the provider's own options object, not fromemailAndPassword, so omitting it would leave that provider's sign-up open even with the email kill-switch flipped on.
Sign-in (email/password and every configured social provider) is never gated by this flag — only account creation is.
Auto-provisioning: idempotent user creation
zudo-text's plural users table — the one authoritative for workspace ownership, subscriptions, PATs, and admin allowlists — is kept in sync with Better Auth's own user table through two database hooks in workers/, both funnelling into the same idempotent helper, ensureBetterAuthUser (INSERT ... ON CONFLICT(sub) DO
NOTHING, keyed on the users.sub unique index):
databaseHooks.user.create.after— the provisioning path proper. A brand-new Better Auth account gets itsusersmapping row immediately, seeded with the same 30-day pro trial that dev-login and owner provisioning use.databaseHooks.session.create.after— a self-heal. Any subject missing itsusersrow — the create hook having failed, or an account that predates this epic — is repaired on its next sign-in instead of staying 401'd forever.
Both hooks are log-and-continue, never throw. Better Auth commits the user row before running create.after (the hook is queued and awaited after the transaction has already returned), so a rejecting hook cannot roll the signup back — it would only turn a completed account creation into a 500 for an email that can then never be re-registered. A failure is logged and left for the session hook to repair.
This is a second, purpose-built helper alongside the older provisionBetterAuthUser (workers/), which stays a bare, non-idempotent INSERT that throws on a duplicate sub. That strictness is deliberate: provisionBetterAuthUser is called only deliberately — owner-repoint tooling, permanent smoke service accounts — and a caller that provisions an already-mapped subject there has a bug worth surfacing. It is never called from a hook, and ensureBetterAuthUser is never used for deliberate one-off provisioning. Token verification itself stays strictly find-only on both paths — resolveTokenToUser never inserts a row; see Identity convention and user mapping below.
Email collision: the repoint policy
users.email carries its own UNIQUE constraint (0001_init.sql) that the ON CONFLICT(sub) target above does not cover. When a Better Auth subject's email is already held by a different row — realistically a dev|<email> dev-login row — the INSERT in ensureBetterAuthUser throws instead of being absorbed by the conflict clause.
Left unresolved (the original defect, epic #5111, superseding #5095), this was a silent, permanent lockout: mirrorSubjectIntoUsers never rethrows (Better Auth has already committed the account row when create.after runs, so a rejecting hook can't undo the signup, only 500 an account that can never re-register its email), so the login itself succeeded but no users row was ever written — every / call then 401'd fail-closed, forever, with only a log line as evidence.
Policy: EMAIL IS THE ACCOUNT IDENTITY. On collision, ensureBetterAuthUser repoints the existing row — it rewrites that row's sub to the new Better Auth subject rather than leaving the two identities unresolved. id is never touched. This is safe specifically because id, not sub, is what every other table references: all seven foreign keys declare REFERENCES
users(id), and hasActiveSub (src/) reads WHERE id = ?. A repoint therefore cannot orphan a workspace or drop an entitlement — it silently transfers an existing account (including its subscription state and history) onto the new sign-in.
That transfer is gated: only a dev-login row may be taken over. The holder must carry a dev|<email> sub. Every other collision is refused — a better_auth|<id> row, an unprefixed legacy row, and a row whose sub is NULL (no sub is no evidence of ownership, so a null holder does not qualify). The gate reads isDevLoginSub / DEV_LOGIN_SUB_PREFIX (src/), the same constant dev-login stamps onto the rows it mints, so the two spellings cannot drift apart.
#5095 raised two alternatives, both rejected: surfacing the conflict to the user (needs a new error channel out of a hook that is contractually never-throw), and accept-and-document (leaves the lockout silent and permanent — the same failure mode this epic exists to close).
Why the gate: the trigger is an unverified address
Self-service signup is on by default and requireEmailVerification is deliberately unset (see the emailAndPassword block in src/) — an unverified user must still be able to sign in, or every account created while RESEND_API_KEY is absent would be locked out. So user.create.after, and with it the repoint, fires the moment someone submits an email/password pair, before any verification link is clicked.
Ungated, that made the repoint an account-takeover primitive: anyone who knew the address on a pre-existing users row would claim that row's id by signing up on it, and with it the row's workspaces, files, assets and subscription. Better Auth's own accountLinking.requireLocalEmailVerified guard below refuses exactly this shape of pre-account hijack at the provider layer; an ungated repoint reintroduced it one layer down, at users.
The approving rationale for the repoint was "pre-release, the only colliding rows are owner-controlled dev-login rows" — nothing in the code made that true. The gate makes it true by construction: dev-login itself is fail-closed behind the DEV_LOGIN_KEY secret, so a dev|<email> row can only exist because the owner minted it.
A non-dev collision fails closed exactly as it did before epic #5111. The refusal is not a new error path — it falls through to the same throw an unresolvable collision always took: the held insert error is rethrown, mirrorSubjectIntoUsers logs it and continues, the signup still returns 200, and the new subject is left with no users row, so every / call 401s. That is the original lockout, and for a non-dev holder it is the correct outcome — the alternative is handing one identity another's account.
Gating on a verified email instead, and gating on either condition, were both considered and rejected: verification is not required to sign in, so a verified-email gate would still admit anyone whose deploy has no mail configured, and an either-gate is only as strong as its weaker half.
Pre-release decision — revisit before first release
What the gate does not remove: repointing a dev-login row still silently transfers an account between identities, with no user confirmation and no visible warning. That's an acceptable trade before first release, because adev|<email> row exists only where the owner ran dev-login (itself fail-closed behind the DEV_LOGIN_KEY secret) — see Pre-Release: No Backward Compatibility in the repo root CLAUDE.md.
Before real users exist, decide whether the repoint survives at all. Its entire justification is migrating the owner's own pre-Better-Auth rows; once that migration is done the safest form of this code is no repoint, and the gate is already the seam to delete it at.
Concurrency: conditional update, not blind overwrite
users.sub is unique (idx_users_sub), so two concurrent requests resolving the same email collision race to repoint one row. ensureBetterAuthUser runs a bounded read-check-write loop (MAX_REPOINT_ATTEMPTS = 3 — one retry covers the realistic loss, where a signup's user.create.after and session.create.after hooks resolve the same subject at once; the bound only exists so a pathological repoint war terminates instead of spinning inside a request):
Look up
subdirectly — already mapped, return it.A
NULLemail can't have collided at all (SQLite treats distinctNULLs as non-colliding under a UNIQUE index), so there is no holder row to find. An empty string does collide —''is an ordinary value under the index — but it is not an identity either, so it is refused the same way rather than repointed.Find the row currently holding the email. None → the original insert error surfaces as-is (not a collision this loop can resolve). Already ours → a concurrent write repointed it onto this very subject, so that row is the mapping; return it rather than issuing a no-op
UPDATE.The gate. Holder
subnot adev|sub (includingNULL) → log the refusal and leave the loop; the original insert error is rethrown.UPDATE users SET sub = ? WHERE id = ? AND sub = ?, conditioned on thesubvalue observed one statement earlier. Plain=suffices because step 4 has already established the holder'ssubis a non-NULLstring.Zero rows changed means another request repointed the row first — loop back to step 1 instead of proceeding, since the winner may already have mapped this very subject.
Exhausting all three attempts re-reads sub one final time before throwing: every pass having lost its race means a competing writer may have mapped this subject after the last read, and reporting a mirror failure for a row that is in fact correctly mapped would put a misleading error into the very log this policy exists to keep honest.
The WHERE ... AND sub = ? guard is load-bearing, not defensive polish. An unconditional UPDATE users SET sub = ? WHERE id = ? would let two concurrent repoints of the same email both fire; the loser would violate idx_users_sub, throw, and get swallowed by mirrorSubjectIntoUsers's never-rethrow contract — reproducing the exact silent lockout this policy exists to fix.
Observability
The original defect's whole failure mode was silence, so every repoint emits a structured, informational (not error-level) log line naming the row's id and the new sub, so a repoint is greppable after the fact:
log.info("repointed users row onto new Better Auth subject", {
id: holder.id,
oldSubKind: "dev-login",
newSub: input.sub,
});The old sub is logged as a kind rather than verbatim: the gate guarantees it is dev|<email>, so emitting it would put the address into the log. id is the durable handle to grep on — a repoint never moves it.
A refused repoint is warn-level and separately greppable — a blocked account takeover should not be indistinguishable from the generic mirror failure that follows it:
log.warn("refused to repoint users row: holder is not a dev-login row", {
id: holder.id,
holderSub: holder.sub,
newSub: input.sub,
});The email address is deliberately kept out of both — including out of the subs they name — matching this worker's other log calls (e.g. mirrorSubjectIntoUsers's failure log in Auto-provisioning above), none of which log PII. holderSub in the refusal is safe verbatim precisely because the gate has just established it is not a dev|<email> sub.
The dispossessed identity: dev-login does not get its sub back
A repoint steals the row from whichever identity held it. dev-login's own backfill (src/, in the dev-login handler) only writes sub when the existing row's sub IS NULL:
if (existing.sub === null) {
await db
.prepare("UPDATE users SET sub = ?, updated_at = datetime('now') WHERE id = ?")
.bind(devSub, userId)
.run();
}So once a dev|<email> row has been repointed onto a better_auth|<id> subject, a later dev-login for that same email does not restore dev|<email> — the row's sub is no longer NULL, so the backfill guard skips it. dev-login still resolves to the same account, though: it looks the row up by email, and the token it mints is HS256 with the userId embedded directly, so resolveTokenToUser never needs to look sub up for that path. Only the dev|<email> subject string itself is gone for good.
Identity convention and user mapping
The JWT plugin creates the subject server-side as:
better_auth|<Better Auth user.id>Every service treats the full string as the canonical external identity. The Better Auth user table and zudo-text's existing users table are separate: the latter remains authoritative for workspace ownership, subscriptions, PATs, and developer/admin allowlists. Token verification (resolveTokenToUser / findBetterAuthUser) looks up the exact better_auth|<id> mapping and stays strictly find-only — it never inserts, and fails closed (401) when the mapping is absent.
Signing up at the identity layer does provision application access now: the databaseHooks in Auto-provisioning above create the users mapping row in the same request a Better Auth account is created, so a freshly signed-up subject is never left unmapped. The find-only rule on the verification path itself is unchanged — provisioning happens only through the hooks (or the deliberate tooling above), never as a side effect of presenting a token.
Password reset and email verification
Both flows are dark-shipped on RESEND_API_KEY: with no key set, sends are skipped and logged rather than attempted, so a deploy without the secret stays healthy instead of erroring, and the handoff page's forgot-password entry point and the linking-conflict landing's resend-verification button are both omitted from the markup entirely rather than shown-and-broken.
sendAuthEmail (workers/) is the single delivery choke point for both flows and never throws — the anti- enumeration contract depends on it. Better Auth's POST / answers the same neutral 2xx whether or not the address has an account; if a delivery failure propagated as a thrown error it would turn "Resend is down" into a 500 that occurs only for addresses that actually exist, handing an attacker the account-existence oracle the neutral response exists to deny. Delivery is also capped at a 10-second timeout, because Better Auth awaits the send inline (no background- task handler is configured on Workers) — a hanging Resend call would otherwise hang the response.
Password reset:
sendResetPasswordemails theurlBetter Auth builds internally, which points at the library's ownGET /. That route validates the token and only then redirects toapi/ auth/ reset- password/ : token RESET_PASSWORD_PATH(/) with it — emailing a hand-built link to that page directly would skip the validation.auth/ reset- password revokeSessionsOnPasswordReset: true— every other session for the account is revoked on reset, not just the library default (false). A reset is often needed because someone else has the old password, so leaving their session alive would defeat the point; the cost is one extra re-sign-in on the account owner's own other devices.The reset flow is also how a social-only account gets a password: the installed better-auth (1.6.25) creates the
credentialaccount when the user has none, rather than requiring one to already exist (/looks the user up by email alone, with no credential- account requirement). No separate "set a password" flow exists or is needed — the handoff page's forgot-password link says as much when a social provider is also configured on the deploy.reset- password
Email verification:
emailVerification.sendOnSignUp: truesends a verification link on every new sign-up.requireEmailVerificationis deliberately not set. Verification exists to make an account linkable, not to gate sign-in — Better Auth's account linking requires the local account'semailVerified(requireLocalEmailVerifieddefaults totrue), so this is the prerequisite for social account linking, not a login requirement. Requiring it here would lock out every account created whileRESEND_API_KEYis absent, since no mail is ever sent then.Both the reset and verification links expire in Better Auth's 1-hour default (
resetPasswordTokenExpiresIn/emailVerification.expiresIn); the email copy quotes that figure, so change both together if it ever changes.Migration
0020_email_verified_backfill.sqlis a one-time, idempotentUPDATE user SET email_verified = 1with noWHEREclause, backfilling every account that existed before verification was switched on (accounts created earlier would otherwise be verified never, since nothing re-sends them a link). Safe only pre-release, when the only existing rows are the owner's account and the permanent smoke identities, all on addresses the owner controls.
Google sign-in
Google is an optional identity provider, dark-shipped as a pair: GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must both be set for socialProviders.google to be included. If either value is missing, Google continues to answer PROVIDER_NOT_FOUND while any other configured provider still works; the handoff page omits the "Continue with Google" button, its markup, and its script entirely rather than showing a disabled control.
No redirectURI is configured — baseURL stays unset (see Client configuration), so the OAuth callback is derived from the request's own origin as <worker origin>/. Both the *.workers.dev origin and the future custom domain are registered on the one Google OAuth client; see the custom domain and email ops runbook for the console-side setup and the cutover checklist.
The round trip is server-constructed, not client-constructed. The handoff page is not a step Google's flow passes through — it is where the flow starts and where it has to land again, and its own query (client_state, scheme, return_to) has to survive that round trip. social-handoff.ts computes both the success and error callback URLs as the page's own relative path+query, re-serialized through URLSearchParams exactly once. That re-serialization is load-bearing, not tidiness: Better Auth validates callbackURL/ errorCallbackURL as relative paths with a regex whose query character class excludes :, so a raw ? straight out of the address bar is rejected as an untrusted callback URL, while the re-encoded %3A%2F%2F form passes.
Account linking uses account.accountLinking.trustedProviders: ["google",
"apple"] (Apple sign-in itself is still out of scope — paid membership and a public HTTPS callback are required — but is pre-listed as trusted). Two linking paths exist, with different requirements:
Implicit linking — a Google sign-in whose email matches an existing local (email/password) account merges into it automatically, but only when
requireLocalEmailVerifiedis satisfied — i.e. only when the local account's own email is already verified.Explicit linking — the "Connect Google account" button, offered on the handoff page after a successful password sign-in on the same landing. It calls
POST /against the just-established session cookie, which checks only the trusted provider and a matching address, not the localapi/ auth/ link- social emailVerifiedflag that gates implicit linking. Signing in with the password and connecting from the handoff page therefore resolves an unverified-email conflict immediately, with no email round trip at all.
Never set requireLocalEmailVerified: false
requireLocalEmailVerified is deliberately left at the library default (true). Setting it to false reopens pre-account hijacking: an attacker registers a password account on the victim's email address, and the victim's later Google or X sign-in silently lands inside the attacker's account.
X (Twitter) sign-in
X is an optional social provider, dark-shipped as a credential pair: TWITTER_CLIENT_ID and TWITTER_CLIENT_SECRET must both be present before socialProviders.twitter is built. With either value missing, the deploy has no X provider, the handoff page omits its button, and / continues to behave as it did before X was configured. The Better Auth and OAuth wire id is twitter; user-facing copy calls the provider X.
The email gate is intentional
In the X Developer Portal, open User authentication settings → App permissions and enable Request email from users. The portal's exact labels may move, but this permission is not optional: the OAuth request uses the users.email scope, which X documents as “Email from an authenticated user” in its OAuth 2.0 scope reference. Without the portal permission, X does not provide the confirmed_email value that this integration needs.
The X user object exposes confirmed_email as the authenticated user's confirmed address (X's data dictionary). The provider mapping in workers/ passes that value through as the Better Auth email and marks it verified only when it is present. If an X account has never verified its own X email, confirmed_email is null here; Better Auth then refuses the sign-in with error=email_not_found. The handoff page renders the static guidance to confirm the email on X and retry, and no Better Auth or application account is created.
This is a design decision, not a defect to debug: every accepted X identity must carry a real verified address. Do not “fix” an email_not_found log by falling back to an X handle or by weakening the gate. The alternatives that were rejected are recorded in epic #5362.
Callback, project, and billing prerequisites
No redirectURI or baseURL is configured. The request-derived origin makes the callback exact for whichever host received the authorization request: <worker origin>/. Register each production origin in the X Developer Portal, including both:
https://zudo-sync-server.takazudo.workers.dev/api/auth/callback/twitter
https://sync.zudo-text.app/api/auth/callback/twitterX requires exact callback matching (including whether a trailing slash is present), allows at most 10 callback URLs per app, and requires HTTPS in production. For local wrangler development register exactly:
http://127.0.0.1:8787/api/auth/callback/twitterBrowse wrangler dev at http:, not localhost. X's current app configuration guidance specifies 127.0.0.1 for local development. Because baseURL is unset and the callback is derived from the incoming request, using that host makes the redirect URI match automatically; no application configuration change is needed.
Attach the app to a Project before testing /. X's current error reference says a forbidden request can mean that an app is not enrolled or lacks the required access. In practice, a newly-created app may return 403
client-not-enrolled from /; the exact error text and the claim that X support must clear it are not specified in the official docs I could verify, so treat this as an operational portal caveat rather than a guaranteed API diagnosis. Confirm Project enrollment first, budget time for the portal state to settle, and contact X support if the enrollment remains stuck.
The current official X API pricing page describes pay-per-use credits and lists User: Read — $0.010 per resource. This sign-in path makes two / reads, so two one-resource responses would be a nominal $0.020 per sign-in; verify actual usage and credits in the Developer Console because X says prices can change and billing is per resource. I could not confirm the more specific claim that the Free tier closed to new signups in February 2026 from the current official pricing page; treat that date as unconfirmed and do not make the rollout depend on a free allowance.
Scopes and account linking
The provider sets disableDefaultScope: true and requests exactly:
["users.read", "tweet.read", "users.email"]users.email supplies the confirmed address; the other requested scopes are the settled X OAuth contract for this provider. offline.access is omitted on purpose. X documents that scope as the one that issues a refresh token; this application never refreshes X credentials, because its own Better Auth session is the long-lived login. Better Auth would persist provider access/refresh tokens in the account table, and this instance leaves OAuth-token encryption disabled, so requesting an unused refresh token would store a long-lived credential without a use case.
X is deliberately absent from account.accountLinking.trustedProviders, which remains ['google', 'apple']. The confirmed-email gate guarantees emailVerified: true for every X identity that reaches account linking, so implicit linking behaves like the Google path without weakening the provider-trust posture. In other words, the trust list is not a missing X configuration; it is a deliberate security choice in workers/.
iOS release gate
The iOS native sign-in plan records the local App Store prerequisite: Apple requires Sign in with Apple to ship in the same iOS release as any third-party social login used for the primary account. The current production configuration has no social provider enabled, so merging this X work does not by itself trigger the gate; enabling TWITTER_* in production does. Retrofitting Apple later is costly because Apple's sub is unrelated to X's, leaving X-first users with a second unlinked identity.
Account-linking UX
When implicit linking refuses to merge — the only reachable refusal given this deploy's configuration is requireLocalEmailVerified && !user.emailVerified — Better Auth's OAuth callback appends error=account_not_linked to the error redirect. The handoff page recognizes that specific code (paired with the attempted provider's own google_error or twitter_error marker, so a hand-typed error query param cannot fabricate the state) and renders a dedicated conflict section instead of a generic failure message:
With
RESEND_API_KEYconfigured: "This email already has a password account. Verify your email to enable social sign-in, or sign in with your password.", plus a Resend verification email button.Without a sender configured: the same message minus the verify option, since nothing would ever arrive — "This email already has a password account. Sign in with your password."
The handoff substitutes the attempted provider's label (Google or X) for “social” in that guidance.
Both routes out of the conflict are real: verifying by email (asynchronous, via POST /, itself neutral on every outcome — unknown, already-verified, and freshly-mailed addresses all answer the same 200) or signing in with the password and clicking Connect Google account or Connect X account (immediate, via the explicit-linking path above). The provider label in that copy follows the attempted flow. The address itself never appears in the redirect — Better Auth's error callback carries only a code — so the conflict section's actions read the email address back out of the sign-in form's own field.
Issuer, JWKS, and shared audience
Service JWTs use RS256. Minting and verification are pinned to three values:
BETTER_AUTH_ISSUER— exact logical issuer string; no normalization.BETTER_AUTH_JWKS_URL— the sync server's/endpoint.api/ auth/ jwks BETTER_AUTH_AUDIENCE—https:./ / sync. zudo. app
The current production triple is:
issuer = https://zudo-sync-server.takazudo.workers.dev
jwks_url = https://zudo-sync-server.takazudo.workers.dev/api/auth/jwks
audience = https://sync.zudo.appThe sync server owns the private signing key in its D1-backed jwks table and serves only public keys from /. Consumers validate the RS256 signature, exact issuer, exact audience, expiry, and subject. Partial verifier configuration fails closed.
Decision D9: one audience spans two services
Sync and publish both accept the shared audience https:. A service JWT minted through sync-server is therefore cryptographically valid at publish-server too. This is an intentional authorization-model change inherited from the Better Auth spike, not an implication of the hostname.
Each service must still enforce its own route-level authorization and resource ownership. The shared audience establishes identity across the service set; it does not grant access to another user's workspace or page.
Production verifier configuration must use the same issuer, audience, and JWKS URL in both deployments. Rotating the signing key is safe because verifiers cache the JWKS and refetch on an unknown kid.
Rate limiting
Better Auth's built-in rate limiter is otherwise a no-op on Cloudflare Workers: its default enabled guess reads NODE_ENV (unset in workerd), and its default memory storage is per-isolate, so nothing about it is actually shared across requests. sync-server's config sidesteps both problems — rateLimit: { enabled: true, storage: "database" } turns limiting on unconditionally and persists buckets in D1 (the rate_limit table, migration 0021_rate_limit.sql), so they survive across isolates and deploys.
Keys are derived from cf-connecting-ip (advanced.ipAddress.ipAddressHeaders) rather than x-forwarded-for — Cloudflare sets it at the edge from the real TCP connection, so it needs no trustedProxies allow-list to be trustworthy, unlike a client-appendable forwarded-for chain.
customRules (keyed on paths relative to /) override both Better Auth's built-in special-case rules (sign-in*/sign-up* default to 3 requests/10s; the password-reset/verification group defaults to 3/60s) and any plugin-registered rule:
| Endpoint | Window | Max |
|---|---|---|
/ | 1 hour | 5 |
/ | 1 minute | 10 |
/ (Google and X together) | 1 minute | 10 per IP |
/ | 1 hour | 3 |
/ | 1 hour | 3 |
/ is deliberately at the same cadence as / rather than the stricter sign-up default: BETTER_AUTH_DISABLE_SIGNUP gates sign-up only, but Google and X sign-in and sign-up share this one endpoint and one IP bucket. The concrete rule is in workers/. Every endpoint not listed here — one-time-token/*, /, /, /, and everything else the desktop flow calls repeatedly under normal use — falls through to Better Auth's untouched top-level default (10-second window, 100 max) rather than being tightened, which is what keeps this change from regressing the desktop flow's hot endpoints.
Client configuration
Desktop and web builds receive the authority origin through VITE_BETTER_AUTH_URL. It normally equals VITE_SYNC_SERVER_URL, because the Better Auth mount and handoff pages live inside sync-server. This is build-time bootstrap identity, not a workspace-synced setting: the client must reach the authority before it can authenticate and open the workspace.
For protected headless operations, pass either a current service JWT from GET / or a PAT in Authorization: Bearer …. Never send the opaque Better Auth session token to / or publish-server.
Server secrets and vars
All of the following live on the sync-server Worker (workers/ for [vars], wrangler secret put for secrets). BETTER_AUTH_SECRET/_ISSUER/_AUDIENCE/_JWKS_URL are the original required-config quartet (see Issuer, JWKS, and shared audience); the rest are additions from this epic, all optional and dark-shipped:
| Name | Kind | Required? | Effect when absent |
|---|---|---|---|
RESEND_API_KEY | secret | optional | Password reset and email verification sends are skipped and logged; the handoff page omits the forgot-password and resend-verification affordances entirely. |
AUTH_EMAIL_FROM | var | optional | Falls back to Resend's sandboxed onboarding@resend.dev sender, which delivers only to the Resend account owner's own address. |
GOOGLE_CLIENT_ID | var | optional (pair with secret below) | With either credential missing, socialProviders.google is omitted — Google sign-in behaves as if it never existed. |
GOOGLE_CLIENT_SECRET | secret | optional (pair with var above) | Same as above. |
TWITTER_CLIENT_ID | var | optional (pair with secret below) | With either credential missing, socialProviders.twitter is not configured — X sign-in behaves as if it never existed. |
TWITTER_CLIENT_SECRET | secret | optional (pair with var above) | Same as above. |
BETTER_AUTH_DISABLE_SIGNUP | var | optional | Absent, empty, or any value other than the exact string "true" leaves self-service sign-up open (the default). |
See the custom domain and email ops runbook for how to mint the Resend sending domain and the Google and X OAuth clients, and for the full cutover checklist when the production origin moves off *.workers.dev.