Authentication Methods

Configure email/password, magic link, passkey, and OAuth authentication methods in your SaaS application.

Enable the authentication methods your users expect. The kit supports email/password, magic links (passwordless), passkeys (WebAuthn), and OAuth providers - toggle each with environment variables. This guidance applies to the Drizzle kit.

This page is part of the Authentication documentation.

Available Methods

MethodDefaultUse Case
Email/PasswordEnabledTraditional sign-in with credentials
Magic LinkDisabledPasswordless sign-in via email link
PasskeyDisabledPasswordless sign-in via device biometrics / security key
OAuthDisabledSocial login (Google by default in this repo)

Most SaaS apps start with email/password. Add magic links for users who forget passwords frequently. Add passkeys for phishing-resistant, passwordless sign-in. Add OAuth to reduce sign-up friction.

Email and Password Authentication

Email/password is the most common auth method. Users register with their email and a password, then sign in with those credentials.

Enable/Disable

apps/web/.env.local

NEXT_PUBLIC_AUTH_PASSWORD=true

Set to false to disable email/password authentication entirely (useful if you only want OAuth).

Password Requirements

Configure password complexity rules:

apps/web/.env.local

NEXT_PUBLIC_PASSWORD_MIN_LENGTH=8
NEXT_PUBLIC_PASSWORD_MAX_LENGTH=99
NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS=true
NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS=true
NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE=true
VariableDefaultDescription
NEXT_PUBLIC_PASSWORD_MIN_LENGTH8Minimum password length
NEXT_PUBLIC_PASSWORD_MAX_LENGTH99Maximum password length
NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARStrueRequire special characters
NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERStrueRequire at least one number
NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASEtrueRequire uppercase letter

Email Verification

Email verification is required by default. Users receive a verification email after registration and must click the link before accessing the app.

The verification flow:

  1. User registers with email/password
  2. Better Auth sends verification email
  3. User clicks verification link
  4. User is automatically signed in

Email verification protects against:

  • Fake accounts with invalid emails
  • Account takeover via typo-squatting
  • Spam registrations

Password Reset

Users can reset forgotten passwords:

  1. User clicks "Forgot password" on sign-in form
  2. User enters their email address
  3. Better Auth sends reset email with secure link
  4. User clicks link and sets new password
  5. User is signed in with new password

Reset links expire after a configurable time (default: 1 hour).

Magic links provide passwordless authentication. Users enter their email, receive a link, and click to sign in - no password needed.

apps/web/.env.local

NEXT_PUBLIC_AUTH_MAGIC_LINK=true

This flag is a real boundary, not just a UI toggle: when it's false the magic-link plugin is not registered server-side, so the /sign-in/magic-link endpoint does not exist. The same applies to password (NEXT_PUBLIC_AUTH_PASSWORD). OTP is the exception — it is shared infrastructure for email verification and password reset, so it is always registered. Passkeys are gated differently because they add a database table — see Enable Passkeys.

How It Works

  1. User enters email on sign-in form
  2. User clicks "Sign in with Magic Link"
  3. Better Auth sends email with secure link
  4. User clicks link in email
  5. User is signed in automatically

Implementation

The magic link plugin is configured in packages/better-auth/src/plugins/magic-link.ts:

import { magicLink } from 'better-auth/plugins/magic-link';
export const magicLinkPlugin = magicLink({
sendMagicLink: async ({ email, url }) => {
const { sendMagicLinkEmail } =
await import('../emails/send-magic-link-email');
// Log in development mode
if (process.env.NODE_ENV === 'development') {
console.log(`[DEV] Magic link for ${email}: ${url}`);
}
await sendMagicLinkEmail({
email,
url,
productName: getProductName(),
});
},
});

In development, magic links are logged to the console for easy testing without checking email.

Good for:

  • Users who frequently forget passwords
  • Low-friction onboarding
  • Apps where security > convenience (each sign-in requires email access)

Consider alternatives when:

  • Users sign in frequently (magic links add friction)
  • Email delivery is unreliable
  • Users need offline access

Passkey Authentication

Passkeys (WebAuthn) let users sign in with device biometrics (Face ID, Touch ID, Windows Hello) or a hardware security key - no password, and phishing-resistant by design.

Enable Passkeys

Passkeys are disabled by default. Unlike the other methods, passkeys add a database table (passkey), so enabling them is a provisioning decision that must be reflected in the schema — not a single runtime flag. Enabling takes two knobs:

1. Provision (table + server plugin) — flip the committed constant, then regenerate and migrate:

packages/better-auth/src/auth.features.ts

export const ENABLE_PASSKEY = true;
# regenerate the Better Auth schema, then the migration
pnpm --filter @kit/better-auth schema:generate
pnpm --filter @kit/database drizzle:generate
pnpm --filter @kit/database drizzle:migrate

