kavachOS

One-time tokens

Single-use tokens for email verification, password resets, invitations, and custom flows.

One-time tokens are short-lived, single-use strings for flows like email verification, password resets, and invitations. The raw token is handed to the caller once and never stored — only a SHA-256 hash lives in the database. On first use (or expiry), the token is gone.

Setup

The module is part of KavachOS core. No extra plugin needed.

lib/kavach.ts
import { createKavach } from 'kavachos';

const kavach = await createKavach({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.KAVACH_SECRET!,
  baseUrl: 'https://auth.example.com',
});

// Access the module
const tokens = kavach.oneTimeTokens;

Token purposes

Each token has a purpose that scopes its validity. Validation fails if the purpose at creation does not match the purpose at consumption.

PurposeUse
email-verifyConfirm a new email address
password-resetAuthenticate a password-reset request
invitationInvite a user to an org or workspace
customAny application-specific flow

Creating a token

createToken returns the raw token exactly once. Put it in a link or hand it to your mailer — there is no way to recover it from the database later.

const result = await tokens.createToken({
  purpose: 'password-reset',
  identifier: 'alice@example.com', // email, user ID, or any scoping key
  ttlSeconds: 1800,                // 30 minutes
});

if (result.success) {
  const { token, expiresAt } = result.data;
  await mailer.send({
    to: 'alice@example.com',
    subject: 'Reset your password',
    html: `<a href="https://app.example.com/reset?token=${token}">Reset password</a>`,
  });
}
const result = await tokens.createToken({
  purpose: 'email-verify',
  identifier: user.id,
  ttlSeconds: 86400, // 24 hours
});

if (result.success) {
  await sendVerificationEmail(user.email, result.data.token);
}
const result = await tokens.createToken({
  purpose: 'invitation',
  identifier: 'invited@example.com',
  ttlSeconds: 604800, // 7 days
  metadata: { orgId: 'org_abc123', role: 'member' }, 
});

The default TTL is 3600 seconds (1 hour). Override it per call with ttlSeconds, or set defaultTtlSeconds on the module config to change the default for all tokens.

Validating a token

Call validateToken when the user lands on your reset or verification page. On success, the token is consumed immediately — a second call with the same token always fails.

const result = await tokens.validateToken(
  incomingToken, // from the URL query param
  'password-reset',
);

if (!result.success) {
  // result.error.code is one of:
  // TOKEN_NOT_FOUND | TOKEN_ALREADY_USED | TOKEN_EXPIRED | TOKEN_PURPOSE_MISMATCH
  return { error: result.error.message };
}

const { identifier, metadata } = result.data;
// identifier === 'alice@example.com'
// Proceed with the reset

validateToken marks the token as used before it returns. Even if your handler crashes after this call, the token cannot be reused. Handle the downstream action (password update, email confirmation) in the same request.

Revoking tokens

Revoke all active tokens for an identifier when a user takes an action that makes them obsolete — for example, invalidating outstanding reset links when a user changes their password through a different flow.

// Revoke all active password-reset tokens for this user
const result = await tokens.revokeTokens('alice@example.com', 'password-reset');

if (result.success) {
  console.log(`Revoked ${result.data.count} token(s)`);
}

// Revoke everything for this identifier (all purposes)
await tokens.revokeTokens(user.id);

Revocation is a soft operation — tokens are marked as used, not deleted. Expired tokens are excluded from the count.

Attaching metadata

Pass a metadata object to store arbitrary data alongside the token. It is returned on successful validation.

const result = await tokens.createToken({
  purpose: 'invitation',
  identifier: 'bob@example.com',
  metadata: { orgId: 'org_xyz', role: 'admin', invitedBy: 'alice' },
});

// On validation:
const validation = await tokens.validateToken(token, 'invitation');
if (validation.success) {
  const { orgId, role } = validation.data.metadata as { orgId: string; role: string };
  await addUserToOrg(user.id, orgId, role);
}

Error codes

CodeCause
TOKEN_NOT_FOUNDToken does not exist or was already deleted
TOKEN_ALREADY_USEDToken was consumed by a previous call
TOKEN_EXPIREDToken's expiresAt is in the past
TOKEN_PURPOSE_MISMATCHPurpose at validation does not match purpose at creation
INVALID_INPUTEmpty token string or unknown purpose value
CREATE_TOKEN_FAILEDDatabase write failed
REVOKE_TOKENS_FAILEDDatabase update failed during revocation

Security notes

Tokens are hashed at rest. Only a SHA-256 hash is stored. A database dump does not expose usable tokens.

Single-use enforcement is atomic. The mark-as-used update runs before the result is returned, with a conditional WHERE used = false. Concurrent requests for the same token will fail at the database level.

Purpose binding prevents cross-flow reuse. A password-reset token cannot be submitted to an email-verify endpoint.

On this page