feat(email-otp): add resendStrategy option to reuse existing OTP (#8560)

This commit is contained in:
Taesu
2026-03-19 12:20:44 -07:00
committed by Bereket Engida
parent 4f41b62cfb
commit 98c8e4e65c
8 changed files with 411 additions and 95 deletions
+1
View File
@@ -1,4 +1,5 @@
totp
otps
pkce
unban
siwe
+20
View File
@@ -411,6 +411,26 @@ export const auth = betterAuth({
When the maximum attempts are exceeded, the `verifyOTP`, `signIn.emailOtp`, `verifyEmail`, and `resetPassword` methods will return an error with code `TOO_MANY_ATTEMPTS`.
- `resendStrategy`: Controls what happens when a user requests a new OTP while an existing one is still valid. Defaults to `"rotate"`.
- `"rotate"`: Always generates a new OTP (default behavior).
- `"reuse"`: Resends the same OTP and extends its expiry. This prevents multiple valid codes from existing simultaneously when emails are delayed. Only works when the OTP is recoverable (`plain`, `encrypted`, or custom encrypt/decrypt). Falls back to `"rotate"` when the OTP is hashed. If the allowed attempts have been exhausted, a fresh OTP is generated instead of reusing the exhausted one.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { emailOTP } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
emailOTP({
resendStrategy: "reuse", // [!code highlight]
async sendVerificationOTP({ email, otp, type }) {
// send the OTP
},
})
]
})
```
- `storeOTP`: The method used to transform the OTP before it is stored by Better Auth's verification layer, whether `encrypted`, `hashed` or `plain` text. Default is `plain` text.
<Callout>
@@ -1978,3 +1978,227 @@ describe("race condition protection", async () => {
expect(res2.error?.code).toBe("INVALID_OTP");
});
});
describe("email-otp-resendStrategy", async () => {
afterEach(() => {
vi.useRealTimers();
});
const otps: string[] = [];
const { client, testUser } = await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP({ otp }) {
otps.push(otp);
},
resendStrategy: "reuse",
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
it("should reuse existing OTP when resendStrategy is reuse", async () => {
otps.length = 0;
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
const firstOtp = otps[0];
expect(firstOtp).toBeDefined();
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
const secondOtp = otps[1];
expect(secondOtp).toBeDefined();
expect(secondOtp).toBe(firstOtp);
});
it("should generate new OTP after previous one expires", async () => {
otps.length = 0;
vi.useFakeTimers();
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "sign-in",
});
const firstOtp = otps[0];
expect(firstOtp).toBeDefined();
// Advance past expiry (default 5 minutes)
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "sign-in",
});
const secondOtp = otps[1];
expect(secondOtp).toBeDefined();
expect(secondOtp).not.toBe(firstOtp);
});
it("should generate new OTP when resendStrategy is reuse but storeOTP is hashed", async () => {
const hashedOtps: string[] = [];
const { client: hashedClient, testUser: hashedUser } =
await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP({ otp }) {
hashedOtps.push(otp);
},
resendStrategy: "reuse",
storeOTP: "hashed",
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
await hashedClient.emailOtp.sendVerificationOtp({
email: hashedUser.email,
type: "email-verification",
});
const firstOtp = hashedOtps[0];
expect(firstOtp).toBeDefined();
// Second request - should get NEW OTP since hashed OTP cannot be retrieved
await hashedClient.emailOtp.sendVerificationOtp({
email: hashedUser.email,
type: "email-verification",
});
const secondOtp = hashedOtps[1];
expect(secondOtp).toBeDefined();
expect(secondOtp).not.toBe(firstOtp);
});
it("should generate new OTP when resendStrategy is reuse but storeOTP is custom hash", async () => {
const customHashOtps: string[] = [];
const { client: hashClient, testUser: hashUser } = await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP({ otp }) {
customHashOtps.push(otp);
},
resendStrategy: "reuse",
storeOTP: {
hash: async (otp) => `hashed-${otp}`,
},
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
await hashClient.emailOtp.sendVerificationOtp({
email: hashUser.email,
type: "email-verification",
});
const firstOtp = customHashOtps[0];
expect(firstOtp).toBeDefined();
await hashClient.emailOtp.sendVerificationOtp({
email: hashUser.email,
type: "email-verification",
});
const secondOtp = customHashOtps[1];
expect(secondOtp).toBeDefined();
expect(secondOtp).not.toBe(firstOtp);
});
it("should not send OTP for non-existent user on email-verification type", async () => {
otps.length = 0;
const { client: noSignUpClient } = await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP({ otp }) {
otps.push(otp);
},
resendStrategy: "reuse",
disableSignUp: true,
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
const res = await noSignUpClient.emailOtp.sendVerificationOtp({
email: "nonexistent@test.com",
type: "email-verification",
});
expect(res.data?.success).toBe(true);
// OTP should not be sent since user doesn't exist
expect(otps.length).toBe(0);
});
it("should generate fresh OTP when attempts are exhausted", async () => {
otps.length = 0;
const { client: attemptClient, testUser: attemptUser } =
await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP({ otp }) {
otps.push(otp);
},
resendStrategy: "reuse",
allowedAttempts: 2,
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
// Send first OTP
await attemptClient.emailOtp.sendVerificationOtp({
email: attemptUser.email,
type: "email-verification",
});
const firstOtp = otps[0];
expect(firstOtp).toBeDefined();
// Exhaust attempts by verifying with wrong OTP
await attemptClient.emailOtp.verifyEmail({
email: attemptUser.email,
otp: "wrong1",
});
await attemptClient.emailOtp.verifyEmail({
email: attemptUser.email,
otp: "wrong2",
});
// Request new OTP — should generate fresh one since attempts exhausted
await attemptClient.emailOtp.sendVerificationOtp({
email: attemptUser.email,
type: "email-verification",
});
const secondOtp = otps[1];
expect(secondOtp).toBeDefined();
expect(secondOtp).not.toBe(firstOtp);
});
});
@@ -19,6 +19,7 @@ import {
verifyEmailOTP,
} from "./routes";
import type { EmailOTPOptions } from "./types";
import { toOTPIdentifier } from "./utils";
declare module "@better-auth/core" {
interface BetterAuthPluginRegistry<AuthOptions, Options> {
@@ -105,7 +106,7 @@ export const emailOTP = (options: EmailOTPOptions) => {
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `email-verification-otp-${email}`,
identifier: toOTPIdentifier("email-verification", email),
expiresAt: getDate(opts.expiresIn, "sec"),
});
await ctx.context.runInBackgroundOrAwait(
@@ -4,8 +4,9 @@ import {
symmetricDecrypt,
symmetricEncrypt,
} from "../../crypto";
import type { EmailOTPOptions } from "./types";
import { defaultKeyHasher } from "./utils";
import { getDate } from "../../utils/date";
import type { EmailOTPOptions, RequiredEmailOTPOptions } from "./types";
import { defaultKeyHasher, splitAtLastColon } from "./utils";
export async function storeOTP(
ctx: GenericEndpointContext,
@@ -59,3 +60,55 @@ export async function verifyStoredOTP(
return constantTimeEqual(otp, storedOtp);
}
/**
* Retrieves the plain-text OTP from a stored value.
* Returns `null` if the OTP is hashed and cannot be recovered.
*/
async function retrieveOTP(
ctx: GenericEndpointContext,
opts: EmailOTPOptions,
storedOtp: string,
): Promise<string | null> {
if (opts.storeOTP === "plain" || opts.storeOTP === undefined) {
return storedOtp;
}
if (opts.storeOTP === "encrypted") {
return await symmetricDecrypt({
key: ctx.context.secretConfig,
data: storedOtp,
});
}
if (typeof opts.storeOTP === "object" && "decrypt" in opts.storeOTP) {
return await opts.storeOTP.decrypt(storedOtp);
}
// hashed or custom hash -> cannot recover
return null;
}
/**
* Tries to reuse an existing unexpired OTP.
* Returns the plain-text OTP if reusable, `null` otherwise.
*/
export async function tryReuseOTP(
ctx: GenericEndpointContext,
opts: RequiredEmailOTPOptions,
identifier: string,
): Promise<string | null> {
const existing =
await ctx.context.internalAdapter.findVerificationValue(identifier);
if (!existing || existing.expiresAt < new Date()) return null;
const [storedOtpValue, attempts] = splitAtLastColon(existing.value);
const allowedAttempts = opts.allowedAttempts || 3;
if (attempts && parseInt(attempts) >= allowedAttempts) return null;
const plainOtp = await retrieveOTP(ctx, opts, storedOtpValue);
if (!plainOtp) return null;
await ctx.context.internalAdapter.updateVerificationByIdentifier(identifier, {
expiresAt: getDate(opts.expiresIn, "sec"),
});
return plainOtp;
}
@@ -12,9 +12,10 @@ import { setCookieCache, setSessionCookie } from "../../cookies";
import { generateRandomString, symmetricDecrypt } from "../../crypto";
import { parseUserInput, parseUserOutput } from "../../db/schema";
import { getDate } from "../../utils/date";
import { storeOTP, verifyStoredOTP } from "./otp-token";
import type { EmailOTPOptions } from "./types";
import { splitAtLastColon } from "./utils";
import { EMAIL_OTP_ERROR_CODES as ERROR_CODES } from "./error-codes";
import { storeOTP, tryReuseOTP, verifyStoredOTP } from "./otp-token";
import type { EmailOTPOptions, RequiredEmailOTPOptions } from "./types";
import { splitAtLastColon, toOTPIdentifier } from "./utils";
const types = [
"email-verification",
@@ -23,14 +24,48 @@ const types = [
"change-email",
] as const;
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
/**
* Resolves the OTP to send: reuses an existing one if possible,
* otherwise generates and stores a new one.
*
* @internal
*/
async function resolveOTP(
ctx: GenericEndpointContext,
opts: RequiredEmailOTPOptions,
email: string,
type: (typeof types)[number],
): Promise<string> {
const identifier = toOTPIdentifier(type, email);
type RequiredEmailOTPOptions = WithRequired<
EmailOTPOptions,
"expiresIn" | "generateOTP" | "storeOTP"
>;
if (opts.resendStrategy === "reuse") {
const reused = await tryReuseOTP(ctx, opts, identifier);
if (reused) return reused;
}
import { EMAIL_OTP_ERROR_CODES as ERROR_CODES } from "./error-codes";
const otp =
opts.generateOTP({ email, type }, ctx) || defaultOTPGenerator(opts);
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter
.createVerificationValue({
value: `${storedOTP}:0`,
identifier,
expiresAt: getDate(opts.expiresIn, "sec"),
})
.catch(async () => {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
identifier,
);
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier,
expiresAt: getDate(opts.expiresIn, "sec"),
});
});
return otp;
}
const sendVerificationOTPBodySchema = z.object({
email: z.string({}).meta({
@@ -108,57 +143,22 @@ export const sendVerificationOTP = (opts: RequiredEmailOTPOptions) =>
message: "Invalid OTP type",
});
}
const otp =
opts.generateOTP({ email, type: ctx.body.type }, ctx) ||
defaultOTPGenerator(opts);
const identifier = toOTPIdentifier(ctx.body.type, email);
const otp = await resolveOTP(ctx, opts, email, ctx.body.type);
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter
.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `${ctx.body.type}-otp-${email}`,
expiresAt: getDate(opts.expiresIn, "sec"),
})
.catch(async (error) => {
// might be duplicate key error
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`${ctx.body.type}-otp-${email}`,
);
//try again
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `${ctx.body.type}-otp-${email}`,
expiresAt: getDate(opts.expiresIn, "sec"),
});
});
const shouldSendOTP = ctx.body.type === "sign-in" && !opts.disableSignUp;
const user = await ctx.context.internalAdapter.findUserByEmail(email);
if (!user) {
if (ctx.body.type === "sign-in" && !opts.disableSignUp) {
// allow
} else {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`${ctx.body.type}-otp-${email}`,
);
return ctx.json({
success: true,
});
}
if (!user && !shouldSendOTP) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
identifier,
);
return ctx.json({ success: true });
}
await ctx.context.runInBackgroundOrAwait(
opts.sendVerificationOTP(
{
email,
otp,
type: ctx.body.type,
},
ctx,
),
opts.sendVerificationOTP({ email, otp, type: ctx.body.type }, ctx),
);
return ctx.json({
success: true,
});
return ctx.json({ success: true });
},
);
@@ -204,7 +204,7 @@ export const createVerificationOTP = (opts: RequiredEmailOTPOptions) =>
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `${ctx.body.type}-otp-${email}`,
identifier: toOTPIdentifier(ctx.body.type, email),
expiresAt: getDate(opts.expiresIn, "sec"),
});
return otp;
@@ -270,7 +270,7 @@ export const getVerificationOTP = (opts: RequiredEmailOTPOptions) =>
const email = ctx.query.email.toLowerCase();
const verificationValue =
await ctx.context.internalAdapter.findVerificationValue(
`${ctx.query.type}-otp-${email}`,
toOTPIdentifier(ctx.query.type, email),
);
if (!verificationValue || verificationValue.expiresAt < new Date()) {
return ctx.json({
@@ -371,17 +371,15 @@ export const checkVerificationOTP = (opts: RequiredEmailOTPOptions) =>
if (!user) {
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
}
const identifier = toOTPIdentifier(ctx.body.type, email);
const verificationValue =
await ctx.context.internalAdapter.findVerificationValue(
`${ctx.body.type}-otp-${email}`,
);
await ctx.context.internalAdapter.findVerificationValue(identifier);
if (!verificationValue) {
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
}
const otpIdentifier = `${ctx.body.type}-otp-${email}`;
if (verificationValue.expiresAt < new Date()) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
otpIdentifier,
identifier,
);
throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED);
}
@@ -390,14 +388,14 @@ export const checkVerificationOTP = (opts: RequiredEmailOTPOptions) =>
const allowedAttempts = opts?.allowedAttempts || 3;
if (attempts && parseInt(attempts) >= allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
otpIdentifier,
identifier,
);
throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS);
}
const verified = await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp);
if (!verified) {
await ctx.context.internalAdapter.updateVerificationByIdentifier(
otpIdentifier,
identifier,
{
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
},
@@ -488,7 +486,7 @@ export const verifyEmailOTP = (opts: RequiredEmailOTPOptions) =>
await atomicVerifyOTP(
ctx,
opts,
`email-verification-otp-${email}`,
toOTPIdentifier("email-verification", email),
ctx.body.otp,
);
@@ -641,7 +639,7 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) =>
const email = rawEmail.toLowerCase();
// Use atomic verification to prevent race conditions
await atomicVerifyOTP(ctx, opts, `sign-in-otp-${email}`, otp);
await atomicVerifyOTP(ctx, opts, toOTPIdentifier("sign-in", email), otp);
const user = await ctx.context.internalAdapter.findUserByEmail(email);
if (!user) {
@@ -748,19 +746,12 @@ export const requestPasswordResetEmailOTP = (opts: RequiredEmailOTPOptions) =>
},
async (ctx) => {
const email = ctx.body.email;
const otp =
opts.generateOTP({ email, type: "forget-password" }, ctx) ||
defaultOTPGenerator(opts);
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `forget-password-otp-${email}`,
expiresAt: getDate(opts.expiresIn, "sec"),
});
const identifier = toOTPIdentifier("forget-password", email);
const otp = await resolveOTP(ctx, opts, email, "forget-password");
const user = await ctx.context.internalAdapter.findUserByEmail(email);
if (!user) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`forget-password-otp-${email}`,
identifier,
);
return ctx.json({
success: true,
@@ -847,19 +838,12 @@ export const forgetPasswordEmailOTP = (opts: RequiredEmailOTPOptions) => {
async (ctx) => {
warnDeprecation();
const email = ctx.body.email;
const otp =
opts.generateOTP({ email, type: "forget-password" }, ctx) ||
defaultOTPGenerator(opts);
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `forget-password-otp-${email}`,
expiresAt: getDate(opts.expiresIn, "sec"),
});
const identifier = toOTPIdentifier("forget-password", email);
const otp = await resolveOTP(ctx, opts, email, "forget-password");
const user = await ctx.context.internalAdapter.findUserByEmail(email);
if (!user) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`forget-password-otp-${email}`,
identifier,
);
return ctx.json({
success: true,
@@ -946,7 +930,7 @@ export const resetPasswordEmailOTP = (opts: RequiredEmailOTPOptions) =>
await atomicVerifyOTP(
ctx,
opts,
`forget-password-otp-${email}`,
toOTPIdentifier("forget-password", email),
ctx.body.otp,
);
@@ -1093,12 +1077,15 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) =>
const currentEmailVerificationValue =
await ctx.context.internalAdapter.findVerificationValue(
`email-verification-otp-${email}`,
toOTPIdentifier("email-verification", email),
);
if (!currentEmailVerificationValue) {
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
}
const currentEmailIdentifier = `email-verification-otp-${email}`;
const currentEmailIdentifier = toOTPIdentifier(
"email-verification",
email,
);
if (currentEmailVerificationValue.expiresAt < new Date()) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
currentEmailIdentifier,
@@ -1151,14 +1138,14 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) =>
const storedOTP = await storeOTP(ctx, opts, otp);
await ctx.context.internalAdapter.createVerificationValue({
value: `${storedOTP}:0`,
identifier: `change-email-otp-${email}-${newEmail}`,
identifier: toOTPIdentifier("change-email", `${email}-${newEmail}`),
expiresAt: getDate(opts.expiresIn, "sec"),
});
const user = await ctx.context.internalAdapter.findUserByEmail(newEmail);
if (user) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`change-email-otp-${email}-${newEmail}`,
toOTPIdentifier("change-email", `${email}-${newEmail}`),
);
return ctx.json({
success: true,
@@ -1262,12 +1249,15 @@ export const changeEmailEmailOTP = (opts: RequiredEmailOTPOptions) =>
const verificationValue =
await ctx.context.internalAdapter.findVerificationValue(
`change-email-otp-${email}-${newEmail}`,
toOTPIdentifier("change-email", `${email}-${newEmail}`),
);
if (!verificationValue) {
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
}
const changeEmailIdentifier = `change-email-otp-${email}-${newEmail}`;
const changeEmailIdentifier = toOTPIdentifier(
"change-email",
`${email}-${newEmail}`,
);
if (verificationValue.expiresAt < new Date()) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
changeEmailIdentifier,
@@ -1,5 +1,12 @@
import type { GenericEndpointContext } from "@better-auth/core";
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type RequiredEmailOTPOptions = WithRequired<
EmailOTPOptions,
"expiresIn" | "generateOTP" | "storeOTP"
>;
export interface EmailOTPOptions {
/**
* Function to send email verification.
@@ -81,6 +88,18 @@ export interface EmailOTPOptions {
}
)
| undefined;
/**
* Strategy for handling OTP when the user requests a new OTP
* while an existing one is still valid.
*
* - `"rotate"`: Always generates a new OTP (default behavior).
* - `"reuse"`: Resends the same OTP and extends its expiry.
* Only works when the OTP is recoverable (plain, encrypted, or custom encrypt/decrypt).
* Falls back to `"rotate"` when OTP is hashed.
*
* @default "rotate"
*/
resendStrategy?: "rotate" | "reuse" | undefined;
/**
* Change email configuration for the change email with OTP flow
*
@@ -1,5 +1,13 @@
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
export function toOTPIdentifier(
type: "email-verification" | "sign-in" | "forget-password" | "change-email",
email: string,
) {
return `${type}-otp-${email}`;
}
export const defaultKeyHasher = async (otp: string) => {
const hash = await createHash("SHA-256").digest(
new TextEncoder().encode(otp),