phone-number plugin stores the OTP in clear; no storeOTP option as email-otp has
Summary
The email-otp plugin supports storeOTP: 'hashed' | 'encrypted' | { ... }, so the code in verification.value is not readable from a database dump. The phone-number plugin has no equivalent option: it writes the code in clear, as ${code}:${attempts}.
Checked against 1.7.4 and 1.7.5.
Where
src/plugins/phone-number/routes.ts — sendPhoneNumberOTP:
const code = generateOTP(opts.otpLength);
await ctx.context.internalAdapter.createVerificationValue({
value: `${code}:0`,
identifier: ctx.body.phoneNumber,
expiresAt: getDate(opts.expiresIn, "sec"),
});and again in verifyPhoneNumberOTP, which rewrites ${otpValue}:${attempts + 1} on a wrong code.
Why verifyOTP does not cover it
PhoneNumberOptions.verifyOTP is documented as replacing "the internal verification logic". It does — but the store above happens in sendPhoneNumberOTP, before sendOTP is even called, so providing verifyOTP does not stop the plaintext write. There appears to be no configuration that reaches it.
Why it matters
An SMS OTP is a live credential for its lifetime. Stored in clear it ends up in every backup, export and read replica taken during that window; anyone who later obtains one of those can sign in as any user whose code had not yet expired. This is the same reasoning that produced storeOTP for email-otp in #1093 — the SMS path just did not get it.
What would fix it
Accepting the same storeOTP option on phone-number as on email-otp would be enough, with the attempt counter kept outside the hashed value (a second column, or hashing only the code portion of value). #6070's pluggable verify() would cover this too if it were extended to this plugin.
Happy to send a PR if the shape is agreed — mainly checking first whether the omission is deliberate.
Context
Found while writing a test asserting that no one-time code is readable at rest. Related: #1093 (which added storeOTP to email-otp), #6070 (pluggable verification inside storeOTP).
Source: better-auth/better-auth