> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kavachos.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Email and password

> Register and sign in with email and password. Full guide covering verification, password reset, and configuration.

The `emailPassword` plugin handles user registration, sign-in, email verification, and password management. Passwords are hashed with scrypt (N=16384, r=8, p=1) using Node's built-in `node:crypto` module, no extra dependencies.

<Warning>
  Because scrypt uses `node:crypto`, the `emailPassword` plugin requires a Node.js runtime. It does not run on Cloudflare Workers, Deno Deploy, or Vercel Edge Functions as-is. If you need edge runtime support, replace the hasher with PBKDF2 via the Web Crypto API using the `password.hash` and `password.verify` options shown below.
</Warning>

<Info>
  If you prefer username-based auth instead of email, see the username plugin.
</Info>

## Setup

<Warning>
  By default, sign-in requires email verification. In development, set `requireVerification: false` to skip this. Without it, users who sign up cannot sign in until they verify their email.
</Warning>

### Dev vs production config

```typescript theme={"system"}
// Development: skip email verification
emailPassword({
  requireVerification: false,
  onSendVerification: async (email, token) => {
    console.log(`Verify: http://localhost:3000/verify?token=${token}`);
  },
})

// Production: require verification
emailPassword({
  requireVerification: true, // default
  onSendVerification: async (email, token, url) => {
    await sendEmail(email, 'Verify your email', `Click: ${url}`);
  },
})
```

<Steps>
  <Step>
    ### Install the plugin

    ```typescript title="lib/kavach.ts" theme={"system"}
    import { createKavach } from 'kavachos';
    import { emailPassword } from 'kavachos/auth'; // [!code highlight]

    const kavach = await createKavach({
      database: { provider: 'postgres', url: process.env.DATABASE_URL! },
      secret: process.env.KAVACH_SECRET!,
      baseUrl: 'https://auth.example.com',
      plugins: [
        emailPassword({ // [!code highlight]
          appUrl: 'https://app.example.com', // [!code highlight]
          requireVerification: true, // [!code highlight]
          sendVerificationEmail: async (email, token, url) => { // [!code highlight]
            await resend.emails.send({ // [!code highlight]
              from: 'auth@example.com', // [!code highlight]
              to: email, // [!code highlight]
              subject: 'Confirm your email', // [!code highlight]
              html: `<a href="${url}">Verify your email</a>`, // [!code highlight]
            }); // [!code highlight]
          }, // [!code highlight]
          sendResetEmail: async (email, token, url) => { // [!code highlight]
            await resend.emails.send({ // [!code highlight]
              from: 'auth@example.com', // [!code highlight]
              to: email, // [!code highlight]
              subject: 'Reset your password', // [!code highlight]
              html: `<a href="${url}">Reset password</a>`, // [!code highlight]
            }); // [!code highlight]
          }, // [!code highlight]
        }), // [!code highlight]
      ],
    });
    ```
  </Step>

  <Step>
    ### Mount the handler

    The plugin registers endpoints automatically. You still need to route incoming requests to KavachOS from your framework adapter.

    <Tabs>
      <Tab title="Next.js">
        ```typescript title="app/api/auth/[...kavach]/route.ts" theme={"system"}
        import { kavach } from '@/lib/kavach';

        export const { GET, POST } = kavach.handler;
        ```
      </Tab>

      <Tab title="Express">
        ```typescript title="server.ts" theme={"system"}
        import { kavach } from './lib/kavach';

        app.use('/auth', kavach.handler);
        ```
      </Tab>

      <Tab title="Hono">
        ```typescript title="src/index.ts" theme={"system"}
        import { kavach } from './lib/kavach';

        app.use('/auth/*', kavach.handler);
        ```
      </Tab>
    </Tabs>
  </Step>
</Steps>

## Sign up

`POST /auth/sign-up`

Creates a new user account. Returns a session token on success. If `requireVerification` is `true`, the user also receives a verification email immediately.

```typescript title="Sign up (client)" theme={"system"}
const res = await fetch('/auth/sign-up', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com', // [!code highlight]
    password: 'correct horse battery', // [!code highlight]
    name: 'Ada Lovelace',
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  console.error(error.code, error.message);
} else {
  const { user, token } = await res.json();
  // token is the session token. Store it in a cookie or local storage
}
```

**Request body**

<ParamField path="email" type="string" required>User email address.</ParamField>
<ParamField path="password" type="string" required>Password. Must meet the configured length and complexity rules.</ParamField>
<ParamField path="name" type="string">Display name for the user.</ParamField>

**Response** `201 Created`

```json theme={"system"}
{
  "user": {
    "id": "usr_abc123",
    "email": "user@example.com",
    "name": "Ada Lovelace",
    "emailVerified": false,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "token": "session-token"
}
```

<Info>
  You can extend the user record with additional fields (e.g. `avatarUrl`, `role`) by hooking into the `onUserCreated` lifecycle. The `name` field is the only optional built-in.
</Info>

## Sign in

`POST /auth/sign-in`

Authenticates a user and returns a session token. Always returns a generic "Invalid email or password" message for wrong credentials. The response does not distinguish between a missing account and a wrong password.

```typescript title="Sign in (client)" theme={"system"}
const res = await fetch('/auth/sign-in', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com', // [!code highlight]
    password: 'correct horse battery', // [!code highlight]
  }),
});

