Authentication
Passwordless sign-in with better-auth — Google and One Tap, optional GitHub, emailed codes — plus sessions, the auth config rule, and account deletion and export.
ShipKit signs people in with better-auth and has no passwords anywhere: no reset flow, no register/login split, nothing to leak. There are three ways in, and the first sign-in by any of them creates the account.
| Method | Required? | What it needs |
|---|---|---|
| Google (redirect + One Tap) | Yes | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and the client id in appConfig.googleClientId |
| GitHub | No | GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET — both, or neither |
| Emailed sign-in code | Always on | RESEND_API_KEY to actually send; without it the code is printed to the server log |
The server config is src/features/auth/server/auth.ts. The client is src/features/auth/client.ts, and the sign-in panel shared by /login and the in-page login dialog is src/features/auth/components/login-panel.tsx.
Google and One Tap
Google is the baseline method, so its two env vars are required: src/core/env.ts refuses to boot without them. The /google-oauth skill walks through the Google Cloud console. The short version:
- Create an OAuth client of type Web application.
- Redirect URI:
<APP_URL>/api/auth/callback/google. - JavaScript origins:
<APP_URL>. For local dev add bothhttp://localhost:3000andhttp://localhost— One Tap needs the portless entry. - Put the id and secret in
.dev.vars, and the same client id ingoogleClientIdinsrc/config/app-config.ts(it has a dev and a production value).
One Tap is the Google prompt that appears in the corner of the page for signed-out visitors. It is mounted on the marketing layout and on the login panel (src/features/auth/components/one-tap.tsx) and signs the visitor in without leaving the page. It validates JavaScript origins rather than redirect URIs, so a working Google button with a silent One Tap almost always means a missing origin. Google also suppresses the prompt for a while after someone dismisses it, so an absent prompt is not necessarily a bug.
GitHub
Create an OAuth app at GitHub with the callback URL <APP_URL>/api/auth/callback/github, then set both vars. With both set, a "Continue with GitHub" button appears next to Google; with neither, it is not rendered at all. The login UI asks the server which providers are configured (getAuthProvidersFn in src/features/auth/server/fns.ts), so the button and the provider can never disagree.
Emailed sign-in codes
The email form sends a six-digit code that is valid for ten minutes. The code is stored hashed and burned after three wrong guesses. Sending a code is throttled in front of better-auth (src/routes/api/auth.$.ts) against the PUBLIC_RATE_LIMIT binding twice — per IP and per address — so rotating IPs cannot flood one inbox.
Without RESEND_API_KEY the send is a no-op and the code is printed to the dev server's log instead:
[auth] no mail provider — code for you@example.com: 123456
That makes the method usable in a fresh clone. In production, set up Resend first — see Email and notifications.
Sessions
Sessions are cookie-based, valid for seven days, and refreshed at most once a day. Nothing in the app hand-rolls a session check:
- Pages under the
_approute group are guarded insrc/routes/_app.tsx: no session, redirect to/login?redirect=<where you were going>. After signing in the visitor lands back on that page. The redirect only honours same-origin paths (src/features/auth/redirect.ts). - Server functions start from
authedFnoradminFninsrc/core/server/fn.ts, which put a typedsessionin the handler's context.
export const myThingFn = authedFn.handler(async ({ context }) => {
const userId = context.session.user.id
// …
})
See Server functions for the full data channel and Permissions for what adminFn does and does not guarantee. A nightly job (auth.purge-expired) deletes expired sessions, verification rows and API keys, because better-auth only expires them logically.
Sign-ins, sign-outs, profile updates, role changes, bans and API-key changes are written to the audit log by src/features/auth/server/audit-hook.ts.
Changing the auth config
The better-auth CLI runs in Node and cannot import cloudflare:workers, so the config lives in two files:
src/features/auth/server/auth.ts— the real one the Worker runs.auth.cli-config.tsat the repo root — a mirror with the same plugins and user fields, used only to generate the schema.
When you add a plugin or a user field, change both, then regenerate the tables and a migration:
bun run auth:generate # rewrites src/features/auth/schema.ts
bun run db:generate # drizzle migration for the change
bun run db:migrate:local
Social providers add no columns, so adding one only touches auth.ts (and src/core/env.ts plus .dev.vars.example for its secrets). See Database for migrations.
Each user's locale
Every user row has a locale column. It is stamped at sign-up from the visitor's language cookie and changed from Settings → Preferences. Emails are sent in that language — see Internationalization.
Deleting an account
Deletion from Settings happens in two steps.
- Close. The account is stamped
deletedAt, every session and API key is removed, and an "account closed" email goes out. Nothing else is deleted. A closed account cannot sign back in by any method. The request is refused if the session is more than a day old (there is no password to re-enter, so a recent sign-in is the confirmation), or if a feature guard objects — for example a live subscription. - Purge. A nightly job erases the account once the retention window has passed (30 days by default, editable in the admin console under Settings, minimum 7). A reminder email goes out three days before. Rows cascade with the user; things outside the database, such as the uploaded avatar in R2, are removed by handlers.
Until the purge an admin can restore the account from the user's page in the console. The user then signs in again as normal; revoked sessions and keys stay revoked.
Features plug into this through src/core/account/events.ts: onAccountDeletionGuard to refuse, onBeforeAccountDelete and onAccountDeleted for cleanup, and onAccountCreated for work a new account needs. Each feature registers with one import line in src/core/account/handlers.ts.
Exporting data
Settings → Your data downloads everything held about the account as JSON: the user row, sessions, linked providers, and one key per feature. A feature adds its slice with onAccountExport('<key>', handler) — the email log, for example, contributes the templates and subjects of mail sent to the user. Each export is audited.