Authentication Flow

How sign-up, sign-in, email verification, and related flows work in AgentKanban.


Overview

Authentication is implemented with better-auth on top of a Drizzle / Postgres database. Transactional email (verification, password reset, change-email, invitations) is delivered via Resend.

Supported credential types:


Registration (sign-up)

Goal: zero friction to activation. A user who completes the sign-up form is signed in immediately. Email verification happens in the background and does not block access to the app.

  1. User submits name, email, and password on /register.
  2. better-auth creates the user, account, and session rows and sets the session cookie.
  3. A verification email is sent in the background (see below).
  4. The browser is redirected to the post-sign-up destination (typically /dashboard).

Why not require verification upfront?

Requiring email verification before the first session is a well-known conversion killer: users drop off while switching to their inbox or never return. Instead we:


Email verification

Every verification email points at /api/auth/verify-email?token=<jwt>&callbackURL=<relative-url>. The token is a stateless JWT signed by BETTER_AUTH_SECRET, valid for 1 hour by default.

When the link is clicked, better-auth:

  1. Verifies the JWT.
  2. Sets user.email_verified = true.
  3. Creates a session if the client is unauthenticated (autoSignInAfterVerification).
  4. Redirects to callbackURL.

Consistent email query string

Every verification email (sign-up, resend, change-email-verification, change-email-confirmation) routes through a single sendVerificationEmail callback. The callback calls ensureEmailInCallbackURL(url, user.email) which appends ?email=<user-email> to the embedded callbackURL if not already present.

This means a user who clicks a link on a device where they are not signed in lands on /login?email=foo@bar.com with the email pre-filled. They only need to enter their password.

Resending

The account page shows a Resend verification email button whenever user.emailVerified is false. After a successful send we persist user.email_verification_last_sent_at so returning users see "Last sent N minutes ago" next to the button and can make an informed decision about resending. The label disappears once the verification JWT has expired (1 hour) because the existing link would no longer work.

better-auth's rate-limit layer guards the /send-verification-email endpoint (3 requests per 10 seconds per IP in production).


Sign-in (login)

  1. User opens /login. If the URL contains ?email=..., the email field is pre-populated.
  2. On success, the session cookie is set and the user is redirected to redirectTo (defaults to /dashboard).

Session conflict

If a user is already signed in and visits /login, a notice is shown and the previous session is signed out before the new sign-in. This applies to both email / password and social logins.

Two-factor

If 2FA is enabled, the sign-in response indicates twoFactorRedirect: true. The login page renders a TOTP challenge and on success redirects to the dashboard.

Passkeys

Passkey sign-in uses WebAuthn (Touch ID, Windows Hello, security keys). The passkey sign-in button only appears when the browser supports WebAuthn. Passkey sign-in skips the TOTP 2FA challenge because passkeys are inherently multi-factor (device possession plus biometric / PIN).

OAuth

"Sign in with GitHub" and "Sign in with Google" redirect to the provider. On callback, better-auth either creates a new user or links the OAuth identity to an existing account with the same email. Linking from the account page is only allowed for verified users, to prevent claiming an email the user does not own.


Password reset

  1. User submits their email on /forgot-password.
  2. A reset link is sent via Resend; the user clicks it and sets a new password.
  3. revokeSessionsOnPasswordReset: true invalidates all other sessions on success.

Password reset works independently of email verification status.


Change email

The account page lets a signed-in user change their email. better-auth performs a two-step process:

  1. Confirmation email at the current address (so a compromised session cannot quietly move the account elsewhere).
  2. Verification email at the new address.

Both emails route through the same sendVerificationEmail callback, so they benefit from the ?email= rewrite and the last-sent timestamp.


Rate limits

better-auth's default limits apply globally (100 requests per 10 seconds per IP) and are tightened for auth-critical routes (3 requests per 10 seconds per IP): sign-up, sign-in, change-password, change-email, send-verification-email, forget-password. Limits are enforced in production; in development they are relaxed.