if (res.status === 401) {
  // Either wrong credentials or email not yet verified
  const { error } = await res.json();
  if (error.code === 'EMAIL_NOT_VERIFIED') {
    // prompt the user to check their inbox
  }
} else {
  const { user, session } = await res.json();
  // session.token, session.expiresAt
}
```

**Request body**

<ParamField path="email" type="string" required>User email address.</ParamField>
<ParamField path="password" type="string" required>User password.</ParamField>

**Response** `200 OK`

```json theme={"system"}
{
  "user": { "id": "usr_abc123", "email": "user@example.com", "emailVerified": true },
  "session": {
    "token": "session-token",
    "expiresAt": "2025-01-08T00:00:00.000Z"
  }
}
```

**Error codes**

| Code                  | Status | Meaning                                        |
| --------------------- | ------ | ---------------------------------------------- |
| `INVALID_CREDENTIALS` | 401    | Wrong email or password                        |
| `EMAIL_NOT_VERIFIED`  | 401    | Account exists but email has not been verified |

## Sign out

`POST /auth/sign-out`

Revokes the current session. The session cookie is cleared by the server.

```typescript title="Sign out (client)" theme={"system"}
await fetch('/auth/sign-out', {
  method: 'POST',
  credentials: 'include',
});

// Redirect to home or login
window.location.href = '/';
```

## Email verification

When `requireVerification: true`, sign-in returns `401` with code `EMAIL_NOT_VERIFIED` until the user clicks the link sent at registration. The link hits your app's callback URL, which should POST the token to `/auth/verify-email`.

### Enable verification

```typescript title="lib/kavach.ts" theme={"system"}
emailPassword({
  appUrl: 'https://app.example.com',
  requireVerification: true, // [!code highlight]
  sendVerificationEmail: async (email, token, url) => { // [!code highlight]
    // `url` is already constructed: appUrl + /auth/verify-email?token=...
    await mailer.send({ to: email, subject: 'Verify your email', html: `<a href="${url}">Confirm</a>` });
  },
}),
```

### Handle the callback

Your app receives the token in the URL. Send it to the endpoint:

```typescript title="app/auth/verify-email/page.tsx (Next.js)" theme={"system"}
const token = new URL(window.location.href).searchParams.get('token');

