Skip to content

Password Reset

The password reset flow is handled entirely by Better-auth's built-in email verification system, with Nodemailer sending emails through Mailpit (dev) or SMTP (production).

Flow

sequenceDiagram
    participant User
    participant Frontend
    participant Hono as Hono API
    participant Email

    User->>Frontend: Enter email on "Forgot Password" screen
    Frontend->>Hono: POST /api/auth/forget-password { email }
    Hono->>Hono: Generate reset token, save to DB
    Hono->>Email: Send reset email with link
    Hono-->>Frontend: 200 OK

    User->>Frontend: Click link in email → Reset Password screen
    Frontend->>Hono: POST /api/auth/reset-password { token, newPassword }
    Hono->>Hono: Validate token, update password (Argon2), invalidate token
    Hono-->>Frontend: 200 OK
    Frontend->>Frontend: Redirect to sign-in

API Endpoints

Request Reset

POST /api/auth/forget-password
Content-Type: application/json

{ "email": "user@example.com" }

Always returns 200 OK regardless of whether the email exists (prevents enumeration).

Confirm Reset

POST /api/auth/reset-password
Content-Type: application/json

{
  "token": "<token-from-email-link>",
  "newPassword": "NewPassword123!"
}

Returns 200 OK on success, 400 if the token is invalid or expired.

Email Configuration

Development (Mailpit)

# backend/.env
EMAIL_FROM=noreply@coachingapp.local
SMTP_HOST=localhost
SMTP_PORT=1025

Open Mailpit at http://localhost:8025 to inspect sent emails.

Production (SMTP)

EMAIL_FROM="Coaching App <noreply@yourdomain.com>"
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<sendgrid-api-key>

Email Template

Better-auth calls a custom sendEmail function configured in backend/src/lib/auth.ts. The template lives in backend/templates/:

// backend/src/lib/auth.ts
export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: 'Reset your password',
        html: renderTemplate('password-reset', {
          name: user.name,
          resetUrl: url,
          expiresIn: '1 hour',
        }),
      });
    },
  },
});

The reset link is constructed from BETTER_AUTH_URL + /reset-password?token=<token>. The frontend handles this route in frontend/src/screens/auth/ResetPasswordScreen.tsx.

Security

  • Reset tokens expire after 1 hour by default.
  • Each token is single-use — it's invalidated immediately after use.
  • Always returns 200 OK for the request step (prevents email enumeration).
  • Rate limiting is handled at the Cloudflare level; see Cloudflare Setup.

Testing

# Start dev stack
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Trigger password reset
curl -X POST http://localhost:3001/api/auth/forget-password \
  -H "Content-Type: application/json" \
  -d '{"email":"teacher@coaching.test"}'

# View email at http://localhost:8025