# AussieAuth — full documentation > AussieAuth is a self-hosted authentication server built on Convex and Better Auth. It offers sixteen sign-in methods — Google, Google One Tap, GitHub, Apple, Solana wallet, passkeys, email/password, phone/password, username/password, magic links, email OTP, SMS codes, a shared demo account, anonymous sessions, Mullvad-style account numbers, and API keys for agents. Unlike a hosted auth provider it has no consent screen of its own: an app that uses AussieAuth talks to the Convex deployment from its own origin, so a user signing in with Google sees Google's consent screen and nothing else. Generated from docs/*.md. Canonical HTML: https://aussieauth.com/docs --- Source: https://aussieauth.com/docs # What AussieAuth is AussieAuth is a self-hosted authentication server built on [Convex](https://convex.dev) and [Better Auth](https://better-auth.com). It offers fifteen sign-in methods and, unlike a hosted auth provider, it has no consent screen of its own. ## Why there's no second consent screen An app that uses AussieAuth talks to the Convex deployment straight from its own origin. There is no redirect to an AussieAuth-hosted page in the middle, so signing in with Google shows Google's consent screen and nothing else. This is the whole design. Hosted auth providers typically put you through two approvals — the identity provider's, then the auth provider's, on behalf of the app you were actually trying to use. AussieAuth removes the middle one by never being a page you visit. ## The fifteen methods | Method | What it is | | ------------------- | ---------------------------------------------------------- | | Google | OAuth via a Google account | | GitHub | OAuth via a GitHub account | | Apple | Sign in with Apple, including Hide My Email | | Solana Wallet | Sign a message with Phantom, Solflare or Backpack | | Passkey | Face ID, Touch ID or a security key | | Email & Password | The classic credential pair | | Phone & Password | A mobile number instead of an email address | | Username & Password | No email on file at all | | Magic Link | A one-click sign-in link, by email | | Email OTP | Six digits sent to an inbox | | SMS Code | Six digits by text, autofilled by iOS Passwords | | Demo Account | One shared sandbox account, no sign-up | | Anonymous | A throwaway session that can be upgraded later | | Account Number | Mullvad-style — the server generates a number, you keep it | | Agent Auth | A long-lived API key for an autonomous agent | Each is registered only when its credentials are present, so a deployment offers exactly the methods it's configured for. The sign-in card badges the rest as "needs setup" rather than letting them fail on click. ## Where to go next - [Quickstart](/docs/quickstart) — running locally in five commands - [Setting up Google](/docs/setup/google) and [Apple](/docs/setup/apple) — the two that need a third-party console - [Using it from another app](/docs/embedding) — the part that matters if you have more than one project - [Architecture](/docs/architecture) — what's in each directory and why ## Questions **Is AussieAuth a hosted service?** No. It's a Convex deployment you run yourself. There's no AussieAuth account, no tenant, and no per-user pricing — you own the database the sessions live in. **Do users see an AussieAuth branded screen?** Never, unless you send them to one. Apps embed the sign-in card at their own origin and talk to the deployment over HTTP. **What is it an alternative to?** Clerk, WorkOS AuthKit, Auth0 — and specifically to their redirect-to-our-domain model. The closest thing in spirit is running Better Auth yourself, which is roughly what this is, with the Convex-shaped parts filled in. **Does it work with a non-Convex frontend?** Yes. Nothing under `src/auth/` imports Convex; the card talks to the auth server over HTTP like any other client. The backend does need Convex. --- Source: https://aussieauth.com/docs/quickstart # Quickstart ```sh bun install bunx convex dev # creates the project, writes .env.local bunx convex env set BETTER_AUTH_SECRET "$(openssl rand -base64 32)" bunx convex env set SITE_URL https://aussieauth.localhost bun dev ``` `bun dev` runs vite through [portless](https://github.com/tobias-tengler/portless), which serves the app at `https://aussieauth.localhost`. ## SITE_URL is the deployed origin, not the local one `SITE_URL` points at the **deployed** origin, not the one you're developing on. It's the relying party id for passkeys and the base for emailed links, so it has to be the one real users are on. Local origins go in `TRUSTED_ORIGINS` instead. ## What works immediately That's enough for every method that doesn't need a third party: email/password, username, passkeys, Solana, anonymous, account numbers, demo, and agent keys. Magic links and OTP codes work too — without `RESEND_API_KEY` the link or code is written to the Convex logs instead of an inbox, which is usually what you want while developing. That fallback is local-only. It applies when `SITE_URL` points at localhost; off a real domain, a missing provider key is an error rather than a log line, because a magic link *is* the credential and "sent" that quietly means "logged" is the worst of both. ## Environment variables are typed Every variable the backend reads is declared in `convex/convex.config.ts`, so the functions get a typed `env` from `./_generated/server` rather than `process.env`. A misspelled name is a type error, and Convex validates values when they're set. Adding a variable means declaring it there first. `BETTER_AUTH_SECRET` is the only one declared required: leave it unset and the push is refused, rather than Better Auth quietly signing sessions with a fallback. Everything else is optional, because an unset variable is a method this deployment doesn't offer, not a broken deployment. ## Third-party credentials Each provider is registered only when its variables are set. Set them with `bunx convex env set`. | Method | Variables | Callback / redirect URL | | ----------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | `https://.convex.site/api/auth/callback/google` | | GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | `https://.convex.site/api/auth/callback/github` | | Apple | `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` | `https://.convex.site/api/auth/callback/apple` | | Email (links/OTP) | `RESEND_API_KEY`, `EMAIL_FROM` | — | | SMS (OTP) | `MOBILE_MESSAGE_API_USERNAME`, `MOBILE_MESSAGE_API_PASSWORD`, `MOBILE_MESSAGE_SENDER` | — | Google and Apple each have a guided walkthrough: [Setting up Google](/docs/setup/google) and [Setting up Apple](/docs/setup/apple). ## Tests ```sh bun run test # everything bun run test:unit # just the fast node ones bun run test:component # just the browser ones ``` Use `bun run test`, not `bun test` — the latter is Bun's own runner, not vitest. ## Changing the auth schema Adding or removing a Better Auth plugin can change the tables it needs: ```sh bun run auth:schema # regenerates convex/betterAuth/schema.ts from convex/auth.ts ``` This replaces the documented `npx auth generate`, whose published CLI still targets Better Auth 1.4. ## Questions **Why `bun run test` rather than `bun test`?** `bun test` is Bun's built-in runner and won't pick up the vitest projects, the browser provider, or the setup files. It will appear to run and find nothing. **Do I need `RESEND_API_KEY` to develop?** No. With a localhost `SITE_URL` and no key, magic links and OTP codes are written to the Convex logs — faster to work with than a real inbox. On a deployment whose `SITE_URL` is a real domain, sending without a key throws instead, so a lapsed key surfaces as an error rather than as mail that never arrives. **Why won't `SITE_URL` accept my localhost origin?** It will, but it shouldn't be one. Passkeys bind to `SITE_URL`'s hostname as their relying party id, so pointing it at localhost creates passkeys that don't work in production. Put local origins in `TRUSTED_ORIGINS`. --- Source: https://aussieauth.com/docs/setup/google # Setting up Google There is a guided version of this page at [/setup/google](/setup/google), which fills in your deployment's real callback URL and checks the credentials once they're set. This is the same walkthrough in text. ## 1. Create the OAuth client In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**, and choose **Web application** as the type. ## 2. Add the redirect URI Under **Authorized redirect URIs**, add: ``` https://.convex.site/api/auth/callback/google ``` `` is your Convex deployment name — `convex dev` prints it, and it's the hostname in `VITE_CONVEX_SITE_URL`. Note this is the `.convex.site` deployment, **not** your app's domain. Unlike Apple, Google doesn't verify the domain, so the callback can go straight to the deployment and never touch your site. Getting this wrong is what produces `redirect_uri_mismatch`. ## 3. Set the credentials ```sh bunx convex env set GOOGLE_CLIENT_ID "" bunx convex env set GOOGLE_CLIENT_SECRET "" ``` Both must be present for the provider to register at all — `convex/auth.ts` checks the pair, so setting one leaves Google switched off. ## 4. Check it Reload the sign-in card. The Google button loses its "needs setup" badge once the deployment can see both variables — that badge is driven by a live probe against the server, not by anything cached in the page. ## Questions **Why does the redirect URI point at `.convex.site` and not my domain?** Because Google doesn't verify domains for the callback, so there's no reason to proxy it. Apple does, which is why Apple's callback is the one path that goes through your own domain. **I get `redirect_uri_mismatch`.** The URI in the Cloud Console must match the callback exactly, including the scheme, the host and the whole `/api/auth/callback/google` path. A trailing slash counts as a mismatch. **Can one client id serve several apps?** Yes. Every app that embeds AussieAuth talks to the same deployment, so they share this one OAuth client. **Does the user see a consent screen for my app as well as for Google?** No. Google's is the only one. The app talks to the deployment from its own origin, so there's no AussieAuth-hosted page to approve. --- Source: https://aussieauth.com/docs/setup/apple # Setting up Apple There is a guided version of this page at [/setup/apple](/setup/apple), which fills in your deployment's real values and checks each credential as you set it. This is the same walkthrough in text. Apple is the fiddly one, for three reasons: the client id isn't what you'd guess, the client secret isn't a secret, and the return URL has to be on a domain Apple has verified. ## 1. Create an App ID In the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list), go to **Certificates, Identifiers & Profiles → Identifiers → `+` → App IDs → App**. - Give it a description — this is shown to users during sign-in - Set a bundle ID in reverse-domain form, e.g. `com.aussieauth.app` - Tick **Sign In with Apple** under Capabilities - Register ## 2. Create a Services ID — this is your client id Back on Identifiers, **`+` → Services IDs**. - Description: your app's name - Identifier: reverse-domain form, e.g. `com.aussieauth.app.si` - Register **This identifier is `APPLE_CLIENT_ID`, not the App ID from step 1.** It is the single most common thing to get wrong here, and the resulting error says nothing useful. ## 3. Configure the Services ID Open the Services ID, enable **Sign In with Apple**, and click Configure. - Primary App ID: the App ID from step 1 - Domains: your site's root domain, e.g. `aussieauth.com` - Return URL: ``` https://aussieauth.com/api/auth/callback/apple ``` **Your own domain — not `.convex.site`.** Apple verifies the domain before it accepts a return URL, and it won't take `localhost` or anything without TLS. The Convex deployment hostname isn't a domain you can verify, so the callback comes back to your site and `vercel.json` proxies that one path through to the deployment. Every other provider calls the deployment directly. ## 4. Create the signing key **Keys → `+`**, name it, tick **Sign In with Apple**, and choose your primary App ID. Download the `.p8` file — Apple gives it to you exactly once. You now have three things: - `APPLE_TEAM_ID` — top right of the developer portal - `APPLE_KEY_ID` — shown with the key you just made - `APPLE_PRIVATE_KEY` — the whole `.p8` file, including the `BEGIN`/`END` lines ```sh bunx convex env set APPLE_CLIENT_ID "com.aussieauth.app.si" bunx convex env set APPLE_TEAM_ID "" bunx convex env set APPLE_KEY_ID "" bunx convex env set APPLE_PRIVATE_KEY "$(cat AuthKey_XXXXXXXXXX.p8)" ``` Escaped `\n` in the key are fine; they're unescaped before parsing. ### Why there's no client secret Apple's "client secret" is a JWT you sign yourself, and Apple rejects one dated more than six months out. So AussieAuth doesn't store a secret at all — you set the key material and `convex/lib/apple.ts` mints a fresh token per request. There is nothing to rotate and nothing to expire. ## 5. Verify the domain Apple gives you a verification file when you add the domain in step 3. Paste its contents into an environment variable rather than committing it: ```sh bunx convex env set APPLE_DOMAIN_ASSOCIATION "" ``` `convex/http.ts` serves it at `/.well-known/apple-developer-domain-association.txt`, and `vercel.json` proxies that path from your domain — which is where Apple looks. Then press **Verify** in the portal. ## 6. Native iOS, if you have one Set `APPLE_APP_BUNDLE_IDENTIFIER` to the App ID from step 1 if a native iOS app will sign in with an id token. For native sign-in Apple issues the token against the **bundle id**, not the Services ID, so without this the JWT validation fails. `APPLE_APP_SITE_ASSOCIATION` serves the file iOS fetches to let a native app use this domain's passkeys and links. It 404s while unset, which is the honest answer — iOS caches what it fetches, so a malformed file is worse than no file. ## Questions **Which identifier is `APPLE_CLIENT_ID`?** The Services ID from step 2, e.g. `com.aussieauth.app.si`. Not the App ID. **Why can't I use localhost or my `.convex.site` URL as the return URL?** Apple verifies the domain before accepting it, and won't take `localhost` or a host without a valid TLS certificate on a domain you control. Use your real domain and let the proxy forward the callback. **Where do I get the client secret?** You don't. AussieAuth signs one per request from the `.p8` key, because Apple refuses secrets dated more than six months ahead. **I lost the `.p8` file.** Apple only lets you download it once. Revoke the key and create a new one, then update `APPLE_KEY_ID` and `APPLE_PRIVATE_KEY`. **Why is `appleid.apple.com` a trusted origin?** Apple returns its callback as a form POST, so the browser sends Apple's origin rather than yours. It's a permanent entry in the trusted-origin list for that reason, and it never touches WebAuthn. **Apple only gave me the user's email the first time.** That's Apple's behaviour: the email claim is present during initial authorization and omitted on later sign-ins. The account already exists by then. --- Source: https://aussieauth.com/docs/embedding # Using it from another app Nothing in the AussieAuth repo changes. The new app registers itself. ## Register the app Set the provisioning secret in **that** app's Convex, then have it POST its own config once — origins first, everything else optional: ```sh # in the new app bunx convex env set AUSSIEAUTH_SECRET "" ``` ```ts await fetch(`${AUSSIEAUTH_URL}/apps/register`, { method: "POST", headers: { authorization: `Bearer ${process.env.AUSSIEAUTH_SECRET}`, "content-type": "application/json", }, body: JSON.stringify({ slug: "portfolio", // stable id; survives a domain move name: "Portfolio", origins: ["https://portfolio.com", "http://localhost:5173"], methods: ["google", "passkey"], // omit for all fifteen }), }); ``` From then on those origins are trusted, they're in the passkey related-origins list, and sessions created from them are stamped with the slug. Registration is idempotent, so calling it on boot and letting it re-run is the intended usage — that's what makes a wiped table repair itself. `POST /apps/unregister` with `{slug}` takes it all back. ## Add the client Copy `src/auth/` and `src/lib/auth-client.ts` into that app and point it at the deployment: ```tsx // src/lib/auth-client.ts — the one line that changes per app export const authClient = createAuthClient({ baseURL: import.meta.env.VITE_CONVEX_SITE_URL, // https://.convex.site plugins: [/* … */], }); ``` ```tsx import { SignIn } from "./auth/SignIn"; ; ``` The app never redirects to AussieAuth — it talks to the deployment from its own origin, so the only consent screen is the provider's. `` takes `methods` to narrow the list, `featured` to choose which buttons sit on the front of the card, and `primary` for the form underneath them. The client plugin shims in `auth-client.ts` import _types_ from `convex/lib/`, so copy that folder too, or keep both apps pointed at one checkout. Nothing under `src/auth/` imports Convex — the card talks to the auth server over HTTP like any other client, so it works in a non-Convex app as well. ## What origins are for `TRUSTED_ORIGINS` still works and is now the _bootstrap_ list — this site and whatever you're developing against, so a fresh checkout works with an empty `apps` table. Registered apps are added on top of it, per request. An app's origins do double duty: they're the CORS allow-list **and** the WebAuthn related-origins list, so registering is also what lets a passkey created on `aussieauth.com` be used from that app. The list is served through `aussieauth.com/.well-known/webauthn`, which `vercel.json` already proxies. Scheme origins are filtered out of that list — a browser can't act on them, and every entry counts against WebAuthn's five-site limit. Because browsers honour at most **five** distinct sites and silently ignore the rest, `/apps/register` answers with the slot usage: ```json { "slug": "portfolio", "origins": 2, "passkeyOrigins": { "limit": 5, "active": ["https://aussieauth.com", "https://portfolio.com"], "dropped": [] } } ``` If your app's origin shows up in `dropped`, passkeys won't work from it — unregister something or consolidate onto fewer sites. ## Method allow-lists The per-app `methods` list is enforced at `/sign-in/social` rather than at the callback, so a blocked provider never gets as far as showing you its consent screen. It fails **open** for origins no app has claimed, which is what keeps a deployment's own sign-in page working with an empty table. ## Questions **Do I have to redeploy AussieAuth to add an app?** No. Registration is a runtime HTTP call against the registry; the trusted-origin list is resolved per request. **What happens if two apps claim the same origin?** The second registration is refused rather than silently reassigned — taking over an origin would mean taking over that app's sign-ins. **How quickly does a new registration take effect?** Within five seconds. The registry is cached per isolate for that long; the isolate that handled the registration invalidates its copy immediately. **Can I use this without Convex on the frontend?** Yes. `src/auth/` imports no Convex. Only the backend is Convex-specific. --- Source: https://aussieauth.com/docs/native # Native apps ## Expo in 5 minutes Install the SDK and native session storage: ```sh bun add @aussieljk/auth @better-auth/expo npx expo install expo-secure-store expo-web-browser ``` Scaffold the client, layout provider, sign-in route, and app scheme: ```sh bunx aussieauth init expo --scheme myapp ``` Set environment variables: ```sh EXPO_PUBLIC_AUSSIEAUTH_URL=https://your-deployment.convex.site EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud ``` Register the native origins with the AussieAuth deployment: ```sh bunx aussieauth apps register \ --auth-url "$EXPO_PUBLIC_AUSSIEAUTH_URL" \ --secret "$AUSSIEAUTH_SECRET" \ --slug myapp \ --name "My App" \ --scheme myapp \ --dev-exp ``` Use the native card in `app/sign-in.tsx`: ```tsx import { AussieAuthNativeSignIn } from "@aussieljk/auth/native"; import { router } from "expo-router"; import { authClient } from "../lib/auth-client"; export default function SignIn() { return router.replace("/")} />; } ``` Protect routes with the Expo helpers: ```tsx import { RequireAuth } from "@aussieljk/auth/expo"; import { authClient } from "../lib/auth-client"; export default function Home() { return {/* app */}; } ``` See `examples/expo-router` for a complete Expo Router app. ## What the SDK provides - `createAussieAuthExpoClient` builds the Better Auth client with AussieAuth's methods, the Expo cookie/session bridge, cross-domain auth, and Convex token support. - `AussieAuthProvider` wraps the usual Expo app setup: native auth client plus `ConvexBetterAuthProvider`. - `AussieAuthNativeSignIn` is a React Native sign-in surface using `ScrollView`, `TextInput`, and `Pressable` rather than DOM/CSS. - `RequireAuth`, `RedirectIfSignedIn`, `useAussieUser`, and `signOutAndRedirect` cover the common route flows. - `aussieauth apps register` registers `myapp://` and optionally `exp://` with the existing `/apps/register` endpoint. ## No Origin header A native app has no `Origin` header, so `@better-auth/expo` sends its deep-link scheme as `expo-origin` and the server-side `expo()` plugin rewrites it back onto the request. Everything downstream — CSRF, `trustedOrigins`, `appMethods`, the session's `appId` — then works unchanged. The plugin has a second job on OAuth callbacks: it appends the session cookie to the `myapp://` redirect, because a native app has no cookie jar the browser can write to. ## Scheme origins Native apps register **scheme origins** rather than URLs: ```jsonc { "slug": "myapp", "name": "My App", "origins": ["myapp://", "exp://"] } ``` Only the bare scheme is accepted, because these match by prefix. That's what makes Expo Go work: its origin is `exp://:8081/--/`, which changes with the network and so can never be registered exactly. Prefix matching is confined to non-http origins. Doing it for web origins would mean `https://myapp.com` claiming `https://myapp.com.evil`, and registration refuses a bare `https://` for the same reason. ## Production notes Trusting `exp://` means any Expo Go project can reach the deployment. Use it for development deployments, not production. Passkeys shared with the website need `APPLE_APP_SITE_ASSOCIATION` set and a real bundle id. Expo Go runs as Expo's bundle id, so associated-domain behavior only applies to development or production builds. --- Source: https://aussieauth.com/docs/deploying # Deploying The site builds to static files. Every route is prerendered at build time, so `dist/client` deploys as plain static output with no server at request time — `vercel.json` sets `outputDirectory` accordingly. ## The four proxied paths `vercel.json` rewrites four paths from `aussieauth.com` to the Convex deployment. They're there because a third party checks them **against the domain**, so serving them from `.convex.site` wouldn't count: | Path | Who fetches it, and why | | ----------------------------------------------------- | ------------------------------------------------------------- | | `/api/auth/callback/apple` | Apple only returns to a domain you've verified | | `/.well-known/apple-developer-domain-association.txt` | Apple's domain verification | | `/.well-known/webauthn` | The browser, from the passkey relying party's domain | | `/.well-known/apple-app-site-association` | iOS, to let a native app use this domain's passkeys and links | Everything else talks to `.convex.site` directly. ## The deployment hostname is hardcoded **The deployment hostname appears literally in those four lines**, because Vercel doesn't interpolate environment variables into rewrite destinations — and it rejects unknown keys like `$comment`, so the warning can't live in the file either. If the Convex deployment ever changes, update all four or Sign in with Apple and cross-domain passkeys break silently. Get the hostname from `convex dev`. ## Pushing the backend ```sh bunx convex dev --once ``` ## Questions **Why is the site static if it has an auth server?** The auth server is the Convex deployment, and it's a separate thing. The site is a sign-in card and some documentation; it talks to Convex over HTTP from the browser. **Can I host the frontend somewhere other than Vercel?** Yes, anywhere that serves static files — but you need equivalents of the four rewrites above, or Apple sign-in and cross-domain passkeys will fail. **What happens if I forget to update the hardcoded hostname?** Sign in with Apple and cross-domain passkeys stop working, with no error on the site itself. It's the failure most worth setting a reminder for. --- Source: https://aussieauth.com/docs/architecture # 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 React Cosmos fixtures — the same `*.fixture.tsx` files the workbench shows — through the same `src/cosmos.decorator.tsx`, so a state you can look at is a state that's covered: ```sh bun run cosmos # the workbench, at localhost:7007 ``` 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. Cosmos runs against `vite.cosmos.config.ts` rather than `vite.config.ts` — it would otherwise pick up the TanStack Start plugin, whose SSR entry and route generation mean nothing to a fixture. Both configs share `vite.base.ts`, so the path aliases can't drift between the app and the workbench. The root `index.html` is committed but isn't the app's document — TanStack Start builds that from `src/routes/__root.tsx`. It's the renderer shim react-cosmos insists on, and `bun run cosmos` fails outright if it's missing, which is the only reason it's in the repo. ## Linting and formatting One tool each, both Rust: ```sh 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 `
` 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. --- Source: https://aussieauth.com/docs/methods # Method notes The behaviour of each method that isn't obvious from its name. ## Solana Better Auth's `siwe` plugin only accepts `0x…` addresses, so `convex/lib/solana.ts` implements Sign In With Solana directly. The server composes the message and stores it; verification consumes it, so a signature can't be replayed. ## Passkeys Bound to `SITE_URL`'s hostname as the relying party id, and usable from every origin in `TRUSTED_ORIGINS` via WebAuthn Related Origin Requests. A passkey is normally locked to the one domain that created it, which for a shared auth server would make the most portable-feeling method the least portable. `convex/http.ts` publishes the allowed origins at `/.well-known/webauthn`, proxied through the site's own domain because the browser fetches it from the relying party's domain. The spec matches on eTLD+1 and allows at most **five** distinct labels, so this is a handful of apps rather than an open list. Anything past the fifth is dropped knowingly, with a warning in the logs, rather than silently ignored by the browser. ### Passkey names Nobody types one. The authenticator identifies itself in the AAGUID it returns at registration and the server turns that into a label ("iCloud Keychain", "1Password"); when it doesn't say — Apple zeroes the AAGUID under `attestation: "none"` — the User-Agent stands in. ## Account numbers Sign-up returns a 16-digit number once and stores it as the user's `username`. There is no email, password or recovery path. Mullvad's model: the number is the entire credential. ## SMS and email OTP The code fields use `autocomplete="one-time-code"`, which is what makes iOS Passwords and Android offer the code above the keyboard. ## Magic links Sent by Resend when `RESEND_API_KEY` is set, and written to the Convex logs when it isn't and `SITE_URL` is a localhost one. Off a real domain the key is required — see [quickstart](/docs/quickstart). Good for one sign-in. ## The demo account Everyone who clicks "Try the demo" lands on the _same_ user, so the session it hands out is deliberately read-only. Setting a password, linking a social account, registering a passkey, minting an API key and revoking sessions are all refused for it. Otherwise the first visitor to set a password would own `demo@aussieauth.com` permanently and every later visitor would be sharing an account with them. The deny-list is `LOCKED` in `convex/lib/demo.ts` — adding a method means adding it there too. ## Anonymous A throwaway session that can be upgraded later by linking a real credential onto it. The address is `…@anonymous.invalid`, which is unroutable on purpose. ## Agent auth API keys, not sessions. Mint one on the account page; the agent sends it as an `x-api-key` header. Keys are numbered, never named. The next key is one past the highest number in use, so revoking key 2 doesn't hand the number out twice. ## Two-factor (TOTP) A second factor rather than a sixteenth method: authenticator-app codes offered after a password sign-in when the account has switched them on, from the account page. Enabling and disabling both require the password — that's Better Auth's rule, so an account without a password can't enrol. It also covers the sign-in methods AussieAuth wrote itself. Better Auth's plugin only gates the three paths it ships (`/sign-in/email`, `/sign-in/username`, `/sign-in/phone-number`), which left a linked wallet or an account number as a way in that never asked for the code. `convex/lib/twoFactorGate.ts` widens that gate to `/sign-in/solana` and `/sign-in/account-number` — so once TOTP is on, it is on for every door into the account, not just the one it was switched on from. Enrolment hands out backup codes once. Each works once, from the "use a backup code" link on the challenge screen; a fresh set can be minted from the account page (which invalidates the old one). The challenge offers "don't ask again on this device for 30 days", which is Better Auth's trusted-device cookie. The demo account can't enrol: `/two-factor/` is on the deny-list, for the same reason it can't set a password. ## Sessions The account page lists every live session with a coarse device label from the User-Agent, and can end any session except the current one. "Sign out everywhere" on the account header is the bulk version. ## Your data The account page's footer card exports everything the server holds about you as JSON — assembled client-side from the same endpoints the page already uses — and can delete the account outright, password-confirmed. The demo account is refused both ways. ## Apple `https://appleid.apple.com` is a permanent trusted origin, because Apple returns its callback as a form POST and the browser sends Apple's origin rather than ours. ## Email verification Sent on sign-up, but never required to sign in. It's there because linking checks it: attaching a social account to an existing user also requires that _existing_ user's address to be verified, so without the sign-up mail a credential user could never later add Google — they'd get "account not linked" and no way forward. The one-line alternative is `accountLinking.requireLocalEmailVerified: false`, which opens the pre-registration attack: sign up under someone else's address, wait for their Google sign-in to merge into your account. ## Rate limiting On, and backed by the `rateLimit` table rather than memory. Both defaults were wrong here: - Better Auth enables rate limiting only when `NODE_ENV === "production"`, which a Convex deployment isn't — so the stock three-sign-ins-per-ten-seconds rule was off rather than merely loose. - Its default memory store is a Map inside one HTTP-action isolate: not shared between concurrent requests, and gone when the isolate is recycled. Matters most for account numbers, where the number is the entire credential and there's no second factor to fall back on. ## Account linking Signing in with Google and later with GitHub on the same address lands on one account, not a duplicate-email error. `trustedProviders` is what makes that merge happen at sign-in rather than only through an explicit link from the account page. Only real provider ids belong there — it's matched against the _incoming_ account's providerId, so the credential provider is never a candidate. ## Questions **Why can't the demo account set a password?** Because it's shared. The first visitor to set one would own it, and everyone after them would be locked into an account someone else controls. **How many apps can share one passkey?** Five distinct eTLD+1 labels, which is a WebAuthn limit rather than an AussieAuth one. Several origins on one site cost one between them. **Is there a recovery path for account numbers?** No. That's the trade — no email, no password, nothing to phish, and nothing to recover if you lose the number. **Why does an anonymous user have an email address?** The schema needs one. It's set to an unroutable `…@anonymous.invalid` address and replaced if the user later links a real credential.