Paste your deployment name or its URL and every command on this page fills itself in. Both .convex.cloud and .convex.site work — auth is served from .convex.site, which is the one that’s easy to get wrong.

Architecture

The backend

  • convex/auth.ts — the whole Better Auth configuration: providers, plugins, trusted origins, rate limiting, account linking. createAuthOptions is split out from createAuth because the component directory needs the options without env-var access.
  • convex/betterAuth/ — a local install of the Better Auth component. The packaged component ships a fixed schema with no passkey, wallet or API-key tables, so this repo owns the schema instead.
  • convex/lib/ — the plugins that aren't in Better Auth: Sign In With Solana, Mullvad-style account numbers, the shared demo account and its lockdown, linking.ts (adding a password to an account that arrived without one), and status.ts (which credentials are set, so the card can say "needs setup"). Plus apple.ts, which mints Apple's client secret, and notify.ts, which sends via Resend / Mobile Message or falls back to logging.
  • convex/http.ts — the two files a third party fetches (Apple's domain association, the WebAuthn related-origins list), plus /apps/register and /apps/unregister.
  • convex/apps.ts + convex/lib/apps.ts — the app registry: who's allowed in, from which origins, using which methods.
  • convex/lib/methods.ts — the one path→method map, shared by lastLoginMethod and the per-app allow-list so they can't disagree.

The frontend

  • src/routes/ — the site. / and /docs/* prerender to static HTML; /sign-in and /account are client-only, because the session is a cookie jar in localStorage and there is nothing a server render could know about it.
  • src/auth/providers.ts — display metadata per method; src/auth/panels.tsx — the matching behaviour; src/auth/methods.ts wires the two together by id.
  • src/auth/AuthProvider.tsx — Convex and Better Auth, mounted only by the two routes that sign someone in, so the landing page and the docs don't download the auth client.
  • src/account/Account.tsx — signed-in view: profile, passkeys and agent API keys; src/account/SignInMethods.tsx — linking extra credentials onto the account you're already signed in as.
  • src/lib/rememberedAccounts.ts — the returning-account chooser.

Tests

Two projects.

unit is plain node and covers the logic where a bug is a security bug and there's no UI to notice it through: Solana signature verification, the demo lockdown's path matcher, account-number generation, the registration secret and body validation, the path→method map, and the WebAuthn site-limit arithmetic.

component renders the sign-in card and account page in a real browser against mocked endpoints.

The component tests render the same *.fixture.tsx files the explorer shows, through the same src/uaight.preview.tsx, so a state you can look at is a state that's covered.

The explorer is uaight, a Vite plugin rather than a second tool: it mounts at /uaight on the app's own dev server, so bun dev is the whole toolchain and there is no second process, no second port and no second Vite config to drift out of sync. A fixture resolves modules exactly as the app does because it is the app's server doing the resolving.

A fixture is a component in a named state; src/testing/MockApi.tsx gives it the endpoints and localStorage it needs. Anything worth asserting about goes in a colocated *.test.tsx that imports the fixture.

src/uaight.preview.tsx is where the providers and the stylesheet live. uaight runs it inside the preview frame's realm — the only place a fixture's CSS and React context can land — and the component tests import it directly.

It also imports src/lib/auth, which is what configures the package's authClient live binding. Components import that binding directly, so without the side-effect import every card fixture throws "AussieAuth has no client yet" before it draws anything.

bun run uaight      # a deployable static explorer, to dist-uaight/

Fixtures that mock the network need a served frame

MockApi intercepts with MSW's service worker, and a service worker only registers in a document loaded from a real http(s) URL. uaight through 0.0.1-canary.1 wrote its preview frame into about:blank, which has no URL, so worker.start() rejected and the four SignInCard fixtures carrying handlers rendered nothing at all.

Fixed upstream in canary.2, which serves the frame's document at /@uaight/preview in dev and as preview.html in a static build. MockApi throws with the reason rather than rendering nothing, because the earlier behaviour was a blank frame whose only evidence was a console line.

bun run uaight needs canary.2 for a second reason: the static build used to run this project's TanStack Start plugins, and Start's manifest plugin counts entries — it saw the explorer's document and the renderer chunk and died on multiple entries detected. uaight now leaves a meta-framework's plugins out of that build and prints which ones, since the explorer is a different application from the site.

The component tests were never affected: vitest browser mode serves them from a real URL.

Linting and formatting

One tool each, both Rust:

bun run lint      # tsc --noEmit && oxlint --type-aware
bun run format    # oxfmt .

--type-aware runs the rules that need a type checker — no-floating-promises is the one that earns its keep here, since a dropped auth promise fails silently. It goes through oxlint-tsgolint, which requires TypeScript 7; that's why the typescript dependency is pinned to ~7.0.2 rather than left to float.

.oxlintrc.json carries a note against every disabled rule. Most of them are off because they disagree with Convex's own conventions — _id is not a dangling underscore anyone chose, and the sequential awaits in convex/apps.ts are sequential on purpose.

Coming back to a remembered account

The sign-in card lists accounts this browser has used before and gets you back into one without a prompt.

crossDomainClient can't be handed a session cookie on a .convex.site response, so it keeps the whole cookie jar as JSON in localStorage. Signing in copies that jar into aussieauth.accounts alongside your name and avatar; clicking the account puts it back and asks the server whether it still holds.

If it does you're in with no round trip to any provider. If it's expired we re-run the method lastLoginMethod recorded — a silent redirect for a social provider, or the right panel with your address already filled in.

Which is why Sign out doesn't revoke: it clears the local jar and leaves the session alive, so the account stays one click away. Sign out everywhere is the real thing, and so is the ✕ next to a remembered account.

A gotcha worth knowing

frosted-ui's Button wraps Base UI, which defaults native buttons to type="button". Inside a <form> you must pass type="submit" explicitly or onSubmit never fires.

Questions

Why is the Better Auth component installed locally rather than as a package? The packaged component ships a fixed schema with no passkey, wallet or API-key tables. Owning convex/betterAuth/ is what makes those methods possible.

Why doesn't signing out revoke the session? So the account stays one click away on the next visit. "Sign out everywhere" revokes; so does removing a remembered account.

Where do I add a new sign-in method? Three places: register the Better Auth plugin in convex/auth.ts, add a row to src/auth/providers.ts, and add a panel to src/auth/panels.tsx. If it should be blocked for the demo account, add it to LOCKED in convex/lib/demo.ts too.

Read this page as markdown: /docs/architecture.md