Neon Auth Email Verification & Password Reset — A Complete Integration Guide
Audience: Developers using Neon Auth (which wraps Better Auth) with the
email-otpplugin in a React + TypeScript project.What this guide covers: Enabling the email verification flow that Neon Auth’s dashboard offers but doesn’t document how to wire into your frontend. By the end, you’ll have working email verification on signup, unverified-user detection on sign-in, and a full password-reset flow — all using 6-digit OTP codes.
This guide is based on hard-won lessons from integrating this into a production app. Every pitfall documented here was encountered and solved in a real codebase.
Table of Contents
- Architecture Overview
- Prerequisites
- Understanding the Flows
- Step 1: Accessing Email-OTP Methods
- Step 2: Auth Hook — Core Logic
- Step 3: Multi-View Form State Machine
- Step 4: OTP Input Component
- Step 5: Gating Access on emailVerified
- Pitfalls & Gotchas
- Complete File Reference
1. Architecture Overview
Neon Auth is a managed authentication service that wraps Better Auth on the server side. When you enable “Verification code” mode in the Neon Auth dashboard, the server-side email-otp plugin is activated automatically — Neon Auth handles sending emails, generating OTPs, and verifying them.
Your job is entirely on the client side: building the UI flows that call the right endpoints at the right time.
graph TB subgraph "Neon Auth Server (managed)" BA[Better Auth + email-otp plugin] DB[(Auth Database)] EMAIL[Email Service] BA --> DB BA --> EMAIL end subgraph "Your Client App" SDK["@neondatabase/neon-js/auth"] UI[Auth UI Components] SDK --> BA UI --> SDK end EMAIL -->|"6-digit OTP"| USER[User's Inbox] USER -->|"enters code"| UI
Key insight: No server-side code changes are needed. The email-otp plugin runs entirely on Neon’s infrastructure. You only write client code.
2. Prerequisites
- Neon project with Auth enabled
- “Verification code” mode turned on in Neon Auth dashboard (Settings > Email Verification)
@neondatabase/neon-jspackage installed (v0.2.0-beta.1+)- Environment variable:
VITE_NEON_AUTH_URL(or equivalent) pointing to your Neon Auth URL
Your auth client should already be set up:
// auth.ts
import { createAuthClient } from '@neondatabase/neon-js/auth';
export const authClient = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL);Basic sign-in/sign-up should already work before adding email verification.
3. Understanding the Flows
3.1 Registration Flow
sequenceDiagram participant U as User participant App as Your App participant NA as Neon Auth U->>App: Fill in name, email, password App->>NA: authClient.signUp.email({ email, password, name }) NA->>NA: Create user (emailVerified = false) NA-->>U: Send verification email with 6-digit OTP NA-->>App: { data: { user } } (success) App->>App: Show OTP input view (do NOT set user as logged in) U->>App: Enter 6-digit code App->>NA: emailOtp.verifyEmail({ email, otp }) NA->>NA: Mark emailVerified = true, create session NA-->>App: { data: { user, token } } App->>NA: authClient.getSession() NA-->>App: Session with verified user App->>App: Set user in store, grant access
3.2 Sign-In with Unverified Email
This is the trickiest flow because Better Auth rejects the sign-in entirely with an error, rather than returning a user object with emailVerified: false:
sequenceDiagram participant U as User participant App as Your App participant NA as Neon Auth U->>App: Enter email and password App->>NA: authClient.signIn.email({ email, password }) NA-->>App: ERROR "Email not verified" (no session created!) App->>App: Detect error via regex match App->>NA: emailOtp.sendVerificationOtp({ email, type: 'email-verification' }) NA-->>U: Send new verification email App->>App: Transition to OTP view U->>App: Enter 6-digit code App->>NA: emailOtp.verifyEmail({ email, otp }) NA->>NA: Mark verified, create session App->>NA: authClient.getSession() alt Session exists App->>App: Set user, grant access else No session (edge case) App->>NA: authClient.signIn.email({ email, password }) NA-->>App: Success (email now verified) App->>App: Set user, grant access end
3.3 Password Reset Flow
sequenceDiagram participant U as User participant App as Your App participant NA as Neon Auth U->>App: Click "Forgot password?" App->>App: Show forgot-password view U->>App: Enter email App->>NA: forgetPassword.emailOtp({ email }) NA-->>U: Send password reset OTP App->>App: Transition to reset-password view U->>App: Enter OTP + new password App->>NA: emailOtp.resetPassword({ email, otp, password }) NA->>NA: Update password NA-->>App: { data: { success } } App->>App: Show success, return to login
3.4 Google OAuth (No Verification Needed)
Google-authenticated users have their email verified by Google. Neon Auth sets emailVerified: true automatically. No OTP flow is needed.
Step 1: Accessing Email-OTP Methods (The Type Gap)
The Problem
The Neon Auth SDK includes emailOTPClient() in its runtime plugin list:
// Inside @neondatabase/auth (runtime code)
const supportedBetterAuthClientPlugins = [
anonymousTokenClient(),
// ... other plugins ...
emailOTPClient(), // <-- Methods exist at runtime!
];But the TypeScript types go through a generic wrapper (NeonAuthPublicApi<T>) that loses the plugin method types. You’ll get errors like:
TS2339: Property 'emailOtp' does not exist on type '{ signIn: ... }'
The Solution
Cast through any to access the runtime methods. Create a typed wrapper so the rest of your code stays type-safe:
// auth.ts
import { createAuthClient } from '@neondatabase/neon-js/auth';
export const authClient = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL);
// Cast to access email-otp plugin methods that exist at runtime
const client = authClient as any;
export const emailOtp = {
sendVerificationOtp: (email: string, type: 'email-verification' | 'sign-in' | 'forget-password') =>
client.emailOtp.sendVerificationOtp({ email, type }) as Promise<{ data: any; error: any }>,
verifyEmail: (email: string, otp: string) =>
client.emailOtp.verifyEmail({ email, otp }) as Promise<{ data: any; error: any }>,
resetPassword: (email: string, otp: string, password: string) =>
client.emailOtp.resetPassword({ email, otp, password }) as Promise<{ data: any; error: any }>,
forgetPassword: (email: string) =>
client.forgetPassword.emailOtp({ email }) as Promise<{ data: any; error: any }>,
};Available Client Methods
| Method | Neon Auth Endpoint | Purpose |
|---|---|---|
emailOtp.sendVerificationOtp({ email, type }) | POST /email-otp/send-verification-otp | Send/resend a 6-digit OTP |
emailOtp.verifyEmail({ email, otp }) | POST /email-otp/verify-email | Verify email + establish session |
forgetPassword.emailOtp({ email }) | POST /forget-password/email-otp | Send password reset OTP |
emailOtp.resetPassword({ email, otp, password }) | POST /email-otp/reset-password | Reset password with OTP |
Warning: Do NOT construct these URLs manually with
fetch(). The Neon Auth URL (e.g.,https://xxx.neonauth.aws.neon.tech/neondb/auth) has a complex path structure, and Better Auth adds its own/api/authbase path. The SDK handles URL construction internally — use the plugin methods viaanycast instead.
Step 2: Auth Hook — Core Logic
The auth hook needs to handle several scenarios that aren’t obvious:
// hooks/useAuth.ts
import { useCallback } from 'react';
import { authClient, emailOtp } from '../auth';
import { useStore } from '../store';
// Better Auth returns this error when email is not verified
const EMAIL_NOT_VERIFIED_RE = /email.*(not|isn.t)\s*verified/i;
export function useAuth() {
const setUser = useStore((s) => s.setUser);
const clearAuth = useStore((s) => s.clearAuth);
// --- Session check: read emailVerified from the session ---
const checkSession = useCallback(async () => {
const { data } = await authClient.getSession();
if (data?.user) {
setUser({
id: data.user.id,
email: data.user.email,
name: data.user.name ?? null,
emailVerified: !!data.user.emailVerified, // <-- critical field
});
} else {
clearAuth();
}
}, [setUser, clearAuth]);
// --- Sign in: intercept "email not verified" error ---
const signIn = useCallback(async (
email: string,
password: string
): Promise<{ needsVerification?: boolean }> => {
const { data, error } = await authClient.signIn.email({ email, password });
if (error) {
const msg = error.message ?? '';
// KEY BEHAVIOR: Better Auth rejects sign-in for unverified emails.
// Instead of showing a dead-end error, detect it and redirect to OTP.
if (EMAIL_NOT_VERIFIED_RE.test(msg)) {
await emailOtp.sendVerificationOtp(email, 'email-verification');
return { needsVerification: true };
}
throw new Error(msg || 'Sign in failed');
}
// Fallback: if sign-in succeeds but email isn't verified
if (data?.user && !data.user.emailVerified) {
await emailOtp.sendVerificationOtp(email, 'email-verification');
return { needsVerification: true };
}
if (data?.user) {
setUser({
id: data.user.id,
email: data.user.email,
name: data.user.name ?? null,
emailVerified: true,
});
}
return {};
}, [setUser]);
// --- Sign up: NEVER set user as logged in ---
const signUp = useCallback(async (
email: string,
password: string,
name: string
): Promise<{ needsVerification: true }> => {
const { error } = await authClient.signUp.email({ email, password, name });
if (error) throw new Error(error.message ?? 'Sign up failed');
// Neon Auth auto-sends OTP on signup. Do NOT call setUser().
return { needsVerification: true };
}, []);
// --- Verify OTP: establish session + fallback re-sign-in ---
const verifyEmailOtp = useCallback(async (
email: string,
otp: string,
savedPassword?: string
) => {
const { error } = await emailOtp.verifyEmail(email, otp);
if (error) throw new Error(error.message ?? 'Invalid code');
// verifyEmail may establish a session — try to pick it up
await checkSession();
// If no session exists (sign-in was rejected earlier), re-sign-in
const currentUser = useStore.getState().user;
if (!currentUser && savedPassword) {
const { data } = await authClient.signIn.email({
email,
password: savedPassword,
});
if (data?.user) {
setUser({
id: data.user.id,
email: data.user.email,
name: data.user.name ?? null,
emailVerified: true,
});
}
}
}, [checkSession, setUser]);
// --- Resend OTP ---
const resendVerificationOtp = useCallback(async (email: string) => {
const { error } = await emailOtp.sendVerificationOtp(email, 'email-verification');
if (error) throw new Error(error.message ?? 'Failed to resend');
}, []);
// --- Forgot password: send reset OTP ---
const sendPasswordResetOtp = useCallback(async (email: string) => {
const { error } = await emailOtp.forgetPassword(email);
if (error) throw new Error(error.message ?? 'Failed to send reset code');
}, []);
// --- Reset password with OTP ---
const resetPassword = useCallback(async (
email: string,
otp: string,
password: string
) => {
const { error } = await emailOtp.resetPassword(email, otp, password);
if (error) throw new Error(error.message ?? 'Failed to reset password');
}, []);
return {
checkSession,
signIn,
signUp,
verifyEmailOtp,
resendVerificationOtp,
sendPasswordResetOtp,
resetPassword,
};
}Critical Implementation Details
-
signUpmust NOT callsetUser()— The user exists but is unverified. Setting them as logged in bypasses verification entirely. -
signInmust detect the error, not just throw — Better Auth returns{ error: { message: "Email not verified" } }with nodata.user. If you justthrow, the user sees an error with no way to verify. -
verifyEmailOtpneeds a fallback re-sign-in — Since the original sign-in was rejected (no session created),verifyEmailmight not establish a session either. The fallback re-signs in with the saved password.
Step 3: Multi-View Form State Machine
The auth form needs 5 views. A state machine hook manages transitions:
stateDiagram-v2 [*] --> login login --> register: click "Register" tab register --> login: click "Login" tab login --> verify_email: signIn returns needsVerification register --> verify_email: signUp succeeds login --> forgot_password: click "Forgot password?" forgot_password --> reset_password: OTP sent successfully forgot_password --> login: click "Back to login" reset_password --> login: password reset success (auto-redirect) reset_password --> login: click "Back to login" verify_email --> login: click "Back to login" verify_email --> [*]: verification success → onSuccess() login --> [*]: signIn success → onSuccess()
// hooks/useAuthForm.ts
export type AuthView =
| 'login'
| 'register'
| 'verify-email'
| 'forgot-password'
| 'reset-password';
export function useAuthForm(onSuccess: () => void) {
const { signIn, signUp, verifyEmailOtp, /* ... */ } = useAuth();
const [view, setView] = useState<AuthView>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [otp, setOtp] = useState('');
const [resendCooldown, setResendCooldown] = useState(0);
// ... other state fields
// KEY: Auto-detect unverified user from session restore
const autoDetectedRef = useRef(false);
useEffect(() => {
if (user && !user.emailVerified && !autoDetectedRef.current) {
autoDetectedRef.current = true;
setEmail(user.email);
setView('verify-email');
}
}, [user]);
// Cooldown timer for resend button
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [resendCooldown]);
const handleLogin = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
const result = await signIn(email, password);
if (result.needsVerification) {
setOtp('');
setView('verify-email');
setResendCooldown(60);
} else {
onSuccess();
}
}, [email, password, signIn, onSuccess]);
const handleRegister = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
await signUp(email, password, name);
setOtp('');
setView('verify-email');
setResendCooldown(60);
}, [email, password, name, signUp]);
const handleVerifyEmail = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
// Pass saved password for fallback re-sign-in
await verifyEmailOtp(email, otp, password);
onSuccess();
}, [email, otp, password, verifyEmailOtp, onSuccess]);
// ... handleResendOtp, handleForgotPassword, handleResetPassword
}Why the Password Must Be Preserved
The password state is kept alive across the login → verify-email transition. This is essential because:
- User enters email + password and clicks “Login”
- Better Auth rejects with “Email not verified” (no session created)
- User enters OTP and verifies
verifyEmailmay not create a session- The hook needs to re-call
signIn.email({ email, password })— it needs the original password
If you clear the password when transitioning views, the fallback re-sign-in fails silently.
Step 4: OTP Input Component
A 6-digit OTP input with individual boxes:
// components/OtpInput.tsx
import { useRef, useCallback } from 'react';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
}
export function OtpInput({
value,
onChange,
length = 6,
disabled = false,
}: OtpInputProps) {
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
// IMPORTANT: Always produce exactly `length` elements.
// Do NOT use: value.padEnd(length, '').split('')
// '' as padString is a no-op — produces 0 elements when value is empty!
const digits = Array.from({ length }, (_, i) => value[i] ?? '');
const focusInput = useCallback((index: number) => {
inputsRef.current[index]?.focus();
}, []);
const handleChange = useCallback(
(index: number, char: string) => {
if (!/^\d?$/.test(char)) return; // digits only
const next = [...digits];
next[index] = char;
onChange(next.join(''));
if (char && index < length - 1) focusInput(index + 1);
},
[digits, length, onChange, focusInput]
);
const handleKeyDown = useCallback(
(index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !digits[index] && index > 0) {
e.preventDefault();
const next = [...digits];
next[index - 1] = '';
onChange(next.join(''));
focusInput(index - 1);
} else if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault();
focusInput(index - 1);
} else if (e.key === 'ArrowRight' && index < length - 1) {
e.preventDefault();
focusInput(index + 1);
}
},
[digits, length, onChange, focusInput]
);
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault();
const pasted = e.clipboardData
.getData('text')
.replace(/\D/g, '')
.slice(0, length);
if (pasted) {
onChange(pasted);
focusInput(Math.min(pasted.length, length - 1));
}
},
[length, onChange, focusInput]
);
return (
<div className="flex gap-2 justify-center">
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => { inputsRef.current[i] = el; }}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
disabled={disabled}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={i === 0 ? handlePaste : undefined}
onFocus={(e) => e.target.select()}
className="w-11 h-12 text-center text-xl font-mono
bg-gray-900 border border-gray-600 rounded
text-white focus:outline-none focus:border-blue-400
disabled:opacity-50 transition-colors"
/>
))}
</div>
);
}Step 5: Gating Access on emailVerified
Add emailVerified to your user type:
interface AuthUser {
id: string;
email: string;
name: string | null;
emailVerified: boolean; // <-- add this
}Gate protected routes:
// App.tsx — route guard
if (user && user.emailVerified) {
// show protected content
}
// WelcomePage.tsx — conditional UI
{user?.emailVerified ? (
<GameButtons />
) : (
<AuthForm /> // useAuthForm auto-detects unverified user
)}When a user has a session but emailVerified === false (e.g., browser refresh after registering but before verifying), the useAuthForm hook auto-detects this and transitions to the verify-email view.
Pitfalls & Gotchas
Pitfall 1: Manual fetch() to OTP Endpoints
Problem: Constructing URLs manually like ${NEON_AUTH_URL}/api/auth/email-otp/verify-email fails because the Neon Auth URL already contains a path (e.g., /neondb/auth), and Better Auth adds its own /api/auth prefix internally.
Symptom: Failed to execute 'json' on 'Response': Unexpected end of JSON input — the endpoint doesn’t exist at the constructed URL, returns an empty response.
Solution: Use the SDK’s runtime plugin methods via any cast. They handle URL routing internally.
// WRONG — manually constructing URLs
const res = await fetch(`${authBaseUrl}/api/auth/email-otp/verify-email`, { ... });
const json = await res.json(); // crashes on empty response
// CORRECT — use runtime plugin methods
const client = authClient as any;
const { data, error } = await client.emailOtp.verifyEmail({ email, otp });Pitfall 2: Sign-In Error Is a Dead End
Problem: When an unverified user tries to sign in, Better Auth returns { error: { message: "Email not verified" } }. If you just display this error on the login form, the user has no way to verify — they’re stuck.
Symptom: Login form shows “Email not verified” with no path forward.
Solution: Detect the error with a regex and redirect to the OTP verification view:
const EMAIL_NOT_VERIFIED_RE = /email.*(not|isn.t)\s*verified/i;
if (error) {
if (EMAIL_NOT_VERIFIED_RE.test(error.message ?? '')) {
await emailOtp.sendVerificationOtp(email, 'email-verification');
return { needsVerification: true };
}
throw new Error(error.message);
}Pitfall 3: signUp Setting User as Logged In
Problem: Calling setUser() after signUp makes the app think the user is authenticated, completely bypassing email verification.
Symptom: User registers and is immediately logged in without verifying their email.
Solution: After signUp, return { needsVerification: true } and transition to the OTP view. Do NOT call setUser().
Pitfall 4: No Session After Verification
Problem: The original sign-in was rejected (no session created). After verifyEmail, calling checkSession() may not find a session.
Symptom: User verifies successfully but remains logged out.
Solution: After verification, check if a session exists. If not, re-sign-in with the saved password:
await emailOtp.verifyEmail(email, otp);
await checkSession();
const currentUser = store.getState().user;
if (!currentUser && savedPassword) {
await authClient.signIn.email({ email, password: savedPassword });
}Pitfall 5: padEnd with Empty String
Problem: ''.padEnd(6, '').split('') returns [] — zero elements — because padEnd with an empty fill string is a no-op.
Symptom: OTP input boxes are not rendered at all. The verification view shows the title and buttons but no input fields.
Solution: Use Array.from:
// WRONG — renders 0 inputs when value is ''
const digits = value.padEnd(length, '').split('');
// CORRECT — always renders exactly `length` inputs
const digits = Array.from({ length }, (_, i) => value[i] ?? '');Pitfall 6: Non-Standard CSS Classes
Problem: Using CSS utility classes that don’t exist in your framework (e.g., h-13 is not a standard Tailwind CSS class).
Symptom: Input boxes exist in the DOM but are invisible (zero height).
Solution: Always use valid utility classes. In Tailwind, height classes follow the scale: h-10 (2.5rem), h-11 (2.75rem), h-12 (3rem), h-14 (3.5rem). There is no h-13.
Pitfall 7: Google OAuth Doesn’t Need Verification
Google OAuth users have their email verified by Google. Neon Auth sets emailVerified: true automatically. Do not show the OTP flow for social auth users — only show it for email/password auth.
Complete File Reference
File Structure
src/
├── auth.ts # Auth client + emailOtp wrapper
├── hooks/
│ ├── useAuth.ts # Core auth logic (signIn, signUp, verify, reset)
│ └── useAuthForm.ts # Multi-view form state machine
├── components/
│ ├── ui/
│ │ └── OtpInput.tsx # 6-digit OTP input component
│ └── auth/
│ ├── AuthFormBody.tsx # Renders all 5 auth views
│ ├── InlineAuthForm.tsx # Inline variant (e.g., welcome page)
│ └── AuthModal.tsx # Modal variant
├── stores/
│ └── slices/
│ └── authSlice.ts # AuthUser type with emailVerified
└── App.tsx # Route guard checking emailVerified
Auth Views
| View | Shown When | Actions Available |
|---|---|---|
login | Default | Sign in, switch to register, forgot password, Google OAuth |
register | User clicks register tab | Sign up with name/email/password, Google OAuth |
verify-email | After signup, after sign-in with unverified email, or session restore with unverified user | Enter OTP, resend OTP (with 60s cooldown), back to login |
forgot-password | User clicks “Forgot password?” | Enter email to receive reset OTP, back to login |
reset-password | After forgot-password OTP is sent | Enter OTP + new password, back to login |
Better Auth email-otp Plugin — Server Endpoints
These are the endpoints the plugin methods call internally. You should not call them directly, but understanding them helps with debugging:
| Endpoint | Method | Body | Purpose |
|---|---|---|---|
/email-otp/send-verification-otp | POST | { email, type } | Send OTP email |
/email-otp/verify-email | POST | { email, otp } | Verify email, create session |
/forget-password/email-otp | POST | { email } | Send password reset OTP |
/email-otp/reset-password | POST | { email, otp, password } | Reset password |
type can be: 'email-verification', 'sign-in', or 'forget-password'.
Summary
The Neon Auth email-otp integration has a straightforward server side (just enable it in the dashboard) but a surprisingly tricky client side. The main challenges are:
- TypeScript types don’t expose the plugin methods — use
anycast - Better Auth blocks sign-in for unverified users — intercept the error, don’t just display it
- Sign-in rejection means no session — need fallback re-sign-in after verification
- Password must survive view transitions — keep it in state for the re-sign-in fallback
signUpmust not set the user — or you bypass verification entirely
Once you understand these behaviors, the implementation is clean: a typed wrapper, an auth hook with error interception, and a 5-view form state machine.