Authentication
Passwordless email codes, GitHub and Google OAuth, two-factor authentication, sessions and account pages, powered by Better Auth.
Launch Now uses Better Auth for everything related to identity: sign-in, sign-up, sessions, two-factor authentication, organizations, API keys and Stripe customers. Users sign in with a one-time code sent by email or with GitHub or Google. This guide covers how it is wired, how to protect pages and server code, and how to customize it.
How it works
Better Auth runs inside your Next.js app. A single server instance (auth) holds the configuration and
talks to Postgres through the Drizzle adapter. The browser talks to it through a catch-all route handler,
and a typed client (authClient) wraps those endpoints for React components.
| File | Role |
|---|---|
lib/auth.ts | Server configuration: database, providers, plugins, hooks and rate limits. Exports auth. |
lib/auth-client.ts | Browser client with the matching client plugins. Exports authClient. |
lib/auth-server.ts | Server helpers getSession() and getUser(). |
app/api/auth/[...all]/route.ts | Mounts every Better Auth endpoint under /api/auth/*. |
lib/safe-action.ts | authAction and nonDemoAction clients for authenticated server actions. |
proxy.ts | Redirects signed-in visitors from / to /dashboard when they opted in. |
app/auth/* | Sign-in, sign-up and onboarding pages. |
features/auth/* | Sign-in and sign-up forms, OAuth buttons, callback URL helpers, demo button. |
app/(app)/account/* | Account pages: profile, security, billing, API keys, notifications, danger zone. |
drizzle/auth-schema.ts | Tables used by Better Auth and its plugins. |
The route handler is one line:
import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js"
export const { GET, POST } = toNextJsHandler(auth.handler)Environment variables
These variables are validated by lib/env.ts at startup:
| Variable | Description |
|---|---|
BETTER_AUTH_SECRET | Secret used to sign cookies and tokens. At least 32 characters (openssl rand -base64 32). |
BETTER_AUTH_URL | Base URL of the auth server, for example http://localhost:3000. Also used to build invitation links. |
NEXT_PUBLIC_APP_URL | Public URL of the app. Used as the only trusted origin and as the client baseURL. |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | GitHub OAuth app credentials. Required. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Google OAuth client credentials. Required. |
DEMO_EMAIL, DEMO_PASSWORD | Optional shared demo account. See Demo account. |
Sign-in codes are sent with Resend, so RESEND_API_KEY and EMAIL_FROM must also be set.
Server configuration
lib/auth.ts is the single place where authentication is configured. Trimmed to its structure:
export const auth = betterAuth({
appName: siteConfig.name,
database: drizzleAdapter(db, { provider: "pg", schema }),
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.NEXT_PUBLIC_APP_URL],
rateLimit: {
enabled: true,
storage: "database",
customRules: {
"/email-otp/send-verification-otp": { window: 60, max: 3 },
"/sign-in/*": { window: 60, max: 5 },
},
},
// Password sign-in is only used by the shared demo account; public sign-up stays disabled.
emailAndPassword: { enabled: true, disableSignUp: true },
hooks: { /* ... */ },
databaseHooks: { /* ... */ },
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},
plugins: [
emailOTP({ /* ... */ }),
admin(),
openAPI(),
organization({ /* ... */ }),
stripe({ /* ... */ }),
lastLoginMethod({ /* ... */ }),
apiKey({ /* ... */ }),
twoFactor(),
// Must stay last: lets auth.api calls in server actions set cookies.
nextCookies(),
],
})Enabled plugins
| Plugin | What it adds | Guide |
|---|---|---|
emailOTP | Six-digit codes sent by email, valid for 10 minutes. This is the main sign-in method. | |
admin | User roles, bans and impersonation. The /admin section requires role === "admin". | |
openAPI | An OpenAPI description of the auth endpoints. | |
organization | Organizations, members, invitations and the active organization. Limited to 5 organizations per user. | Organizations |
stripe (from @better-auth/stripe) | Stripe customers, checkout, subscriptions and webhooks for users and organizations. | Billing |
lastLoginMethod | Remembers the last method used, shown as a "Last used" badge on the sign-in page. | |
apiKey (from @better-auth/api-key) | Personal API keys prefixed with the product slug. | API keys |
twoFactor | TOTP two-factor authentication with backup codes. | |
nextCookies | Lets auth.api calls made in server actions set cookies. Keep it last. |
The client in lib/auth-client.ts registers the matching client plugins so authClient exposes typed
methods such as authClient.emailOtp, authClient.organization, authClient.twoFactor,
authClient.apiKey and authClient.subscription:
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [
emailOTPClient(),
lastLoginMethodClient(),
adminClient(),
organizationClient(),
twoFactorClient(),
apiKeyClient(),
stripeClient({ subscription: true }),
],
})Good to know: When you add a server plugin to lib/auth.ts, add its client plugin to
lib/auth-client.ts too, otherwise the client methods are missing from authClient.
Rate limits
Rate limiting is enabled and stored in the database. On top of Better Auth's defaults, two custom
rules apply: 3 requests per minute on /email-otp/send-verification-otp and 5 requests per minute on
every /sign-in/* endpoint.
Hooks and notifications
lib/auth.ts also registers hooks that feed the in-app notifications:
- A new user triggers an
admin.user_signed_upnotification for admins. - A new session from a device and IP pair never seen for that user triggers
security.new_sign_in(skipped for the first session and for impersonated sessions). - Turning 2FA on or off triggers
security.two_factor_changed. - Creating an API key triggers
security.api_key_created.
Sign-in and sign-up
The auth pages live in app/auth/ and share the split-screen layout in app/auth/layout.tsx:
| Route | Page | Form |
|---|---|---|
/auth/signin | app/auth/signin/page.tsx | features/auth/signin-form.tsx |
/auth/signup | app/auth/signup/page.tsx | features/auth/signup-form.tsx |
/auth/onboarding | app/auth/onboarding/page.tsx | features/onboarding/onboarding-form.tsx |
Both forms work the same way. The user enters an email, receives a six-digit code, then types it in:
const { error } = await authClient.emailOtp.sendVerificationOtp({
email: values.email,
type: "sign-in",
})
// ...
const { error } = await authClient.signIn.emailOtp({
email,
otp: otpValue,
})The code email is rendered from emails/otp-email.tsx in the sendVerificationOTP callback of the
emailOTP plugin and sent with sendEmail from lib/email.ts. The resend button unlocks after 30
seconds.
There is no separate account creation step: signing in with a code for an unknown email creates the account. The difference between the two pages is where the user lands afterwards:
- Sign-in sends the user to
/dashboard. - Sign-up sends the user to
/auth/onboarding, where they set their name and create their first organization, then continue to/dashboard.
Below the email form, GitHubButton and GoogleButton from features/auth/social/ call
authClient.signIn.social. On the sign-up page they pass newUserCallbackURL so new OAuth users also
land on onboarding:
<GitHubButton
callbackURL={callbackURL ?? "/dashboard"}
newUserCallbackURL={callbackURL ?? "/auth/onboarding"}
/>Good to know: Email and password sign-in is enabled only so the shared demo account can sign in.
disableSignUp: true prevents anyone from creating a password account through the API.
Callback URLs
Any page can send a user to sign-in and get them back afterwards with a callbackURL query parameter:
/auth/signin?callbackURL=%2Faccept-invitation%2Fabc123useCallbackURL() in features/auth/use-callback-url.ts reads the parameter on the client, and
sanitizeCallbackURL() in features/auth/callback-url.ts only accepts same-origin paths. Anything
else, such as //evil.com or a full URL, falls back to the default:
export function sanitizeCallbackURL(value: unknown, fallback: string): string {
if (typeof value !== "string") return fallback
if (!value.startsWith("/") || value.startsWith("//")) return fallback
if (value.includes("\\") || /[\u0000-\u001f]/.test(value)) return fallback
return value
}The invitation page, the pricing page and the billing page all use this parameter. Always pass
user-provided redirect targets through sanitizeCallbackURL before redirecting.
Last used method
The lastLoginMethod plugin stores the method used for the last sign-in. LastLoginIndicator in
features/auth/last-login-indicator.tsx reads it with authClient.getLastUsedLoginMethod() and shows a
"Last used" badge next to the matching button. A custom resolver in lib/auth.ts maps the email code
sign-in to "email-otp".
Demo account
The demo account lets visitors try the app without creating an account. It is enabled when both
DEMO_EMAIL and DEMO_PASSWORD are set.
Set the credentials
DEMO_EMAIL=demo@example.com
DEMO_PASSWORD=demo-passwordDEMO_PASSWORD must be at least 8 characters.
Seed the account
pnpm db:seed:demoscripts/seed-demo.ts is idempotent. It creates the demo user with a hashed password, a "Demo
Workspace" organization with fake members, and pending invitations.
Use the button
The sign-in page renders a Try the demo button (features/auth/demo-signin-button.tsx). It calls
the signInAsDemo server action in lib/demo-actions.ts, which signs in on the server so the
credentials never reach the browser, then redirects to the sanitized callbackURL or /dashboard.
Destructive endpoints are blocked for the demo user in a before hook in lib/auth.ts, which returns
403 with "This action is disabled on the demo account". The blocked paths include /delete-user,
/change-password, /set-password, /change-email, /update-user, session revocation,
organization delete, leave, update and member removal, 2FA enable and disable, and /api-key/delete.
Server actions use nonDemoAction from lib/safe-action.ts for the same purpose.
DEMO_EMAIL to a real user's address in production: that user would lose access to every blocked action.Protecting pages
Reading the session on the server
lib/auth-server.ts exports two helpers:
/** Deduplicated per request: layouts, loaders and pages share one lookup. */
export const getSession = cache(async () => {
return auth.api.getSession({ headers: await headers() })
})
export const getUser = async () => {
const session = await getSession()
if (!session?.user) {
unauthorized()
}
return session.user
}getSession()returns the session ornull. It is wrapped in Reactcache, so calling it in a layout and a page costs one lookup.getUser()returns the user or callsunauthorized(), which rendersapp/unauthorized.tsx(a 401 page with a sign-in link).
unauthorized() and forbidden() need experimental.authInterrupts, which is enabled in
next.config.mjs. app/forbidden.tsx is the matching 403 page.
The app layout
Every page under app/(app)/ is behind the check in app/(app)/layout.tsx:
export default async function RouteLayout({ children }: LayoutParams) {
const session = await getSession()
if (!session?.user) redirect("/auth/signin")
// ...
}New pages you add under app/(app)/ are protected automatically. Inside them, call getUser() when
you need the user:
export default async function RoutePage() {
const user = await getUser()
// ...
}To send the user back after sign-in, redirect with a callback URL, as the billing page does:
const BILLING_PATH = "/account/billing"
export default async function RoutePage() {
const session = await getSession()
if (!session?.user) redirect(`/auth/signin?callbackURL=${BILLING_PATH}`)
// ...
}Role checks
The admin section checks the role set by the admin plugin in app/(app)/admin/layout.tsx and
redirects everyone else to /dashboard:
const session = await getSession()
if (!session?.user || session.user.role !== "admin") {
redirect("/dashboard")
}For organization pages, use getOrganizationForPage() instead. See
Organizations.
Server actions
Server actions are built with next-safe-action. lib/safe-action.ts
exports three clients:
| Client | Behavior |
|---|---|
action | No auth check. Errors thrown as ActionError are returned to the client, others are logged and replaced by a generic message. |
authAction | Calls getUser() and passes the user as ctx.user. |
nonDemoAction | Same as authAction, but refuses the shared demo account. Use it for destructive actions. |
const updateProfileName = authAction
.inputSchema(z.object({ name: z.string().min(1).max(32) }))
.action(async ({ parsedInput: { name } }) => {
await auth.api.updateUser({
body: { name },
headers: await headers(),
})
revalidatePath("/account")
})When you call auth.api.* on the server on behalf of the user, pass headers: await headers() so
Better Auth can read the session cookie.
Route handlers
In a route handler, read the session from the request headers:
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: request.headers })
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
return NextResponse.json({ userId: session.user.id })
}Client components
Use the authClient.useSession() hook to read the session in client components:
const { data: session, refetch } = authClient.useSession()Client checks are for display only. Always enforce access on the server.
proxy.ts
proxy.ts (the Next.js 16 name for middleware) does not protect routes. It only redirects visitors of
/ to /dashboard when they have a session cookie and the open_app=1 cookie set by the "Always
open the app" preference (lib/open-app-preference.ts). Visiting /?stay bypasses it. It uses
getSessionCookie(), which checks that the cookie exists without a database call, so never rely on it
for authorization.
Account pages
Account settings live under app/(app)/account/:
| Route | Content |
|---|---|
/account | Profile: avatar, name and email (features/account/user-profile-form.tsx). |
/account/security | Password, linked accounts, 2FA and sessions (features/account/security-settings.tsx). |
/account/billing | Personal plan and invoices. See Billing. |
/account/keys | Personal API keys. See API keys. |
/account/notifications | Notification preferences. |
/account/danger | Delete account. |
Profile
The profile form updates the name and avatar through the updateProfileName and updateProfileImage
server actions in lib/account-actions.ts, which call auth.api.updateUser.
Security
app/(app)/account/security/page.tsx loads the sessions and linked accounts on the server with
auth.api.listSessions and auth.api.listUserAccounts, then renders SecuritySettings. From there a
user can:
- Set or change a password. Users who signed up with a code or OAuth have no password. The
setPasswordaction (anonDemoAction) callsauth.api.setPasswordand sends a confirmation email. Users who already have one useauthClient.changePassword. - Link or unlink GitHub and Google with
authClient.linkSocialandauthClient.unlinkAccount. - Manage two-factor authentication (see below).
- Review and revoke sessions (see below).
Two-factor authentication
2FA uses TOTP apps (1Password, Google Authenticator, and so on). Better Auth asks for the password to enable it, so a user must set a password first. The UI shows "Set a password first to enable two-factor authentication." otherwise.
The setup dialog (features/account/two-factor-setup.tsx) runs in three steps:
- The user enters their password.
authClient.twoFactor.enable({ password })returns a TOTP URI and backup codes. - The user scans the QR code and enters a six-digit code, checked with
authClient.twoFactor.verifyTotp. 2FA is switched on only after this step. - The user copies or downloads the backup codes.
Once enabled, the user can show the QR code again (authClient.twoFactor.getTotpUri) or disable 2FA
(authClient.twoFactor.disable), both after entering their password.
Good to know: Better Auth's twoFactor plugin challenges password sign-ins (/sign-in/email).
Sign-ins with an email code or OAuth are not challenged, and the boilerplate does not ship a 2FA
verification page. If you open password sign-in to all users, add a page that calls
authClient.twoFactor.verifyTotp when sign-in returns twoFactorRedirect.
Sessions
features/account/sessions-card.tsx lists active sessions 10 per page with their browser, OS, device
type and last activity date, and marks the current one. Users can:
- Revoke one session with
authClient.revokeSession({ token }). - Revoke every session with
authClient.revokeSessions(). - Clean up sessions inactive for more than 30 days.
Danger zone
app/(app)/account/danger/page.tsx renders a "Delete Account" card. The button is not wired to an
action: account deletion is left for you to implement. Better Auth provides a deleteUser endpoint
that you enable in the user options of lib/auth.ts. Before deleting a user, cancel their Stripe
subscription and handle the organizations they own.
Adding an OAuth provider
This example adds Discord. The same steps apply to any provider supported by Better Auth.
Create the OAuth app
In the provider's developer console, set the redirect URI to:
http://localhost:3000/api/auth/callback/discordUse your production BETTER_AUTH_URL instead of http://localhost:3000 for the production app. GitHub
and Google use the same pattern (/api/auth/callback/github, /api/auth/callback/google).
Declare the environment variables
Add the variables to both the server and runtimeEnv objects in lib/env.ts:
server: {
// ...
DISCORD_CLIENT_ID: z.string().min(1),
DISCORD_CLIENT_SECRET: z.string().min(1),
},
runtimeEnv: {
// ...
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
},Then add the values to .env and .env.example.
Register the provider
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
},Add a button
Copy features/auth/social/github-button.tsx to discord-button.tsx, change provider: "github" to
provider: "discord", the icon and the label, and pass provider="discord" to LastLoginIndicator.
Export it from features/auth/social/index.tsx and render it in both signin-form.tsx and
signup-form.tsx.
Allow linking (optional)
To let users link the provider from /account/security, extend the "google" | "github" union in
handleLinkSocial in features/account/security-settings.tsx and add a row for it.
To remove a provider, do the reverse: delete its entry in socialProviders, its variables in
lib/env.ts, and its button.