# Authentication Methods

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

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/better-auth/auth-methods*

---

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](/drizzle) kit.

This page is part of the [Authentication documentation](./overview).

## 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

```bash {% title="apps/web/.env" %}
VITE_AUTH_PASSWORD=true
```

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

### Password Requirements

Configure password complexity rules:

```bash {% title="apps/web/.env" %}
VITE_PASSWORD_MIN_LENGTH=8
VITE_PASSWORD_MAX_LENGTH=99
VITE_PASSWORD_REQUIRE_SPECIAL_CHARS=true
VITE_PASSWORD_REQUIRE_NUMBERS=true
VITE_PASSWORD_REQUIRE_UPPERCASE=true
```

| Variable | Default | Description |
|----------|---------|-------------|
| `VITE_PASSWORD_MIN_LENGTH` | 8 | Minimum password length |
| `VITE_PASSWORD_MAX_LENGTH` | 99 | Maximum password length |
| `VITE_PASSWORD_REQUIRE_SPECIAL_CHARS` | false | Require special characters |
| `VITE_PASSWORD_REQUIRE_NUMBERS` | false | Require at least one number |
| `VITE_PASSWORD_REQUIRE_UPPERCASE` | false | 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:

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

```bash {% title="apps/web/.env" %}
VITE_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 (`VITE_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](#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`:

```typescript
import { getProductName } from '../utils/product-name';

export const magicLinkPlugin = async function () {
  const { magicLink } = await import('better-auth/plugins/magic-link');

  return 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(),
      });
    },
  });
};
```

The plugin is an async factory: `auth.ts` registers it only when `VITE_AUTH_MAGIC_LINK=true` (via a helper whose disabled branch returns `[] as never`), so a disabled magic-link method has no `/sign-in/magic-link` endpoint at all.

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:

```typescript {% title="packages/better-auth/src/auth.features.ts" %}
export const ENABLE_PASSKEY = true;
```

```bash
# 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:

```bash {% title="apps/web/.env" %}
VITE_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.

{% callout type="warning" title="Set the constant first" %}
`VITE_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.
{% /callout %}

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`](https://www.npmjs.com/package/@better-auth/passkey) and configured in `packages/better-auth/src/plugins/passkey.ts`:

```typescript
import { getProductName } from '../utils/product-name';

export async function createPasskeyPlugin(siteUrlValue: string) {
  const siteUrl = new URL(siteUrlValue);

  const { passkey } = await import('@better-auth/passkey');

  return passkey({
    // Relying party ID: a registrable domain, no scheme/port
    rpID: siteUrl.hostname,
    rpName: getProductName(),
    origin: siteUrl.origin,
  });
}
```

`auth.ts` calls `createPasskeyPlugin(authConfig.baseURL)` (the validated `VITE_SITE_URL`) and only registers it when the `passkey` toggle is on; the client plugin (`passkeyClient()`) is always present in `auth-client.ts`. 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 `VITE_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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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 `VITE_` 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 `VITE_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.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Can I enable both password and magic link authentication?", "answer": "Yes. Users can choose their preferred method on the sign-in form. The UI adapts to show available options based on environment variables."},
     {"question": "How do I customize password requirements?", "answer": "Set the VITE_PASSWORD_* environment variables. Changes apply to new registrations and password changes."},
     {"question": "What happens if email verification fails?", "answer": "Users can request a new verification email. The original link expires but the account remains valid."},
     {"question": "Can I disable email verification?", "answer": "Yes, but this is not recommended for production. Set requireEmailVerification to false in auth.ts. This allows unverified accounts to access the app."},
     {"question": "How long do magic links last?", "answer": "Magic links expire after a configurable time (default varies by Better Auth version). Users can request a new link if theirs expires."},
     {"question": "Why don't passkeys appear on the sign-up page?", "answer": "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."},
{"question": "Do passkeys require extra packages?", "answer": "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 VITE_AUTH_PASSKEY=true to show the UI."}
   ]
/%}

---

**Next:** [Social Providers →](./social-providers)
