@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:
| Option | Type | Default | Description |
|---|---|---|---|
store | OtpStore | In-memory store | Backend for persisting OTP records |
ttlSeconds | number | OTP_TOKEN_TTL_SECONDS env var | Time-to-live in seconds |
codeLength | number | 6 | Number of digits in the OTP code |
Types
OtpPurpose
type OtpPurpose = stringA 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:
| Reason | Description |
|---|---|
not_found | No OTP record exists with the given ID |
expired | The OTP has passed its TTL |
consumed | The OTP has already been used |
mismatch | The provided code does not match |
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
OTP_TOKEN_TTL_SECONDS | No | -- | 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
}