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
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.
import { kavach } from '@/lib/kavach';
export const { GET, POST } = kavach.handler;import { kavach } from './lib/kavach';
app.use('/auth', kavach.handler);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.
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
Response — 201 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.
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
Response — 200 OK
{
"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.
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
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:
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:
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
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)
}),Request a reset link
POST /auth/request-reset
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 existsSubmit the new password
POST /auth/reset-password
Your reset page receives the token from the URL. Collect the new password and post both:
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.
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
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:
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