Launch NowDocs

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.

FileRole
lib/auth.tsServer configuration: database, providers, plugins, hooks and rate limits. Exports auth.
lib/auth-client.tsBrowser client with the matching client plugins. Exports authClient.
lib/auth-server.tsServer helpers getSession() and getUser().
app/api/auth/[...all]/route.tsMounts every Better Auth endpoint under /api/auth/*.
lib/safe-action.tsauthAction and nonDemoAction clients for authenticated server actions.
proxy.tsRedirects 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.tsTables used by Better Auth and its plugins.

The route handler is one line:

app/api/auth/[...all]/route.ts
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:

VariableDescription
BETTER_AUTH_SECRETSecret used to sign cookies and tokens. At least 32 characters (openssl rand -base64 32).
BETTER_AUTH_URLBase URL of the auth server, for example http://localhost:3000. Also used to build invitation links.
NEXT_PUBLIC_APP_URLPublic URL of the app. Used as the only trusted origin and as the client baseURL.
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETGitHub OAuth app credentials. Required.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGoogle OAuth client credentials. Required.
DEMO_EMAIL, DEMO_PASSWORDOptional 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:

lib/auth.ts
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

PluginWhat it addsGuide
emailOTPSix-digit codes sent by email, valid for 10 minutes. This is the main sign-in method.
adminUser roles, bans and impersonation. The /admin section requires role === "admin".
openAPIAn OpenAPI description of the auth endpoints.
organizationOrganizations, 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
lastLoginMethodRemembers 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
twoFactorTOTP two-factor authentication with backup codes.
nextCookiesLets 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:

lib/auth-client.ts
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_up notification 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:

RoutePageForm
/auth/signinapp/auth/signin/page.tsxfeatures/auth/signin-form.tsx
/auth/signupapp/auth/signup/page.tsxfeatures/auth/signup-form.tsx
/auth/onboardingapp/auth/onboarding/page.tsxfeatures/onboarding/onboarding-form.tsx

Both forms work the same way. The user enters an email, receives a six-digit code, then types it in:

features/auth/signin-form.tsx
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:

features/auth/signup-form.tsx
<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%2Fabc123

useCallbackURL() 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:

features/auth/callback-url.ts
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

.env
DEMO_EMAIL=demo@example.com
DEMO_PASSWORD=demo-password

DEMO_PASSWORD must be at least 8 characters.

Seed the account

pnpm db:seed:demo

scripts/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.

Don't set 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:

lib/auth-server.ts
/** 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 or null. It is wrapped in React cache, so calling it in a layout and a page costs one lookup.
  • getUser() returns the user or calls unauthorized(), which renders app/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:

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:

app/(app)/account/page.tsx
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:

app/(app)/account/billing/page.tsx
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:

app/(app)/admin/layout.tsx
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:

ClientBehavior
actionNo auth check. Errors thrown as ActionError are returned to the client, others are logged and replaced by a generic message.
authActionCalls getUser() and passes the user as ctx.user.
nonDemoActionSame as authAction, but refuses the shared demo account. Use it for destructive actions.
lib/account-actions.ts
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:

app/api/example/route.ts
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/:

RouteContent
/accountProfile: avatar, name and email (features/account/user-profile-form.tsx).
/account/securityPassword, linked accounts, 2FA and sessions (features/account/security-settings.tsx).
/account/billingPersonal plan and invoices. See Billing.
/account/keysPersonal API keys. See API keys.
/account/notificationsNotification preferences.
/account/dangerDelete 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 setPassword action (a nonDemoAction) calls auth.api.setPassword and sends a confirmation email. Users who already have one use authClient.changePassword.
  • Link or unlink GitHub and Google with authClient.linkSocial and authClient.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:

  1. The user enters their password. authClient.twoFactor.enable({ password }) returns a TOTP URI and backup codes.
  2. 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.
  3. 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/discord

Use 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:

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

lib/auth.ts
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.

Next steps