kavachOS
Authentication

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 crypto module — no extra dependencies.

If you prefer username-based auth instead of email, see the username plugin.

Setup

Install the plugin

lib/kavach.ts
import { createKavach } from 'kavachos';
import { emailPassword } from 'kavachos/plugins/email-password'; 

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

Mount the handler

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

app/api/auth/[...kavach]/route.ts
import { kavach } from '@/lib/kavach';

export const { GET, POST } = kavach.handler;
server.ts
import { kavach } from './lib/kavach';

app.use('/auth', kavach.handler);
src/index.ts
import { kavach } from './lib/kavach';

app.use('/auth/*', kavach.handler);

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.

Sign up (client)
const res = await fetch('/auth/sign-up', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com', 
    password: 'correct horse battery', 
    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 in a cookie or local storage
}

Request body

Prop

Type

Response201 Created

{
  "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"
}

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.

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.

Sign in (client)
const res = await fetch('/auth/sign-in', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com', 
    password: 'correct horse battery', 
  }),
});

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

Prop

Type

Response200 OK

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

Error codes

CodeStatusMeaning
INVALID_CREDENTIALS401Wrong email or password
EMAIL_NOT_VERIFIED401Account 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.

Sign out (client)
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

lib/kavach.ts
emailPassword({
  appUrl: 'https://app.example.com',
  requireVerification: true, 
  sendVerificationEmail: async (email, token, url) => { 
    // `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:

app/auth/verify-email/page.tsx (Next.js)
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 }), 
});

Resend verification

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

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

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.

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 for background.

Password reset

Configure the reset email

lib/kavach.ts
emailPassword({
  appUrl: 'https://app.example.com',
  sendResetEmail: async (email, token, url) => { 
    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)
}),

POST /auth/request-reset

Request reset (client)
await fetch('/auth/request-reset', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'user@example.com' }), 
});
// Always returns { success: true } — never reveals if the email exists

Submit the new password

POST /auth/reset-password

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

Reset password (client)
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, 
    newPassword: 'new-strong-password', 
  }),
});

On success, all sessions for that user are revoked. They will need to sign in again.

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.

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.

Change password (client)
const res = await fetch('/auth/change-password', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    currentPassword: 'old-password', 
    newPassword: 'new-stronger-password', 
  }),
});

Request body

Prop

Type

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

lib/kavach.ts
emailPassword({
  password: {
    minLength: 12, 
    maxLength: 128, 
    requireUppercase: true, 
    requireNumber: true, 
    requireSpecial: false,
  },
}),

Custom hash function

If you need a different algorithm (e.g. argon2id on a server where it is supported), bring your own hash and verify functions:

lib/kavach.ts
import { hash, verify } from '@node-rs/argon2';

emailPassword({
  password: {
    hash: async (password) => { 
      return hash(password, { 
        memoryCost: 65536, 
        timeCost: 3, 
        parallelism: 4, 
      }); 
    }, 
    verify: async (password, stored) => { 
      return verify(stored, password); 
    }, 
  },
}),

The default scrypt implementation covers the vast majority of use cases. Custom hashers are useful when you are migrating from an existing system with a different algorithm, or when running on hardware with different memory constraints.

Configuration reference

Prop

Type

On this page