await fetch('/auth/verify-email', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token }), // [!code highlight]
});
```

### Resend verification

If the user lost the email, they can trigger a new one:

```typescript title="Resend verification (client)" theme={"system"}
await fetch('/auth/request-reset', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'user@example.com' }),
});
```

<Warning>
  The `sendVerificationEmail` callback runs synchronously during sign-up. If your email provider is slow, use fire-and-forget: wrap the send call in `void someEmailFn(...)` so it does not block the response. Make sure you have proper error logging so failures are visible.
</Warning>

## Email enumeration protection

When `requireVerification: true`, the sign-up endpoint returns `201` even if the email is already registered. The existing user gets a "someone tried to create an account with your email" notification instead of an error being exposed to the attacker.

This prevents an attacker from harvesting valid email addresses by observing which registrations fail with `409 Conflict`.

The password reset endpoint (`/auth/request-reset`) follows the same pattern. It always returns `200` regardless of whether the email exists.

See [OWASP: Prevent Username Enumeration](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses) for background.

## Password reset

<Steps>
  <Step>
    ### Configure the reset email

    ```typescript title="lib/kavach.ts" theme={"system"}
    emailPassword({
      appUrl: 'https://app.example.com',
      sendResetEmail: async (email, token, url) => { // [!code highlight]
        await mailer.send({
          to: email,
          subject: 'Reset your password',
          html: `<a href="${url}">Reset password</a> (expires in 1 hour)`,
        });
      },
      resetExpiry: 3600, // seconds, default 3600 (1 hour) // [!code highlight]
    }),
    ```
  </Step>

  <Step>
    ### Request a reset link

    `POST /auth/request-reset`

    ```typescript title="Request reset (client)" theme={"system"}
    await fetch('/auth/request-reset', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: 'user@example.com' }), // [!code highlight]
    });
    // Always returns { success: true }. Never reveals if the email exists
    ```
  </Step>

  <Step>
    ### Submit the new password

    `POST /auth/reset-password`

    Your reset page receives the token from the URL. Collect the new password and post both:

    ```typescript title="Reset password (client)" theme={"system"}
    const token = new URL(window.location.href).searchParams.get('token');

    const res = await fetch('/auth/reset-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        token, // [!code highlight]
        newPassword: 'new-strong-password', // [!code highlight]
      }),
    });
    ```

    On success, all sessions for that user are revoked. They will need to sign in again.
  </Step>
</Steps>

<Warning>
  Rate limiting on `/auth/request-reset` is set to 3 requests per 60 seconds by default. Do not remove it. Without a rate limit, the endpoint can be used to spam users with reset emails.
</Warning>

## Change password

`POST /auth/change-password`

Requires an active session. The endpoint reads the user identity from the session, no `userId` in the request body.

```typescript title="Change password (client)" theme={"system"}
const res = await fetch('/auth/change-password', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    currentPassword: 'old-password', // [!code highlight]
    newPassword: 'new-stronger-password', // [!code highlight]
  }),
});
```

**Request body**

<ParamField path="currentPassword" type="string" required>The user's current password.</ParamField>
<ParamField path="newPassword" type="string" required>The new password. Must meet configured strength requirements.</ParamField>

The current session stays active after a successful change. Other sessions for the same user are not revoked (unlike a password reset). If you want to sign out other devices, revoke sessions explicitly via the session management API.

## Password configuration

Passwords are hashed with **scrypt** using Node's built-in `node:crypto` module (N=16384, r=8, p=1, keylen=64 bytes). These parameters match OWASP interactive login recommendations and require no external dependencies.

### Custom strength rules

```typescript title="lib/kavach.ts" theme={"system"}
emailPassword({
  password: {
    minLength: 12, // [!code highlight]
    maxLength: 128, // [!code highlight]
    requireUppercase: true, // [!code highlight]
    requireNumber: true, // [!code highlight]
    requireSpecial: false,
  },
}),
```

### Custom hash function

If you need a different algorithm, bring your own hash and verify functions.

**Edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge):** use PBKDF2 via the Web Crypto API, which is available everywhere:

```typescript title="lib/kavach.ts" theme={"system"}
emailPassword({
  password: {
    hash: async (password) => { // [!code highlight]
      const enc = new TextEncoder();
      const keyMaterial = await crypto.subtle.importKey(
        'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'],
      );
      const salt = crypto.getRandomValues(new Uint8Array(32));
      const derived = await crypto.subtle.deriveBits(
        { name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 600_000 },
        keyMaterial, 512,
      );
      const toHex = (b: Uint8Array) => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
      return `pbkdf2:600000:${toHex(salt)}:${toHex(new Uint8Array(derived))}`; // [!code highlight]
    },
    verify: async (password, stored) => { // [!code highlight]
      const [, iterations, saltHex, hashHex] = stored.split(':');
      const fromHex = (s: string) => new Uint8Array(s.match(/.{2}/g)!.map(b => parseInt(b, 16)));
      const enc = new TextEncoder();
      const keyMaterial = await crypto.subtle.importKey(
        'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'],
      );
      const derived = await crypto.subtle.deriveBits(
        { name: 'PBKDF2', hash: 'SHA-256', salt: fromHex(saltHex), iterations: parseInt(iterations, 10) },
        keyMaterial, 512,
      );
      const toHex = (b: Uint8Array) => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
      return toHex(new Uint8Array(derived)) === hashHex; // [!code highlight]
    },
  },
}),
```

**Node.js with argon2id:** use `@node-rs/argon2` for stronger memory-hard hashing on full Node.js servers:

```typescript title="lib/kavach.ts" theme={"system"}
import { hash, verify } from '@node-rs/argon2';

emailPassword({
  password: {
    hash: async (password) => { // [!code highlight]
      return hash(password, { // [!code highlight]
        memoryCost: 65536, // [!code highlight]
        timeCost: 3, // [!code highlight]
        parallelism: 4, // [!code highlight]
      }); // [!code highlight]
    }, // [!code highlight]
    verify: async (password, stored) => { // [!code highlight]
      return verify(stored, password); // [!code highlight]
    }, // [!code highlight]
  },
}),
```

<Info>
  The default scrypt implementation is fine for most Node.js deployments. Use a custom hasher when you are migrating from an existing system, need edge runtime support, or want stronger memory-hard guarantees.
</Info>

## Configuration reference

<ParamField path="appUrl" type="string" required>Base URL for verification and reset links. Links are constructed as appUrl + /auth/verify-email?token=... and appUrl + /auth/reset-password?token=...</ParamField>
<ParamField path="sendVerificationEmail" type={`(email: string, token: string, url: string) => Promise<void>`} required>Called after sign-up with the recipient email, raw token, and fully constructed URL.</ParamField>
<ParamField path="sendResetEmail" type={`(email: string, token: string, url: string) => Promise<void>`} required>Called when a user requests a password reset. Same signature as sendVerificationEmail.</ParamField>
<ParamField path="requireVerification" type="boolean" default="true">Block sign-in until the user clicks the email verification link.</ParamField>
<ParamField path="verificationExpiry" type="number" default="86400 (24h)">Verification token lifetime in seconds.</ParamField>
<ParamField path="resetExpiry" type="number" default="3600 (1h)">Password reset token lifetime in seconds.</ParamField>
