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
| Method | Default | Use Case |
|---|---|---|
| Email/Password | Enabled | Traditional sign-in with credentials |
| Magic Link | Disabled | Passwordless sign-in via email link |
| Passkey | Disabled | Passwordless sign-in via device biometrics / security key |
| OAuth | Disabled | Social 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=trueSet 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=8NEXT_PUBLIC_PASSWORD_MAX_LENGTH=99NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS=trueNEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS=trueNEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE=true| Variable | Default | Description |
|---|---|---|
NEXT_PUBLIC_PASSWORD_MIN_LENGTH | 8 | Minimum password length |
NEXT_PUBLIC_PASSWORD_MAX_LENGTH | 99 | Maximum password length |
NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS | true | Require special characters |
NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS | true | Require at least one number |
NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE | true | Require 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:
- User registers with email/password
- Better Auth sends verification email
- User clicks verification link
- User is automatically signed in
Email verification protects against:
- Fake accounts with invalid emails
- Account takeover via typo-squatting
- Spam registrations
If a user registers with a password, never verifies, and then signs in with a magic link or an email OTP, Better Auth treats that sign-in as the first proof of email ownership. It deletes every credential and linked account the user had, revokes their sessions, and only then marks the email verified. The password is gone and must be reset. This is intentional: nothing linked before the email was proven is trusted.
Password Reset
Users can reset forgotten passwords:
- User clicks "Forgot password" on sign-in form
- User enters their email address
- Better Auth sends reset email with secure link
- User clicks link and sets new password
- User is signed in with new password
Reset links expire after a configurable time (default: 1 hour).
Magic Link Authentication
Magic links provide passwordless authentication. Users enter their email, receive a link, and click to sign in - no password needed.
Enable Magic Links
apps/web/.env.local
NEXT_PUBLIC_AUTH_MAGIC_LINK=trueThis 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
- User enters email on sign-in form
- User clicks "Sign in with Magic Link"
- Better Auth sends email with secure link
- User clicks link in email
- 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.
When to Use Magic Links
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 migrationpnpm --filter @kit/better-auth schema:generatepnpm --filter @kit/database drizzle:generatepnpm --filter @kit/database drizzle:migrateENABLE_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=trueWhen 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.
NEXT_PUBLIC_AUTH_PASSKEY=true only shows the UI. If you set it without ENABLE_PASSKEY=true (and the migration applied), the passkey button renders but the endpoints don't exist. Always provision first, then enable the UI.
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
- User signs in with email/password (or another method)
- User opens Settings → Security and clicks Add a passkey
- The browser prompts to create a passkey (biometrics / security key)
- 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);}Sign In with Magic Link
import { authClient } from '@kit/better-auth/client';// Request magic linkawait authClient.signIn.magicLink({ email: 'user@example.com', callbackURL: '/dashboard',});// User receives email and clicks link to complete sign-inRegister and Sign In with a Passkey
import { authClient } from '@kit/better-auth/client';// While signed in: register a passkey for the current userawait authClient.passkey.addPasskey({ name: 'MacBook Touch ID' });// List the user's passkeysconst { data: passkeys } = await authClient.passkey.listUserPasskeys();// Remove a passkeyawait authClient.passkey.deletePasskey({ id: passkeyId });// On a later visit: sign in with a registered passkeyawait 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 resetawait authClient.forgetPassword({ email: 'user@example.com', redirectTo: '/auth/password-reset',});// After user clicks email link and enters new passwordawait 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/originin production: Passkeys are bound to the relying party domain. SetNEXT_PUBLIC_SITE_URLto your real domain or the browser rejects the passkey. - Forgetting the passkey migration: Enabling passkeys adds a
passkeytable. Generate and apply the migration before going live.
Frequently Asked Questions
Can I enable both password and magic link authentication?
How do I customize password requirements?
What happens if email verification fails?
Can I disable email verification?
How long do magic links last?
Why don't passkeys appear on the sign-up page?
Do passkeys require extra packages?
Next: Social Providers →