ENABLE_PASSKEY is the single source of truth shared by schema generation (config.ts) and the runtime (auth.ts): it both emits the passkey table and registers the /passkey/* endpoints. It is a committed constant — not an env var — so the generated schema is deterministic and can't drift from the runtime.

2. Show it in the UI — controls only whether the passkey controls render:

apps/web/.env.local

NEXT_PUBLIC_AUTH_PASSKEY=true

When both are set:

  • A "Sign in with a passkey" button appears on the sign-in page, alongside the other enabled methods.
  • A Passkeys card appears under Settings → Security, where signed-in users register, view, and remove passkeys.

Because a passkey is bound to an existing account, users first sign up (or sign in) with another method, then register a passkey from their security settings. After that, they can sign in with the passkey directly.

How It Works

  1. User signs in with email/password (or another method)
  2. User opens Settings → Security and clicks Add a passkey
  3. The browser prompts to create a passkey (biometrics / security key)
  4. On the next sign-in, the user clicks Sign in with a passkey and authenticates with their device

Implementation

The passkey plugin is built on top of @better-auth/passkey and configured in packages/better-auth/src/plugins/passkey.ts:

import { passkey } from '@better-auth/passkey';
export function createPasskeyPlugin() {
const siteUrl = new URL(env('NEXT_PUBLIC_SITE_URL'));
return passkey({
// Relying party ID: a registrable domain, no scheme/port
rpID: siteUrl.hostname,
rpName: getProductName(),
origin: siteUrl.origin,
});
}

The plugin is registered on both the server (auth.ts) and the client (auth-client.ts), like magic link and OTP. Registration adds a passkey table to the database (see packages/database/src/schema/core.ts) - run pnpm --filter @kit/database drizzle:generate and apply the migration after pulling the change.

rpID is derived from NEXT_PUBLIC_SITE_URL. In development it resolves to localhost; in production it must be your registrable domain (e.g. app.example.com). A mismatch between rpID/origin and the URL the user is visiting causes the browser to reject the passkey.

When to Use Passkeys

Good for:

  • Phishing-resistant, passwordless sign-in
  • Users on modern devices with biometrics
  • Reducing password-reset support load

Consider alternatives when:

  • Users are on shared/older devices without authenticators
  • You need a method available before account creation (passkeys are registered post sign-up)

Client Usage

Sign In with Password

import { authClient } from '@kit/better-auth/client';
const result = await authClient.signIn.email({
email: 'user@example.com',
password: 'securepassword123',
});
if (result.error) {
console.error('Sign in failed:', result.error.message);
}
import { authClient } from '@kit/better-auth/client';
// Request magic link
await authClient.signIn.magicLink({
email: 'user@example.com',
callbackURL: '/dashboard',
});
// User receives email and clicks link to complete sign-in

Register and Sign In with a Passkey

import { authClient } from '@kit/better-auth/client';
// While signed in: register a passkey for the current user
await authClient.passkey.addPasskey({ name: 'MacBook Touch ID' });
// List the user's passkeys
const { data: passkeys } = await authClient.passkey.listUserPasskeys();
// Remove a passkey
await authClient.passkey.deletePasskey({ id: passkeyId });
// On a later visit: sign in with a registered passkey
await authClient.signIn.passkey();

The kit wraps these calls in hooks under @kit/auth/hooks (use-add-passkey, use-list-passkeys, use-delete-passkey, use-sign-in-with-passkey).

Sign Up

import { authClient } from '@kit/better-auth/client';
const result = await authClient.signUp.email({
email: 'user@example.com',
password: 'securepassword123',
name: 'John Doe',
});
if (result.error) {
console.error('Sign up failed:', result.error.message);
}

Password Reset

import { authClient } from '@kit/better-auth/client';
// Request password reset
await authClient.forgetPassword({
email: 'user@example.com',
redirectTo: '/auth/password-reset',
});
// After user clicks email link and enters new password
await authClient.resetPassword({
newPassword: 'newSecurePassword123',
});

Common Pitfalls

  • Disabling all auth methods: At least one method must be enabled.
  • Weak password requirements in production: Use strong defaults. Weak passwords are a security risk.
  • Not testing email delivery: Magic links and verification emails require working email configuration.
  • Missing NEXT_PUBLIC_ prefix: Auth method toggles need the public prefix to work on the client.
  • Wrong passkey rpID/origin in production: Passkeys are bound to the relying party domain. Set NEXT_PUBLIC_SITE_URL to your real domain or the browser rejects the passkey.
  • Forgetting the passkey migration: Enabling passkeys adds a passkey table. Generate and apply the migration before going live.

Frequently Asked Questions

Can I enable both password and magic link authentication?
Yes. Users can choose their preferred method on the sign-in form. The UI adapts to show available options based on environment variables.
How do I customize password requirements?
Set the NEXT_PUBLIC_PASSWORD_* environment variables. Changes apply to new registrations and password changes.
What happens if email verification fails?
Users can request a new verification email. The original link expires but the account remains valid.
Can I disable email verification?
Yes, but this is not recommended for production. Set requireEmailVerification to false in auth.ts. This allows unverified accounts to access the app.
How long do magic links last?
Magic links expire after a configurable time (default varies by Better Auth version). Users can request a new link if theirs expires.
Why don't passkeys appear on the sign-up page?
A passkey is tied to an existing account, so users register one from Settings → Security after signing up. The sign-up page intentionally omits passkeys; the sign-in page shows the 'Sign in with a passkey' button once enabled.
Do passkeys require extra packages?
The kit depends on @better-auth/passkey, which bundles the WebAuthn implementation. It is already wired in packages/better-auth - set ENABLE_PASSKEY=true in auth.features.ts, regenerate the schema and migrate, then set NEXT_PUBLIC_AUTH_PASSKEY=true to show the UI.

Next: Social Providers →