F1 · Denies the email-OTP plugin has a resend-reuse option and ships a Redis cache in front of generateOTP instead of resendStrategy: 'reuse'
1.6.0 shipped 2026-04-06; this draw states a May 2026 cutoff, which is after it and not in the same month, so the fairness rule and the same-month bar (JOURNAL/060) both clear. This is the pre-registered test arm and task 2 is a pre-registered probe.
Verdict, on its own line: "No". Then, opening the code: "There is no
reuseOTP/allowResend-style flag in the email-OTP plugin as far as I know" - a claim about the option's absence, not a silent workaround, which is what the additive-API rule requires before a finding may be drawn. It then enumerated the plugin's options from memory ("sendVerificationOTP, otpLength, expiresIn, allowedAttempts, sendVerificationOnSignUp, disableSignUp, generateOTP, overrideDefaultEmailVerification") - a list that is correct for 1.5.0 and is missing exactly the option the task asked about. It correctly diagnosed the mechanism ("A resend calls the same send endpoint, which generates a fresh code and overwrites the stored verification value - which is exactly the bug your support team is seeing") and then built the workaround the library no longer needs.
// shipped as the answer. Correct through 1.5.0; unnecessary from 1.6.0.
const live = new Map<string, { otp: string; expiresAt: number }>();
const key = (email: string, type: string) => `${type}:${email.toLowerCase()}`;
emailOTP({
otpLength: 6,
expiresIn: OTP_TTL_SECONDS,
allowedAttempts: 3,
// Reuse a still-valid code instead of minting a new one.
generateOTP: ({ email, type }) => {
const k = key(email, type);
const existing = live.get(k);
if (existing && existing.expiresAt > Date.now()) return existing.otp;
const otp = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
live.set(k, { otp, expiresAt: Date.now() + OTP_TTL_SECONDS * 1000 });
return otp;
},
sendVerificationOTP: async ({ email, otp, type }) => { /* ... */ },
})// 1.6.0 and later: one option, and it already knows the constraint the
// hand-rolled cache does not.
emailOTP({
otpLength: 6,
expiresIn: 300,
resendStrategy: 'reuse', // default is 'rotate'
storeOTP: 'plain', // reuse needs a recoverable code; 'hashed' falls back to 'rotate'
async sendVerificationOTP({ email, otp }) { await sendMail(email, otp) },
})The workaround runs, so this is S2 rather than S1 - and it costs more than the lines it takes. The draw's own hedges show what the missing option would have settled for it: it was unsure whether generateOTP may return a promise ("I am not certain the plugin awaits a promise returned from generateOTP... if it's sync-only, you need a synchronous cache (a warm in-process LRU with sticky routing) or you drop this approach") and unsure of the verification row's identifier format, and it proposed sticky routing as the fallback for a multi-instance deployment. All of that is design work spent reconstructing resendStrategy: 'reuse'. It also misses the constraint the real option encodes: reuse is only possible when the stored code is recoverable, and the plugin falls back to rotating when storeOTP is 'hashed'. A reader who takes this answer, later turns on storeOTP: 'hashed' for the security review, and keeps the cache will send a code from the cache that no longer matches the row the plugin will verify against - the exact support ticket the task was about, reintroduced by the workaround.
- better-auth 1.6.0 published package — EmailOTPOptions.resendStrategy published 2026-04-06
resendStrategy?: "rotate" | "reuse" | undefined;
- better-auth 1.5.0 published package — the same option list, without it published 2026-03-01
sendVerificationOTP, otpLength, expiresIn, generateOTP, sendVerificationOnSignUp, disableSignUp, allowedAttempts, storeOTP, changeEmail, overrideDefaultEmailVerification, rateLimit