Raypx

@raypx/otp

One-time password service for verification flows.

@raypx/otp provides a purpose-based one-time password (OTP) service for email and phone verification flows. It generates, stores, and verifies numeric codes with configurable TTL and code length.

Core API

import { createOtpService } from "@raypx/otp"

const otp = createOtpService()

// Issue a code
const record = await otp.issue({
  purpose: "email-verification",
  recipient: "user@example.com",
})

// Verify a code
const result = await otp.verify({
  id: record.id,
  code: "123456",
  consume: true, // marks as used after successful verification
})

if (result.ok) {
  // Verification succeeded
} else {
  // result.reason: "not_found" | "expired" | "consumed" | "mismatch"
}

Factory

createOtpService(options?) creates an OTP service with the following options:

OptionTypeDefaultDescription
storeOtpStoreIn-memory storeBackend for persisting OTP records
ttlSecondsnumberOTP_TOKEN_TTL_SECONDS env varTime-to-live in seconds
codeLengthnumber6Number of digits in the OTP code

Types

OtpPurpose

type OtpPurpose = string

A free-form string that identifies the verification context. Common values include "email-verification", "phone-verification", and "password-reset". The purpose field is informational and not used for enforcement -- it is stored alongside the record for auditing.

OtpRecord

type OtpRecord = {
  id: string          // UUID v4
  purpose: OtpPurpose // e.g., "email-verification"
  recipient: string   // e.g., "user@example.com"
  code: string        // numeric string, e.g., "739281"
  expiresAt: Date
  consumedAt?: Date
}

OtpService

interface OtpService {
  issue(input: { purpose: OtpPurpose; recipient: string; code?: string }): Promise<OtpRecord>
  verify(input: { id: string; code: string; consume?: boolean }): Promise<{
    ok: boolean
    reason?: "not_found" | "expired" | "consumed" | "mismatch"
  }>
}

OtpStore

interface OtpStore {
  create(record: OtpRecord): Promise<void>
  getById(id: string): Promise<OtpRecord | null>
  markConsumed(id: string, consumedAt: Date): Promise<void>
}

The default store is an in-memory implementation (createMemoryOtpStore()). For production, you should implement a database-backed store.

Verification Result Reasons

When verify() returns { ok: false }, the reason field indicates why:

ReasonDescription
not_foundNo OTP record exists with the given ID
expiredThe OTP has passed its TTL
consumedThe OTP has already been used
mismatchThe provided code does not match

Environment Variables

VariableRequiredDefaultDescription
OTP_TOKEN_TTL_SECONDSNo--TTL for OTP codes in seconds

Full Usage Example

import { createOtpService } from "@raypx/otp"

const otp = createOtpService({ ttlSeconds: 300, codeLength: 6 })

// 1. User requests verification
const record = await otp.issue({
  purpose: "email-verification",
  recipient: "user@example.com",
})

// 2. Send the code to the user (e.g., via email)
await sendEmail({
  to: record.recipient,
  subject: "Your verification code",
  text: `Your code is ${record.code}`,
})

// 3. User submits the code
const result = await otp.verify({
  id: record.id,
  code: userInputCode,
  consume: true,
})

if (result.ok) {
  // Mark email as verified
} else if (result.reason === "expired") {
  // Prompt user to request a new code
} else if (result.reason === "mismatch") {
  // Show error
}

On this page