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
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.
Set the constant first
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 →