npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

better-auth-email-otp-reliable

v1.0.1

Published

A reliable email OTP plugin for Better Auth

Readme

better-auth-email-otp-reliable

Test wbfy

Reliable email OTP sign-in for Better Auth, extracted for Exercode and prompt-study. New addresses are registered automatically on successful sign-in.

Concurrent send requests share a usable code. Sign-in resends reuse the pending code and extend its expiry by default, without resetting failed attempts. Expired, exhausted, or undecryptable codes are replaced. The sender is always awaited, including when Better Auth has a background task handler: a rejected send returns HTTP 503 with error code FAILED_TO_SEND_EMAIL. A retry can reuse that pending code.

Usage

Install this package alongside Better Auth and Zod. Replace the server's emailOTP plugin with reliableEmailOTP; keep emailOTPClient on the client.

import { betterAuth } from 'better-auth';
import { symmetricDecrypt, symmetricEncrypt } from 'better-auth/crypto';
import { reliableEmailOTP } from 'better-auth-email-otp-reliable';

export const auth = betterAuth({
  database, // Your Better Auth database adapter.
  secret,
  plugins: [
    reliableEmailOTP({
      otpLength: 8,
      expiresIn: 300,
      allowedAttempts: 5,
      storeOTP: {
        encrypt: (otp) => symmetricEncrypt({ key: secret, data: otp }),
        decrypt: (value) => symmetricDecrypt({ key: secret, data: value }),
      },
      sendVerificationOTP: async ({ email, otp }) => {
        await sendMail({ to: email, subject: 'Your sign-in code', text: otp });
      },
    }),
  ],
});

database, secret, and sendMail above are supplied by the application. The sender must return the delivery promise and reject on failure. Custom storage must encrypt codes in a recoverable format; decryption failures are treated as unusable codes. Stored ciphertext is wrapped with a per-insertion random identifier so creation-hook failures can be distinguished from competing inserts, even with fixed codes. Legacy ciphertext is passed unchanged to the decryptor unless it matches the complete envelope: reliable-email-otp:v1: followed by a JSON [UUID, ciphertext] tuple. Legacy formats already producing that complete envelope need explicit migration; a matching prefix alone remains readable. Empty submissions are rejected before verification. Supply generateOTP to use a custom nonempty code format, including fixed codes in local tests. Numeric options must be positive integers.

resendStrategy: 'rotate' explicitly replaces a code on a new send request; concurrent insert conflicts still share the winning code. Prefer the default 'reuse' so delayed emails stay useful.

The reuse default applies to the overridden sign-in sender. Retained upstream endpoints use their upstream defaults unless resendStrategy is explicitly supplied.

Once a replacement is stored, a later creation-hook or delivery failure does not restore the old code: a concurrent sender may already have delivered the replacement. The request still reports the error, and a retry can issue or deliver a usable code.

Database contract

The verification.identifier column must have a database UNIQUE constraint. The existing Exercode and prompt-study schemas already provide it. Better Auth's standard schema alone does not. For a new integration, deduplicate any existing rows and add a unique index using your normal migration workflow before enabling the plugin. Serial and string primary keys are supported; row IDs must not be reused after deletion. Better Auth's memory adapter is suitable only for basic development flows because it does not enforce uniqueness.

Creation goes through Better Auth's internal adapter. Conflicting requests read the winning row and reuse its code; replacement deletes only the observed row, matching its ID, value, and expiry. This coordinates independent application instances through the database without a process-local lock.

The adapter records verification insertion failures without changing the thrown errors. Only recognized duplicate-key errors from that insertion enter conflict recovery; other database and creation-hook errors propagate instead of triggering conflict recovery. Duplicate errors are recognized by SQLite/libSQL extended codes, Cloudflare D1's uniqueness error message, PostgreSQL SQLSTATE 23505, MySQL ER_DUP_ENTRY/1062, or MongoDB 11000, including nested cause chains. Adapters with other error formats fail closed and need compatibility work before use.

Secondary storage supplied directly is rejected at initialization. The finalized configuration is also checked before API requests to catch storage added by other plugins. This includes verification.storeInDatabase: true: conditional database writes cannot safely keep Better Auth's verification cache in sync. Use a database-only Better Auth instance for this plugin.

Conditional expiry updates and replacement deletes bypass verification update and delete hooks. Creation invokes verification create hooks; hooks must preserve the identifier, value, and expiry. Hook-dependent integrations require further work tracked in issue #6.

Scope and compatibility

The overridden send endpoint accepts only type: 'sign-in' and sends to existing and new addresses alike. Signup email verification, email changes, and disabling automatic signup are not configurable through this plugin. Other Better Auth endpoints retain upstream behavior; the delivery and resend guarantees apply to /email-otp/send-verification-otp. Verification and sign-in retain upstream single-use and attempt-limit handling. Simultaneous verification and sending are not serialized by this package, and provider acceptance does not guarantee inbox delivery.

The package depends on Better Auth's internal verification format and adapter behavior. Compatibility is tested with Better Auth 1.6.29 (prompt-study) and 1.7.2 (Exercode); the peer range excludes 1.8 and later until those contracts are reviewed. The development dependency follows Exercode's 1.7.2. CI checks that locked version, then installs 1.6.29 in its disposable checkout and repeats type checking, tests, and the build. Tests exercise actual HTTP sign-in, delivery failure recovery, and real SQLite concurrency with the applications' serial-ID and unique-identifier schema, plus D1 concurrency through Miniflare's actual Workers runtime. Run bun run verify-full and bun run build when changing the package.