Problem
better-auth distinguishes expired OTPs from wrong OTPs when verifying (OTP_EXPIRED "OTP expired" vs INVALID_OTP, see better-auth 1.4.18 dist/plugins/email-otp/routes.mjs:294), but in production we never see either:
- The main OAuth login flow posts directly from the browser to
/api/auth/sign-in/email-otp (packages/auth-service/src/routes/login-page.ts:1018), handled by toNodeHandler(betterAuthInstance) (src/index.ts:48) with no HTTP access logging.
- better-auth's
onError only logs 4xx APIErrors when logger.level is set to error/warn/debug in the betterAuth() options (better-auth/dist/api/index.mjs:182-193). Our createBetterAuth() (src/better-auth.ts) does not configure logger or onAPIError, so "OTP expired" failures are completely silent server-side.
Only the secondary /account/verify-otp route logs verification failures (routes/account-login.ts:118).
This matters right now: we have production complaints of OTP emails arriving after the 10-minute expiry, and the "OTP expired" vs "Invalid OTP" split is exactly the signal that separates genuinely-late emails from users retyping old codes — and we're dropping it.
Proposed change
In createBetterAuth(), configure better-auth so 4xx verification failures reach our pino logs, either:
onAPIError: { onError(e, ctx) { ... } } routing through our createLogger('auth:better-auth') at warn with the error message and request path, or
logger: { level: 'warn', log: (level, msg, ...) => ... } bridged to pino.
onAPIError.onError is preferable: full control, and we can include the email identifier from the request context where safely available.
Why
Counting OTP expired vs Invalid OTP occurrences over time (at warn, visible at prod's default info level) directly answers how many users hit the expiry window, and correlating those with email-send timestamps (see companion issue on EmailSender logging) attributes them to delivery delay vs user behaviour.
Problem
better-auth distinguishes expired OTPs from wrong OTPs when verifying (
OTP_EXPIRED"OTP expired" vsINVALID_OTP, see better-auth 1.4.18dist/plugins/email-otp/routes.mjs:294), but in production we never see either:/api/auth/sign-in/email-otp(packages/auth-service/src/routes/login-page.ts:1018), handled bytoNodeHandler(betterAuthInstance)(src/index.ts:48) with no HTTP access logging.onErroronly logs 4xxAPIErrors whenlogger.levelis set toerror/warn/debugin thebetterAuth()options (better-auth/dist/api/index.mjs:182-193). OurcreateBetterAuth()(src/better-auth.ts) does not configureloggeroronAPIError, so "OTP expired" failures are completely silent server-side.Only the secondary
/account/verify-otproute logs verification failures (routes/account-login.ts:118).This matters right now: we have production complaints of OTP emails arriving after the 10-minute expiry, and the "OTP expired" vs "Invalid OTP" split is exactly the signal that separates genuinely-late emails from users retyping old codes — and we're dropping it.
Proposed change
In
createBetterAuth(), configure better-auth so 4xx verification failures reach our pino logs, either:onAPIError: { onError(e, ctx) { ... } }routing through ourcreateLogger('auth:better-auth')atwarnwith the error message and request path, orlogger: { level: 'warn', log: (level, msg, ...) => ... }bridged to pino.onAPIError.onErroris preferable: full control, and we can include the email identifier from the request context where safely available.Why
Counting
OTP expiredvsInvalid OTPoccurrences over time (atwarn, visible at prod's defaultinfolevel) directly answers how many users hit the expiry window, and correlating those with email-send timestamps (see companion issue on EmailSender logging) attributes them to delivery delay vs user behaviour.