Authentication
Every app ships Better Auth 1.7 wired to D1, and the screens for it: sign-in, sign-up, password reset, two-factor, and an account page where people manage all of it.
Sign-in methods
Section titled “Sign-in methods”| Method | How it works |
|---|---|
| Email and password | Always on. Includes password reset by email and email verification. |
| Magic links | A one-time link by email that expires in 5 minutes. |
| Email codes | A 6-digit code by email, for people who’d rather type than click. |
| Passkeys | Face ID, Touch ID, Windows Hello or a security key. There’s no password to steal, and it counts as a strong sign-in on its own. |
| Two-factor | After a password, a code from an authenticator app (with 10 backup codes) or a code by email. |
| Google, GitHub, Apple, Microsoft | Social sign-in, joined to an existing account with the same verified email. |
flare create asks which to switch on. In scripts, pass them as flags:
npx flare create shop --auth magic-link,passkeys,2fa-app --auth-providers google,github --yes--auth takes magic-link, email-otp, passkeys, 2fa-app and 2fa-email,
or all (the default) or none.
Changing them later
Section titled “Changing them later”The choices live in lib/auth-config.ts:
export const authConfig = { magicLink: true, emailOtp: false, passkeys: true, twoFactor: { authenticator: true, email: true }, social: ["google", "github"] as SocialProvider[], requireEmailVerification: false,};Every method’s tables exist whatever you choose, so switching one on or off
needs no migration. A method that’s off is refused by the API with
404 This sign-in method isn't enabled., not just hidden from the screens.
Who may sign up
Section titled “Who may sign up”npx flare create shop --signup=inviteor later, in lib/auth-config.ts:
signup: "open", // "open" | "invite" | "closed"| Mode | Who gets an account |
|---|---|
open |
Anybody who finds the sign-up page. The default. |
invite |
Only somebody holding an invitation link, issued by an admin |
closed |
Nobody. Accounts are made with flare user:add |
The first account is always allowed, whatever the mode says. An app nobody
can sign into cannot be set up, and in invite mode there would be nobody to
issue the first invitation. That first account also becomes the admin.
Enforced where the account is created, not on each sign-up route. There are
four ways to end up with a new account — a password sign-up, a magic link, an
email code, a social callback — and gating them one at a time is how one gets
missed. Everything goes through one check in lib/auth.ts. Magic links and
email codes also stop creating accounts of their own outside open, since a
link to an address with no account would otherwise be a sign-up through a side
door.
The sign-up page says so rather than offering a form that will be refused, and
flare deploy mentions an open sign-up among the things worth knowing before
you push.
Invitations
Section titled “Invitations”Issued from /dashboard/invitations, which appears in the sidebar when signup
is invite. An invitation can name an address — only that person may use it —
or be for anybody who has the link. Both expire; seven days by default.
The link is /invite/<token>. Opening it puts the token in a short-lived cookie
and sends the person to sign up, so the token stays out of the sign-up URL and
therefore out of bookmarks, referrer headers and screenshots. It is spent once
the account exists, so a link works exactly once — and an invitation survives a
sign-up that fails validation, which is why it is not spent at the check.
Invitations live in the verification table, which Better Auth already has
and already uses for single-use tokens with an expiry. So switching an existing
app to invite is one line in lib/auth-config.ts and no migration. Only the
hash of each token is stored, for the same reason a password is: a copy of the
database should not be a pile of working invitations — which is also why the
link is shown once, on screen, to copy.
lib/invitations.ts is in your app. Issue one from your own code with
createInvitation({ for: { email } }) and send it however you like.
Making an account from the command line
Section titled “Making an account from the command line”npx flare user:add ada@example.com --role adminnpx flare user:add bob@example.com --password 'correct-horse-battery-staple'npx flare user:add ada@example.com --role admin --remoteThe way in for a closed app, and a shortcut for any app. It writes the same
two rows a sign-up writes — the user, and a credential account holding the
password hash — so the account is indistinguishable from one made through the
form.
The password is generated and printed once unless you give one. Nothing stores it in plain text, here or anywhere, so there is no second chance to read it.
--role is checked against the roles the app has registered (flare role:add),
because a role nothing recognises gives you somebody who can sign in and reach
nothing, which looks like a different bug entirely.
Passwords
Section titled “Passwords”The sign-up, reset and change-password forms check as you type: the rules appear before you can fail them, a meter reads the strength, and the eye shows what you’ve typed.
Only the length is enforced — at least 8 characters. The rest is advice,
because length beats punctuation, and a form that refuses
correct horse battery staple while accepting P@ssw0rd has its rules the
wrong way round. What it says:
| At least 8 characters | Required; the API refuses anything shorter |
| Letters and something else | A number, a symbol or a space |
| Not a password everyone tries first | Common passwords, and keyboard runs like qwerty |
| Nothing from your name or email | Anyone who knows the address can guess the rest |
The same rules run on the server for every new password
(lib/password-rules.ts, enforced in lib/auth.ts), so the API and the
screens always agree.
New passwords are also checked against
Have I Been Pwned: only the first five
characters of the password’s SHA-1 leave the Worker, the password itself never
does, and an outage lets the password through rather than blocking sign-up.
Turn it off in lib/auth-config.ts:
checkBreachedPasswords: false,Change the rules themselves in lib/password-rules.ts — it’s your app’s file.
The screens
Section titled “The screens”| Route | What it’s for |
|---|---|
/sign-in |
Two steps: the email first (with passkey and social sign-in beside it), then the password, or an emailed link or code |
/sign-up |
Name, email, password, and social sign-up |
/forgot-password, /reset-password |
Reset by emailed link; a reset signs every other device out |
/verify-email |
Confirm an email address by the code in the message, or ask for a fresh link |
/two-factor |
The second step after a password: authenticator code, email code or backup code, with “trust this device for 30 days” |
/dashboard/account |
Profile and email verification, password, two-factor setup (QR code and backup codes), passkeys, connected accounts, signed-in devices |
Their layout, colours and type follow the app’s theme.
The wording lives in the components under components/auth/, and the emails
in lib/auth-emails.ts.
Two-factor and email sign-in
Section titled “Two-factor and email sign-in”Better Auth only asks for the second factor after a password. A magic link or emailed code on its own would let anyone with the inbox skip it. So for accounts with two-factor on, Flare doesn’t send a sign-in link or code. It emails a notice instead: “sign in with your password or a passkey”. The screen shows the same “check your email” either way, so nobody can use this to learn which accounts have two-factor.
People who signed up with a social account have no password. They can still set up two-factor, and the account page doesn’t ask them for one.
Codes and links
Section titled “Codes and links”Every code box — sign-in, two-factor, email verification — is one input with the cells drawn behind it, so pasting a code works, iOS and Android autofill work, and so does backspacing across cells. A code submits itself as soon as the last digit lands.
“Send a new code” waits 30 seconds between sends, and sending another doesn’t invalidate the one already in the inbox: people press resend, then find the first email.
Emails
Section titled “Emails”Without RESEND_API_KEY, emails print to the console (in flare dev and
flare start), links and codes included, so every flow works locally with
nothing set up. With it, they’re sent through Resend from
MAIL_FROM.
Social providers
Section titled “Social providers”Each provider switches on only when it’s in authConfig.social and its
credentials are set. That way the app runs and deploys before you have any
credentials, and a button never appears for a provider that can’t work. Put
credentials in .dev.vars locally, and wrangler secret put in production.
Register this callback URL with each provider:
<your app URL>/api/auth/callback/<provider>| Provider | Where | Variables |
|---|---|---|
| Google Cloud console → OAuth client (web) | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
|
| GitHub | GitHub developer settings → OAuth app | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| Microsoft | Microsoft Entra → App registrations | MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, optional MICROSOFT_TENANT_ID |
| Apple | Apple Developer → Services ID | APPLE_CLIENT_ID, APPLE_CLIENT_SECRET, optional APPLE_APP_BUNDLE_ID |
- Microsoft:
MICROSOFT_TENANT_IDdefaults tocommon, which accepts personal and work accounts. Set your tenant id to allow only your organisation. - Apple:
APPLE_CLIENT_IDis the Services ID.APPLE_CLIENT_SECRETisn’t a static secret: it’s a JWT you sign with your Sign in with Apple key (.p8), and it’s valid for at most six months. Regenerate it before then. Apple also needs a real domain with HTTPS: it doesn’t redirect tolocalhost. For local testing, use a tunnel.
Passkeys
Section titled “Passkeys”Passkeys use the address the app is served from. On your machine, open the app
at http://localhost:3000, not http://127.0.0.1:3000: browsers refuse
passkeys on IP addresses. In production the domain is the passkey’s home, so a
passkey made on app.example.com works there and nowhere else.
Password hashing
Section titled “Password hashing”Passwords are hashed with PBKDF2-SHA256 at 100,000 iterations via WebCrypto, not Better Auth’s default scrypt. Scrypt in pure JavaScript costs about 250–300ms of CPU per hash, well over the Workers free-plan CPU budget; PBKDF2 at the same strength costs about 45ms. 100k is the highest iteration count Workers’ WebCrypto accepts, and it’s stored with each hash so it can change later without breaking existing users.
Session gating
Section titled “Session gating”Two layers, on purpose:
proxy.tsdoes an optimistic, cookie-only redirect, with no database call, so signed-out visitors bounce off protected routes immediately.requireSession()/getSession()inlib/session.tscheck the session against D1 in the page or route handler itself. The proxy is a fast, best-effort gate; these are the real one.
import { requireSession } from "@/lib/session";
export default async function ReportsPage() { const { user } = await requireSession("/reports"); // to /sign-in?next=/reports when signed out return <p>Welcome, {user.name}</p>;}Base URL and allowed hosts
Section titled “Base URL and allowed hosts”Set BETTER_AUTH_URL once you have a custom domain. Until then, Flare allows
localhost:*, 127.0.0.1:*, your <app>.*.workers.dev host and
*.trycloudflare.com (tunnels), so local dev, tunnels and
your first deploy all work with no configuration.
Write it as a bare origin — https://example.com, no trailing slash and no
path. Better Auth compares it against the request’s Origin header, which
never carries a trailing slash, so https://example.com/ matches nothing and
every sign-in returns 403. Nothing in the response says which setting is
wrong, which is what makes it expensive. Copying a domain out of a browser bar
or the Vercel dashboard is the usual way to acquire the slash.
lib/auth.ts trims it and warns on startup rather than letting sign-in break:
[auth] BETTER_AUTH_URL has a trailing slash, which never matches an Originheader and makes every sign-in 403. Using "https://example.com" — setBETTER_AUTH_URL to that to silence this.It warns the same way for a value with no https://. Both are in lib/auth.ts
in your app, so you can see exactly what it does with the variable.
Secrets
Section titled “Secrets”flare create writes a random BETTER_AUTH_SECRET to .dev.vars
(git-ignored). flare deploy generates and uploads a separate secret for
production the first time you deploy. It never reuses the local value.
Admin access
Section titled “Admin access”The admin plugin adds user.role (default "user") and ban fields.
dashboardSession() in lib/dashboard.ts decides who reaches /dashboard:
- any role in
ADMIN_ROLES(adminandstaffby default), or - any role that may read at least one resource under its policy.
Grant a role:
npx flare user:role you@example.com admin --remoteTested
Section titled “Tested”scripts/e2e-auth.mjs in the framework repo runs every method in a real
browser against a running app. It covers sign-up, both sign-in steps, magic
links and email codes, password reset, authenticator setup and sign-in with a
code or backup code, email two-factor, the notice for two-factor accounts,
passkeys (with Chrome’s virtual authenticator) and signed-in devices.