Skip to content

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.

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:

Terminal window
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.

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.

Terminal window
npx flare create shop --signup=invite

or 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.

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.

Terminal window
npx flare user:add ada@example.com --role admin
npx flare user:add bob@example.com --password 'correct-horse-battery-staple'
npx flare user:add ada@example.com --role admin --remote

The 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.

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.

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.

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.

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.

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.

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 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_ID defaults to common, which accepts personal and work accounts. Set your tenant id to allow only your organisation.
  • Apple: APPLE_CLIENT_ID is the Services ID. APPLE_CLIENT_SECRET isn’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 to localhost. For local testing, use a tunnel.

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.

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.

Two layers, on purpose:

  1. proxy.ts does an optimistic, cookie-only redirect, with no database call, so signed-out visitors bounce off protected routes immediately.
  2. requireSession() / getSession() in lib/session.ts check 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>;
}

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 Origin
header and makes every sign-in 403. Using "https://example.com" — set
BETTER_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.

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.

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 (admin and staff by default), or
  • any role that may read at least one resource under its policy.

Grant a role:

Terminal window
npx flare user:role you@example.com admin --remote

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.