feat(auth): add email verification for signup with 6-digit OTP#152
feat(auth): add email verification for signup with 6-digit OTP#152
Conversation
Users must now verify their email address during registration. A 6-digit code is sent via Resend API, and the account is only created after successful verification. In local development, codes are logged to the console instead of sending emails. - Add pendingRegistrations table and D1 migration - Add verification code generation and Resend email sending utility - Add VerifyEmailPage component with code input and resend button - Rewrite emailAuth routes: register stores pending, verify creates user - Add resend-code endpoint for re-sending verification emails - Update README with Resend configuration and setup instructions - 87 tests passing, lint clean, build clean
There was a problem hiding this comment.
Pull request overview
Adds an email verification step to the email/password signup flow by creating a pending registration, sending a 6-digit OTP via Resend (or logging in dev), and only creating the user after OTP verification.
Changes:
- Introduces OTP generation/expiry + Resend email-sending utilities and unit tests.
- Reworks email auth routes to store pending registrations, verify OTPs, and allow resending codes.
- Adds a new
pending_registrationsD1 table/migration and a server-rendered verification page + styles.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| hono/types.ts | Adds Resend-related environment bindings. |
| hono/testApp.ts | Adds default Resend env vars for tests. |
| hono/routes/emailAuth.ts | Implements pending registration, OTP verification, and resend endpoints. |
| hono/routes/emailAuth.test.ts | Expands route tests to cover the new OTP flows. |
| hono/db/schema.ts | Adds Drizzle schema for pending_registrations. |
| hono/db/migrations/0001_nosy_starfox.sql | Creates the pending_registrations table + unique email index. |
| hono/db/migrations/meta/_journal.json | Registers the new migration in the migration journal. |
| hono/db/migrations/meta/0001_snapshot.json | Adds Drizzle snapshot metadata for the new table. |
| hono/components/VerifyEmailPage.tsx | Adds the email verification page UI with OTP input + resend action. |
| hono/components/AuthPage.css | Adds styling for the verification page. |
| hono/auth/emailVerification.ts | Adds OTP generation/expiry helpers and Resend email sending. |
| hono/auth/emailVerification.test.ts | Adds unit tests for OTP utilities and Resend request behavior. |
| README.md | Documents email verification and Resend configuration. |
| .env.sample | Adds Resend env placeholders. |
Comments suppressed due to low confidence (1)
hono/auth/emailVerification.test.ts:90
- The tests stub
globalThis.fetchand then callvi.unstubAllGlobals()at the end of each test. If an assertion throws before cleanup, the stub can leak into later tests. Consider moving the cleanup intoafterEach(or usingtry/finally) to guarantee restoration.
it("should call Resend API with correct parameters", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", mockFetch);
const result = await sendVerificationEmail("re_test_key", "noreply@example.com", "user@example.com", "123456");
expect(result).toEqual({ success: true });
expect(mockFetch).toHaveBeenCalledOnce();
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe("https://api.resend.com/emails");
expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe("Bearer re_test_key");
const body = JSON.parse(options.body);
expect(body.from).toBe("noreply@example.com");
expect(body.to).toEqual(["user@example.com"]);
expect(body.subject).toBe("MLack - Verify your email");
expect(body.html).toContain("123456");
vi.unstubAllGlobals();
});
it("should return error on API failure", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
text: () => Promise.resolve("validation error"),
});
vi.stubGlobal("fetch", mockFetch);
const result = await sendVerificationEmail("re_test_key", "noreply@example.com", "user@example.com", "123456");
expect(result).toEqual({ success: false, error: "Failed to send verification email." });
vi.unstubAllGlobals();
});
| const [existingPending] = await db | ||
| .select() | ||
| .from(pendingRegistrations) | ||
| .where(eq(pendingRegistrations.email, email)) | ||
| .limit(1); | ||
|
|
||
| if (existingPending) { | ||
| await db | ||
| .update(pendingRegistrations) | ||
| .set({ name, passwordHash, verificationCode, expiresAt }) | ||
| .where(eq(pendingRegistrations.email, email)); | ||
| } else { | ||
| await db.insert(pendingRegistrations).values({ | ||
| email, | ||
| name, | ||
| passwordHash, | ||
| verificationCode, | ||
| expiresAt, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The pending registration “upsert” is implemented as select-then-update/insert, which is not atomic and can race under concurrent requests (both requests can observe no row and then one insert fails due to the unique email constraint). Consider using a single INSERT with an ON CONFLICT(email) DO UPDATE clause (Drizzle onConflictDoUpdate) so behavior matches the PR description and avoids uniqueness races.
There was a problem hiding this comment.
Done in 48fe756. Replaced the select-then-update/insert pattern with a single db.insert(pendingRegistrations).values(...).onConflictDoUpdate({ target: pendingRegistrations.email, set: {...} }) call, making the upsert atomic. Updated the test mocks and assertions accordingly.
| emailAuth.post("/auth/resend-code", async (c) => { | ||
| try { | ||
| const body = await c.req.parseBody(); | ||
| const email = body.email as string; | ||
|
|
||
| if (!email) { | ||
| return c.redirect("/auth/register"); | ||
| } | ||
|
|
||
| const db = getDb(c.env.DB); | ||
| const [pending] = await db | ||
| .select() | ||
| .from(pendingRegistrations) | ||
| .where(eq(pendingRegistrations.email, email)) | ||
| .limit(1); | ||
|
|
||
| if (!pending) { | ||
| return c.html( | ||
| `<!DOCTYPE html>${await VerifyEmailPage({ email, error: "No pending registration found. Please register again." })}`, | ||
| 400, | ||
| ); | ||
| } | ||
|
|
||
| const verificationCode = generateVerificationCode(); | ||
| const expiresAt = createExpiresAt(); | ||
|
|
||
| await db | ||
| .update(pendingRegistrations) | ||
| .set({ verificationCode, expiresAt }) | ||
| .where(eq(pendingRegistrations.email, email)); | ||
|
|
||
| if (c.env.NODE_ENV === "development") { | ||
| console.log(`[DEV] Verification code for ${email}: ${verificationCode}`); | ||
| } else { | ||
| const emailResult = await sendVerificationEmail( | ||
| c.env.RESEND_API_KEY, | ||
| c.env.RESEND_FROM_EMAIL, | ||
| email, | ||
| verificationCode, | ||
| ); | ||
| if (!emailResult.success) { | ||
| return c.html( | ||
| `<!DOCTYPE html>${await VerifyEmailPage({ email, error: "Failed to resend verification email. Please try again." })}`, | ||
| 500, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return c.html( | ||
| `<!DOCTYPE html>${await VerifyEmailPage({ email, success: "A new verification code has been sent." })}`, | ||
| ); |
There was a problem hiding this comment.
/auth/resend-code (and /auth/verify-email) can be called repeatedly without any throttling or attempt limit, which makes it easy to spam Resend emails and brute-force 6-digit OTPs. Consider tracking lastSentAt/sendCount and attemptCount in pending_registrations and enforcing a cooldown (e.g. 30–60s between sends) + max attempts before invalidating the pending registration.
| export async function sendVerificationEmail( | ||
| apiKey: string, | ||
| fromEmail: string, | ||
| toEmail: string, | ||
| code: string, | ||
| ): Promise<SendEmailResult> { | ||
| const response = await fetch("https://api.resend.com/emails", { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| from: fromEmail, | ||
| to: [toEmail], | ||
| subject: "MLack - Verify your email", | ||
| html: ` | ||
| <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;"> | ||
| <h2 style="color: #333;">Verify your email</h2> | ||
| <p style="color: #555; font-size: 16px;">Your verification code is:</p> | ||
| <div style="background: #f4f4f4; padding: 16px; border-radius: 8px; text-align: center; margin: 24px 0;"> | ||
| <span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #111;">${code}</span> | ||
| </div> | ||
| <p style="color: #888; font-size: 14px;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.</p> | ||
| </div> | ||
| `, | ||
| }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const body = await response.text(); | ||
| console.error("Resend API error:", response.status, body); | ||
| return { success: false, error: "Failed to send verification email." }; | ||
| } |
There was a problem hiding this comment.
sendVerificationEmail does not handle fetch throwing (network/DNS/runtime errors). In those cases the exception bubbles up and the caller returns a generic registration/verification 500. Consider wrapping the fetch in try/catch and returning { success: false, error: ... } so callers can show a consistent “failed to send email” message and avoid unhandled promise rejections in this utility.
Add 11 tests covering HTML structure, form actions, input validation
attributes, conditional error/success messages, and navigation link.
Change pattern="\d{6}" to pattern="[0-9]{6}" for unambiguous
digit matching without backslash escaping concerns.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…n upsert Agent-Logs-Url: https://github.com/mahata/mlack/sessions/4d1b9b6b-7abc-4560-ba9f-48ea6206c6f4 Co-authored-by: mahata <23497+mahata@users.noreply.github.com>
Summary
NODE_ENV=development), codes are logged to the Wrangler consolepending_registrationsD1 table (with upsert on email and 10-minute expiry)Changes
New files
hono/auth/emailVerification.ts— verification code generation and Resend API email sendinghono/auth/emailVerification.test.ts— 8 unit tests for email verification utilitieshono/components/VerifyEmailPage.tsx— server-rendered verification page with code input and resend buttonhono/db/migrations/0001_nosy_starfox.sql— D1 migration forpending_registrationstableModified files
hono/db/schema.ts— addedpendingRegistrationstable schemahono/types.ts— addedRESEND_API_KEYandRESEND_FROM_EMAILtoBindingshono/routes/emailAuth.ts— rewritten:POST /auth/registerstores pending registration and sends OTP; addedGET/POST /auth/verify-emailandPOST /auth/resend-codehono/routes/emailAuth.test.ts— rewritten with 29 tests covering all new routeshono/components/AuthPage.css— added verification page styleshono/testApp.ts— added Resend mock env vars.env.sample— addedRESEND_API_KEYandRESEND_FROM_EMAILplaceholdersREADME.md— documented Resend setup for local dev and production deploymentHow to test
pnpm install && pnpm db:migratepnpm test:run— 87 tests passpnpm lint— cleanpnpm build— cleanpnpm dev— register a new account, check Wrangler console for the verification code