Skip to content

04. Authentication

Maruf Bepary edited this page Jun 17, 2026 · 2 revisions

Overview

AI Client employs Better Auth v1.6.8 as its authentication framework — a self-hosted, TypeScript-first solution that provides comprehensive identity management without reliance on third-party authentication services. The system is highly configurable, allowing administrators to enable or disable specific authentication methods through environment variables to suit different deployment security postures. The framework connects to a PostgreSQL database through a Drizzle ORM adapter, ensuring type-safe schema management and query construction across six authentication-related tables: user, session, account, verification, passkey, and two_factor.

The system supports five authentication methods: email and password with scrypt hashing, GitHub OAuth, Discord OAuth, WebAuthn passkeys for passwordless authentication, and TOTP-based two-factor authentication with backup codes. This multi-method approach allows users to choose the credential type that best suits their security requirements and workflow preferences.

Session management uses a JWT-based cookie cache strategy. Session tokens are stored in HTTP-only cookies managed by the Next.js cookies plugin, with a seven-day expiration period and automatic refresh every twenty-four hours. The JWT strategy eliminates the need for a database lookup on every request by encoding session data into a signed token, whilst the underlying session record remains persisted in PostgreSQL for revocation and audit purposes.

Route protection operates on two layers: server-side session guards enforce authentication at the top of every protected Server Action, throwing an unauthorised error if no valid session exists; client-side layout wrappers redirect unauthenticated users before rendering protected routes. Transactional emails — including welcome messages, email verification links, password reset tokens, and account deletion confirmations — are dispatched through Postmark.

flowchart TB
    subgraph Client["Client Application"]
        UI["React UI"]
        AuthClient["Auth Client SDK"]
    end

    subgraph Server["Next.js Server"]
        API["Auth API Route\n(catch-all handler)"]
        Guard["Session Guard"]
        Actions["Server Actions"]
    end

    subgraph BetterAuth["Better Auth Core"]
        Plugins["Plugins\n(Cookies · 2FA · Passkey)"]
        SessionMgr["Session Manager\n(JWT Cookie Cache)"]
    end

    subgraph External["External Services"]
        GitHub["GitHub OAuth"]
        Discord["Discord OAuth"]
        Postmark["Postmark Email"]
    end

    DB[("PostgreSQL\nvia Drizzle ORM")]

    UI --> AuthClient
    AuthClient -->|"HTTP requests"| API
    API --> BetterAuth
    BetterAuth --> Plugins
    BetterAuth --> SessionMgr
    SessionMgr -->|"read/write sessions"| DB
    BetterAuth -->|"user & account CRUD"| DB
    BetterAuth -->|"OAuth flow"| GitHub
    BetterAuth -->|"OAuth flow"| Discord
    BetterAuth -->|"transactional emails"| Postmark
    Actions -->|"requireSession()"| Guard
    Guard -->|"validate session"| BetterAuth
Loading

Authentication Methods

AI Client supports five distinct authentication methods, each implemented as a Better Auth plugin or core feature. Users may register multiple credential types against a single account, and the system handles account linking automatically when the same email address appears across providers.


Email and Password

Email and password authentication serves as the primary credential-based method. This feature can be toggled via the NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD environment variable (defaults to true). When disabled, the emailAndPassword provider is deactivated at the engine level, and the UI conditionally hides all related input fields and management tools.

Passwords are hashed using scrypt, a memory-hard key derivation function recommended by OWASP when Argon2id is unavailable. Scrypt is natively supported by the Node.js runtime, requiring no external binary dependencies. The hashing algorithm's memory-intensive design makes brute-force and GPU-accelerated attacks computationally prohibitive.

Passwords are stored in the account table under records with a credential provider identifier — not in the user table itself. This architectural separation ensures that the user record remains provider-agnostic: a single user may hold both a credential account (with a hashed password) and one or more OAuth accounts (with access tokens), all linked through a shared user identifier. The account table enforces a unique constraint on the combination of user identifier and provider identifier, preventing duplicate provider registrations.

A minimum password length of six characters is enforced at the validation schema level using Zod, with a maximum of one hundred characters. Password validation occurs client-side via form schema resolution and is re-validated server-side by Better Auth before hashing and storage. When email/password authentication is disabled, the system-wide password reset page (/auth/reset-password) automatically redirects to the login page. Additionally, password management tools — such as the "Change Password" and "Set Password" forms in the user's profile Security tab — are hidden from the interface.

Email verification is mandatory. Upon registration, the system dispatches a verification email containing a time-limited token link. Users cannot sign in until verification is complete; attempts to authenticate with an unverified email address return a forbidden response. Once verified, the system automatically signs the user in and redirects to the application. A resend mechanism with a thirty-second cooldown prevents abuse of the verification endpoint.

The password reset flow begins when a user requests a reset link, which is sent to the registered email address. The link contains a cryptographic token that, when valid, permits the user to set a new password. Invalid or expired tokens redirect to an error state. The reset form enforces the same minimum and maximum password constraints as registration.

Email enumeration protection is enabled by the mandatory verification requirement. When a user attempts to register with an email address that already exists, the system returns a success-equivalent response rather than indicating that the address is already registered. This behaviour aligns with OWASP guidance on preventing user enumeration through registration endpoints.


OAuth Providers

AI Client supports two OAuth providers: GitHub and Discord. Both integrations use the OAuth 2.0 Authorisation Code Flow, where the server holds the client secret and performs a server-to-server token exchange — the client secret never reaches the browser.

GitHub OAuth uses the default scopes provided by Better Auth, which include access to the user's public profile information and primary email address. The user:email scope is requested by default, ensuring that the email address is available for account creation and linking.

Discord OAuth similarly uses default scopes, granting access to the user's identity and email. Both providers populate the user table with the display name and email retrieved from the provider's profile endpoint, and create an account record with the provider's access token, refresh token, and scope.

Account linking is handled automatically by email address matching. When a user authenticates via OAuth and an existing user record shares the same email, the OAuth account is linked to that user rather than creating a duplicate. This allows a user who initially registered with email and password to subsequently sign in via GitHub or Discord without losing access to their existing data. The account table maintains a unique constraint on the combination of user identifier and provider identifier, ensuring that each provider can be linked only once per user.

OAuth callbacks are handled by the Better Auth catch-all API route, which processes the authorisation code, exchanges it for tokens via a server-to-server request, upserts the user and account records, creates a session, and redirects the user to the application. No custom callback URLs are configured; Better Auth auto-generates them from the configured base URL.

After a successful email-and-password registration, a welcome email is dispatched through a post-sign-up middleware hook. The hook intercepts requests to the sign-up path, which does not match OAuth callback paths; consequently, OAuth sign-ups do not trigger a welcome email.

sequenceDiagram
    participant User as User Browser
    participant App as AI Client Server
    participant Provider as OAuth Provider<br/>(GitHub / Discord)
    participant DB as PostgreSQL

    User->>App: Click "Sign in with GitHub/Discord"
    App->>User: Redirect to provider authorisation URL<br/>(with client_id, redirect_uri, state, scope)
    User->>Provider: Authenticate and grant consent
    Provider->>App: Redirect to callback URL<br/>(with authorisation code + state)
    App->>Provider: Exchange code for tokens<br/>(server-to-server, includes client_secret)
    Provider-->>App: Return access_token, refresh_token, id_token
    App->>Provider: Fetch user profile (name, email)
    Provider-->>App: Return profile data
    App->>DB: Upsert user record (match by email)
    App->>DB: Upsert account record (provider + tokens)
    App->>DB: Create session record
    App->>User: Set session cookie and redirect to application
Loading

WebAuthn Passkeys

AI Client supports WebAuthn passkeys — a passwordless authentication method based on the FIDO2 standard. Passkeys use asymmetric cryptography: a private key remains on the user's device (protected by biometrics, a PIN, or a hardware security key), whilst the corresponding public key is stored on the server. This design ensures that the server never possesses the user's authentication secret, eliminating the risk of credential theft through database compromise.

The passkey implementation is powered by SimpleWebAuthn, a standards-compliant library that handles the WebAuthn ceremony (challenge generation, attestation verification, and assertion verification) under the hood. The Better Auth passkey plugin wraps SimpleWebAuthn and manages credential storage in the passkey table.

Each passkey record stores the public key, a unique credential identifier, the device type (single-device or multi-device), backup status, supported transport methods (such as internal platform authenticator or USB security key), and an authentication counter. The counter is a monotonically increasing integer that the authenticator increments on each use. The server verifies that the counter in each authentication response exceeds the stored value, providing replay attack protection — a cloned authenticator would present a stale or duplicate counter, causing the server to reject the assertion.

Passkey registration requires an authenticated session. The user initiates registration from their profile or settings, which triggers the WebAuthn registration ceremony: the server generates a challenge, the browser prompts the user to create a credential via their platform authenticator, and the resulting attestation (containing the public key and credential identifier) is sent to the server for verification and storage. Users may register multiple passkeys and assign each a human-readable name for identification.

Passkey authentication does not require an existing session. The user clicks the passkey sign-in button, the server generates an authentication challenge, the browser prompts the user to select a registered credential and verify their identity (via biometrics or PIN), and the signed assertion is sent to the server for verification. Upon successful verification, a full session is created.

Passkeys are inherently phishing-resistant because the credential is cryptographically bound to the relying party's origin (domain). A phishing site on a different domain cannot trigger the browser's WebAuthn prompt for credentials registered to the legitimate domain, as the browser enforces origin matching before presenting the credential selector.

sequenceDiagram
    participant User as User Device
    participant Browser as Browser
    participant Server as AI Client Server
    participant DB as PostgreSQL

    Note over User, DB: Passkey Registration (requires authenticated session)
    Browser->>Server: Request registration options
    Server->>Server: Generate challenge
    Server-->>Browser: Return challenge + relying party info
    Browser->>User: Prompt to create credential<br/>(biometrics / PIN / security key)
    User-->>Browser: Approve and create key pair
    Browser->>Server: Send attestation<br/>(public key, credential ID, counter)
    Server->>Server: Verify attestation
    Server->>DB: Store passkey record
    Server-->>Browser: Registration confirmed

    Note over User, DB: Passkey Authentication (no session required)
    Browser->>Server: Request authentication options
    Server->>Server: Generate challenge
    Server-->>Browser: Return challenge + allowed credentials
    Browser->>User: Prompt to select credential<br/>(biometrics / PIN / security key)
    User-->>Browser: Sign challenge with private key
    Browser->>Server: Send signed assertion<br/>(credential ID, signature, counter)
    Server->>DB: Retrieve stored passkey
    Server->>Server: Verify signature and counter
    Server->>DB: Update counter + create session
    Server-->>Browser: Set session cookie and authenticate
Loading

Two-Factor Authentication

AI Client implements two-factor authentication (2FA) using the TOTP (Time-based One-Time Password) algorithm, providing an additional verification layer beyond the primary credential. TOTP generates a unique six-digit code every thirty seconds based on a shared secret and the current time, following the RFC 6238 standard. Any standards-compliant authenticator application — such as Google Authenticator, Authy, or Microsoft Authenticator — can generate these codes.

When a user enables 2FA, the system generates an encrypted TOTP secret and a set of backup codes, both stored in the dedicated two_factor table. The secret is presented to the user as a QR code (encoded as a TOTP URI) for scanning into their authenticator application. The twoFactorEnabled flag on the user record is not set to true until the user successfully verifies a TOTP code from their authenticator, confirming that the secret was correctly provisioned.

Backup codes serve as a recovery mechanism when the authenticator application is unavailable — for instance, if the user loses their device. Each backup code is single-use: upon successful verification, the code is removed from the stored set and cannot be reused. Users may regenerate their backup codes at any time, which replaces the entire existing set. Both the TOTP secret and backup codes are stored in encrypted form.

2FA is enforced exclusively on credential-based sign-in (email and password). OAuth sign-in (GitHub, Discord) and passkey authentication bypass the 2FA challenge entirely, as these methods already provide strong authentication guarantees through their respective protocols — OAuth delegates trust to the identity provider, and passkeys rely on cryptographic proof of possession with biometric or PIN verification. Consequently, if email and password authentication is disabled via the environment toggle, the 2FA management interface and associated challenges are automatically hidden from the application.

The 2FA verification flow is handled client-side by the two-factor client plugin, which detects when a sign-in response contains a two-factor redirect signal and automatically navigates the user to the verification page. The user then enters either a six-digit TOTP code from their authenticator or a backup code. Successful verification completes the session creation and grants full access.

Enabling and disabling 2FA requires the user to confirm their current password, preventing an attacker who has gained temporary access to an authenticated session from silently disabling the second factor.

flowchart TD
    A([User submits email + password]) --> B{Credentials valid?}
    B -->|No| C([Return authentication error])
    B -->|Yes| D{2FA enabled?}
    D -->|No| E([Create session and authenticate])
    D -->|Yes| F([Return 2FA challenge signal])
    F --> G([Redirect to 2FA verification page])
    G --> H{Verification method}
    H -->|TOTP| I([Enter 6-digit code from authenticator])
    H -->|Backup code| J([Enter single-use backup code])
    I --> K{Code valid?}
    J --> K
    K -->|No| L([Return verification error])
    K -->|Yes| M([Create full session and authenticate])
    L --> G
Loading

Authentication Flows

The application supports multiple authentication methods, each following a distinct flow through the system. This section describes the lifecycle of each authentication pathway — from initial user action through to session establishment — including the intermediate validation, persistence, and notification steps.


Registration Flow

User registration follows an email-and-password model with server-side validation, cryptographic password storage, and mandatory email verification. The process creates two database records — a user record and an associated credential account record — before dispatching transactional emails and establishing a session. If the email/password provider is disabled, the registration interface omits the credential fields, leaving OAuth and passkeys as the available pathways.

When a user submits the registration form, the client performs Zod schema validation: the name field must be at least one character, the email must conform to RFC 5322 and not exceed 255 characters, and the password must be between 6 and 100 characters. If validation passes, the client calls the Better Auth sign-up endpoint.

On the server, Better Auth checks for an existing user with the same email address. To prevent user enumeration, when email verification is required and a matching user already exists, the server returns a 200 response identical to the success case — an attacker cannot distinguish between a new registration and a duplicate. If no conflict exists, Better Auth creates a user record with emailVerified set to false and an account record with providerId set to credential. The password is hashed using the scrypt algorithm — a memory-hard, CPU-intensive key derivation function natively supported by Node.js and recommended by OWASP — and stored in the account record's password field. The plaintext password is never persisted.

Following successful record creation, the system dispatches two transactional emails via Postmark: a welcome email greeting the user by name, and a verification email containing a time-limited link with a 24-hour expiry. Both emails include HTML and plain-text bodies for broad client compatibility. A post-sign-up middleware hook triggers the welcome email by intercepting requests to the sign-up path and extracting user details from the newly created session or request body.

The client then transitions the user interface to the email verification prompt, which displays instructions to check their inbox. A resend button with a 30-second cooldown allows the user to request a fresh verification email if the original does not arrive.

sequenceDiagram
    participant U as User
    participant C as Client
    participant S as Server
    participant DB as Database
    participant E as Email Service

    U->>C: Submit name, email, password
    C->>C: Validate input (Zod schema)
    C->>S: POST /api/auth/sign-up
    S->>DB: Check for existing user by email
    alt User exists
        S-->>C: 200 OK (identical to success)
    else New user
        S->>S: Hash password (scrypt)
        S->>DB: Insert user record (emailVerified: false)
        S->>DB: Insert account record (providerId: credential)
        S->>S: Create session
        S-->>C: 200 OK with session
        S->>E: Send welcome email
        S->>E: Send verification email (24h expiry)
    end
    C->>U: Show email verification prompt
Loading

Sign-In Flow

The sign-in flow authenticates a user against their stored credentials and, where two-factor authentication is enabled, introduces an additional verification step before establishing a full session. Similar to registration, if the email/password toggle is disabled, the sign-in form is restricted to OAuth providers and passkey authentication. The branching logic between single-factor and two-factor paths is handled entirely by Better Auth based on the user record's twoFactorEnabled flag.

When the user submits their email and password, the client validates the input against the sign-in schema: the email must be a valid address not exceeding 255 characters, and the password must be at least one character (no minimum length is enforced for existing passwords — only that the field is non-empty). The client then calls the Better Auth sign-in endpoint.

On the server, Better Auth locates the user by email and retrieves the associated credential account record. If no matching user or account exists, the server returns a generic error to prevent user enumeration. If the user's email has not been verified and email verification is required, the server returns a 403 status. The client detects this specific error code and transitions the interface to the email verification tab, allowing the user to request a new verification email.

When the user exists and the email is verified, Better Auth performs a constant-time comparison of the submitted password against the stored scrypt hash. Constant-time comparison prevents timing side-channel attacks that could otherwise reveal whether a partial password match occurred.

If password verification succeeds and two-factor authentication is not enabled on the account, Better Auth creates a session record in the database, generates a JWT-encoded session token using HS256, and sets it as an HTTP-only cookie. The user is redirected to the application.

If the user has two-factor authentication enabled, the system redirects to the verification page where the user must provide a valid TOTP code or backup code, as described in Section 2.4. Only upon successful two-factor verification is the full session established.

flowchart TD
    A([User submits email and password]) --> B[Client validates input]
    B --> C[POST /api/auth/sign-in]
    C --> D{User exists?}
    D -->|No| E[Return generic error]
    D -->|Yes| F{Email verified?}
    F -->|No| G[Return 403 — show verification prompt]
    F -->|Yes| H[Compare password hash — constant-time]
    H --> I{Password valid?}
    I -->|No| J[Return generic error]
    I -->|Yes| K{2FA enabled?}
    K -->|No| L[Create session record in database]
    L --> M[Generate JWT — HS256]
    M --> N[Set HTTP-only cookie]
    N --> O([Redirect to application])
    K -->|Yes| P[Return twoFactorRedirect: true]
    P --> Q[Redirect to 2FA challenge page]
    Q --> R{TOTP or backup code valid?}
    R -->|No| S[Return error — retry]
    R -->|Yes| L
Loading

OAuth Sign-In Flow

OAuth sign-in delegates identity verification to an external provider — GitHub or Discord — using the OAuth 2.0 Authorisation Code Flow. Better Auth manages the full lifecycle: redirect construction, state and PKCE parameter generation, callback validation, token exchange, and user provisioning.

When the user clicks a social sign-in button, the client calls the Better Auth social sign-in method with the chosen provider identifier. Better Auth generates a cryptographically random state parameter (for CSRF protection) and a PKCE code challenge, stores both server-side, and constructs the authorisation URL with the appropriate client identifier, redirect URI, requested scopes, and the generated state and challenge values. The user's browser is redirected to the provider's authorisation page.

At the provider, the user reviews the requested permissions and grants or denies access. If the user grants access, the provider redirects the browser back to the Better Auth callback endpoint with an authorisation code and the state parameter. Better Auth validates the returned state against the stored value to prevent CSRF attacks, then performs a server-to-server token exchange — sending the authorisation code and PKCE code verifier to the provider's token endpoint in exchange for access and refresh tokens. This server-to-server exchange ensures that tokens are never exposed to the browser.

With the access token in hand, Better Auth retrieves the user's profile from the provider's API. It then checks whether a user with the same email already exists in the database. If so, the provider account is linked to the existing user by creating an account record with the provider's identifier. If no matching user exists, Better Auth creates a new user record populated with the provider's profile data (name, email, avatar) and a corresponding account record storing the provider's tokens and scopes. In both cases, the user's emailVerified flag is set based on the provider's verification status.

A session record is created in the database, a JWT session token is generated using HS256, and the HTTP-only session cookie is set. The user is then redirected to the application. The post-sign-up middleware hook does not fire for OAuth registrations, as it only intercepts requests to the sign-up path; consequently, no welcome email is sent for OAuth sign-ups.

sequenceDiagram
    participant U as User
    participant B as Browser
    participant S as Server
    participant P as OAuth Provider
    participant DB as Database

    U->>B: Click GitHub/Discord button
    B->>S: Initiate social sign-in
    S->>S: Generate state + PKCE challenge
    S-->>B: Redirect to provider authorisation URL
    B->>P: User reviews and grants permissions
    P-->>B: Redirect to callback with code + state
    B->>S: GET /api/auth/oauth/callback
    S->>S: Validate state parameter (CSRF check)
    S->>P: Exchange code + verifier for tokens
    P-->>S: Return access token + refresh token
    S->>P: Fetch user profile
    P-->>S: Return profile data
    S->>DB: Upsert user + account records
    S->>DB: Create session record
    S->>S: Generate JWT (HS256)
    S-->>B: Set HTTP-only cookie + redirect
    B->>U: Application loads with active session
Loading

Password Reset Flow

The password reset flow allows a user who has forgotten their credentials to establish a new password via a time-limited email link. The process creates a verification record, dispatches a reset email, and upon successful token validation, replaces the stored password hash.

The user navigates to the forgotten password form and submits their email address. The client validates the email against the forgot-password schema (valid RFC 5322 format, maximum 255 characters) and calls the Better Auth password reset request endpoint. To prevent user enumeration, the server returns a success response regardless of whether a matching user exists.

If a matching user is found, Better Auth creates a verification record in the database containing a hashed token, the user's email as the identifier, and an expiry timestamp. A password reset email is dispatched via Postmark containing a link with the token embedded as a URL parameter. The email includes a 24-hour expiry notice and provides both HTML and plain-text bodies.

When the user clicks the reset link, the browser navigates to the reset password page (/auth/reset-password). If the email/password provider is disabled, this page immediately redirects to the login page. Otherwise, the client extracts the token from the URL search parameters and presents a form for the new password. The new password must conform to the standard password schema: minimum 6 characters, maximum 100 characters.

Upon form submission, the client sends the new password and the token to Better Auth's reset password endpoint. The server validates the token against the verification table, checking both the token value and the expiry timestamp. If the token is valid and has not expired, Better Auth hashes the new password using scrypt and updates the account record. The verification record is consumed and cannot be reused. The user is then redirected to the sign-in page.

sequenceDiagram
    participant U as User
    participant C as Client
    participant S as Server
    participant DB as Database
    participant E as Email Service

    U->>C: Submit email on forgot password form
    C->>C: Validate email (Zod schema)
    C->>S: POST /api/auth/forgot-password
    S->>DB: Look up user by email
    alt User exists
        S->>DB: Create verification record (hashed token, 24h expiry)
        S->>E: Send password reset email with link
    end
    S-->>C: 200 OK (always, regardless of user existence)
    C->>U: Show success message

    Note over U,E: User opens email and clicks reset link

    U->>C: Navigate to reset password page (token in URL)
    U->>C: Submit new password
    C->>C: Validate password (min 6, max 100 chars)
    C->>S: POST /api/auth/reset-password (password + token)
    S->>DB: Validate token and check expiry
    alt Token valid
        S->>S: Hash new password (scrypt)
        S->>DB: Update account record with new hash
        S->>DB: Delete verification record
        S-->>C: 200 OK
        C->>U: Redirect to sign-in page
    else Token invalid or expired
        S-->>C: Error response
        C->>U: Show error message
    end
Loading

Email Verification Flow

Email verification ensures that the address associated with a user account is genuinely controlled by that user. The application enforces mandatory email verification — users cannot sign in until their email is verified.

During registration, Better Auth generates a verification token and creates a verification record in the database containing the token hash, the user's email address as the identifier, and an expiry timestamp. The email verification hook dispatches a transactional email via Postmark containing a verification link with the token embedded as a URL parameter. The email states a 24-hour validity period.

When the user clicks the verification link, the browser sends the token to Better Auth's verify-email endpoint. The server retrieves the verification record by identifier, validates the token against the stored hash, and confirms that the record has not expired. If validation succeeds, Better Auth sets the emailVerified flag to true on the user record and deletes the consumed verification record.

Because automatic sign-in after verification is enabled, the user is automatically signed in upon successful verification — a session is created without requiring them to re-enter their credentials. This reduces friction for newly registered users.

If the verification link has expired or the token is invalid, the user is redirected with an error parameter. The email verification prompt in the client interface provides a resend button with a 30-second cooldown, allowing the user to request a fresh verification email without risk of abuse through rapid repeated requests.

sequenceDiagram
    participant U as User
    participant E as Email Service
    participant B as Browser
    participant S as Server
    participant DB as Database

    Note over S,DB: During registration
    S->>DB: Create verification record (token hash, 24h expiry)
    S->>E: Send verification email with link

    Note over U,E: User receives email
    U->>B: Click verification link
    B->>S: GET /api/auth/verify-email?token=...
    S->>DB: Retrieve verification record
    S->>S: Validate token hash and expiry
    alt Token valid and not expired
        S->>DB: Set emailVerified = true on user record
        S->>DB: Delete verification record
        S->>DB: Create session (auto sign-in)
        S-->>B: Set session cookie and redirect
        B->>U: Application loads — user is signed in
    else Token invalid or expired
        S-->>B: Redirect with error parameter
        B->>U: Show error — offer resend option
    end
Loading

Sign-Out Flow

The sign-out process terminates the user's authenticated session and returns them to the sign-in page. When the user initiates sign-out, the client calls the Better Auth sign-out endpoint, which performs two server-side operations: the session record associated with the current token is permanently deleted from the database, and the HTTP-only session cookie is cleared from the response. Because the database record is removed, the session cannot be reused even if a cached copy of the cookie persists elsewhere. After successful sign-out, the client redirects the user to the sign-in page, completing the session teardown.

flowchart LR
    A[User clicks sign out] --> B[Client calls sign-out API]
    B --> C[Server deletes session record]
    C --> D[HTTP-only cookie cleared]
    D --> E[User redirected to sign-in page]
Loading

Session Management

Session management governs how the application establishes, maintains, validates, and terminates authenticated user contexts. The system employs a hybrid approach that combines database-persisted session records with JWT-encoded cookies, providing both the stateless performance benefits of token-based authentication and the revocation capabilities of server-side session storage.


JWT Session Architecture

The application uses a hybrid session model that stores session records in the database whilst encoding session data into JWT tokens carried in HTTP-only cookies. This design differs from both pure stateless JWT systems and traditional opaque-token session stores, combining advantages of each approach.

Token structure and signing. Session tokens are encoded as JSON Web Tokens signed with the HS256 (HMAC-SHA256) algorithm. The signing key is a server-side secret that must remain confidential — it is never transmitted to the client. HS256 is a symmetric signing algorithm, meaning the same secret is used for both signing and verification. This is appropriate for a single-application deployment where only the issuing server needs to verify tokens; asymmetric algorithms such as RS256 would be preferred in multi-service architectures where third-party services need to independently verify tokens.

Cookie security properties. The JWT is transported in an HTTP-only cookie, which prevents client-side JavaScript from accessing the token. This is the primary defence against cross-site scripting (XSS) attacks — even if an attacker injects malicious script into the page, the script cannot read or exfiltrate the session token. In production, the cookie carries the Secure flag, restricting transmission to HTTPS connections only. The SameSite attribute provides protection against cross-site request forgery (CSRF) by limiting when the cookie is sent with cross-origin requests.

Session lifetime and refresh. Sessions are configured with a 7-day lifetime. Rather than requiring users to re-authenticate after this period regardless of activity, the system implements a sliding window refresh strategy. When a request arrives and the session was last refreshed more than 24 hours ago, Better Auth extends the session expiry to a fresh 7-day window from the current time. This means an active user who interacts with the application at least once per day will never encounter a session expiry, whilst an inactive user's session will expire 7 days after their last refresh.

Cookie cache for performance. To avoid a database query on every request, session data is cached in a signed cookie with a 7-day cache duration using the JWT encoding strategy. When a request arrives, Better Auth first checks the cookie cache. If the cached data is valid and not stale, the session is validated without a database round-trip. If the cache has expired or is absent, a database lookup occurs. This introduces a trade-off: a revoked session may remain active on other devices until the cookie cache expires. The application accepts this trade-off in favour of reduced database load.

Hybrid model advantages. A pure stateless JWT system stores all session state in the token and has no server-side record, making immediate session revocation impossible — the token remains valid until it expires. A pure server-side session store requires a database query on every request, adding latency. The hybrid approach provides the best of both: the database record enables explicit session revocation (on sign-out, password change, or security events), whilst the cookie cache provides near-stateless performance for the common case of validating an active, non-revoked session.

stateDiagram-v2
    [*] --> Created : Authentication succeeds
    Created --> Active : Session cookie set
    Active --> Active : Request within refresh window
    Active --> Refreshed : Request after 24h since last refresh
    Refreshed --> Active : Expiry extended by 7 days
    Active --> Expired : 7 days without refresh
    Active --> Revoked : User signs out or session invalidated
    Refreshed --> Expired : 7 days without further refresh
    Expired --> [*]
    Revoked --> [*]
Loading

Session Lifecycle

Sessions in the application follow a well-defined lifecycle from creation through to termination, with automatic refresh behaviour that maintains active sessions without user intervention. The system supports multiple concurrent sessions per user, enabling simultaneous access from different devices.

Session creation. A session is created whenever authentication succeeds — whether through email-and-password sign-in, OAuth provider callback, passkey verification, two-factor authentication completion, or automatic sign-in after email verification. Better Auth generates a unique session token, creates a session record in the database, and encodes the session data into a JWT cookie. The session record captures metadata including the client's IP address and user agent string, providing an audit trail for security review and enabling users to identify and manage sessions across devices.

Automatic refresh. The session refresh mechanism operates on a 24-hour sliding window. When a request arrives carrying a session token, Better Auth checks the session's updatedAt timestamp. If more than 24 hours have elapsed since the last refresh, the session's expiry is extended to 7 days from the current time, and the updatedAt timestamp is updated in the database. The refreshed session data is re-encoded into the cookie cache. If fewer than 24 hours have elapsed, the session is validated without modification. This design minimises unnecessary database writes — at most one write per session per 24-hour period — whilst ensuring active sessions remain valid indefinitely.

Session invalidation. Sessions are explicitly invalidated when the user signs out. The sign-out operation deletes the session record from the database and clears the session cookie from the browser. Because the database record is removed, the session cannot be reused even if the cookie were somehow preserved — subsequent validation attempts will fail the database lookup. The password change flow optionally revokes all other active sessions (controlled by a user-facing checkbox), which is a recommended security practice when credentials may have been compromised.

Concurrent sessions. The application does not impose a limit on the number of simultaneous active sessions per user. A user may be signed in from a desktop browser, a mobile browser, and multiple tabs concurrently, each maintaining an independent session record in the database. The profile settings interface exposes session management capabilities, allowing users to view their active sessions (with IP address and user agent metadata) and selectively revoke individual sessions or all sessions except the current one.

Session metadata. Each session record stores the client's IP address and user agent string at the time of creation. This metadata serves two purposes: it enables users to identify unfamiliar sessions that may indicate unauthorised access, and it provides an audit trail for security investigations. The metadata is not updated on subsequent requests — it reflects the state at session creation time.


Database Schema

The authentication subsystem persists its state across six PostgreSQL tables, each serving a distinct concern within the identity lifecycle. The schema is generated and managed by the Better Auth CLI via the Drizzle ORM adapter, which produces type-safe table definitions that mirror the framework's internal data model. All primary keys are text-based identifiers, and all timestamp columns default to the current time at insertion. Four of the six tables carry a foreign key to the central user entity with cascade-delete semantics, ensuring that account termination propagates cleanly through every dependent record.


Entity-Relationship Overview

The following diagram illustrates the complete authentication data model, including all six tables, their key columns, and the cardinality of each relationship. The user table sits at the centre of the graph; every other auth table either references it directly or operates independently as a stateless token store.

erDiagram
    USER {
        text id PK
        text name
        text email UK
        boolean emailVerified
        text image
        boolean twoFactorEnabled
        timestamp createdAt
        timestamp updatedAt
    }

    SESSION {
        text id PK
        text token UK
        timestamp expiresAt
        text ipAddress
        text userAgent
        text userId FK
        timestamp createdAt
        timestamp updatedAt
    }

    ACCOUNT {
        text id PK
        text accountId
        text providerId
        text userId FK
        text accessToken
        text refreshToken
        text idToken
        timestamp accessTokenExpiresAt
        timestamp refreshTokenExpiresAt
        text scope
        text password
        timestamp createdAt
        timestamp updatedAt
    }

    VERIFICATION {
        text id PK
        text identifier
        text value
        timestamp expiresAt
        timestamp createdAt
        timestamp updatedAt
    }

    TWO_FACTOR {
        text id PK
        text secret
        text backupCodes
        text userId FK
    }

    PASSKEY {
        text id PK
        text name
        text publicKey
        text credentialID
        integer counter
        text deviceType
        boolean backedUp
        text transports
        text userId FK
        text aaguid
        timestamp createdAt
    }

    USER ||--o{ SESSION : "has"
    USER ||--o{ ACCOUNT : "has"
    USER ||--o| TWO_FACTOR : "has"
    USER ||--o{ PASSKEY : "has"
Loading

Five of the six tables maintain a direct relationship with the user entity. The verification table is deliberately decoupled — it identifies users by email string rather than by foreign key, enabling a stateless token validation model that does not require an authenticated session to consume.


Table Descriptions

5.2.1 User

The user table is the root entity of the authentication subsystem. Every other auth table either references it via a foreign key or identifies users indirectly through an email address. Each row represents a single registered identity within the application.

The email column carries a unique constraint, preventing duplicate registrations and enabling deterministic user lookup during sign-in and OAuth account linking. The emailVerified flag controls whether the user has completed the email verification flow; sign-in attempts are rejected with a 403 status when this flag is false and email verification is required. The twoFactorEnabled flag is a nullable boolean added by the two-factor authentication plugin — it is set to true only after the user has successfully verified a TOTP code during enrolment, not merely upon initiating the setup process.

The updatedAt column uses an automatic update trigger that refreshes the timestamp on every mutation, providing a lightweight audit trail for profile changes.

5.2.2 Session

The session table tracks active authenticated sessions and provides the server-side backing store for cookie-based session management. Each session row is created upon successful sign-in and deleted upon sign-out or expiry.

The token column holds the session identifier transmitted to the client as an HTTP-only cookie value. It carries a unique constraint to prevent collision. The expiresAt column enforces the session time-to-live — the framework checks this value on every session validation request and extends it by the configured lifetime when the rolling refresh threshold is reached. The ipAddress and userAgent columns capture device metadata at session creation time, enabling audit logging and suspicious-activity detection across concurrent sessions.

A B-tree index on userId accelerates session lookups during authentication checks and supports efficient enumeration when a user lists or revokes active sessions. All session rows are cascade-deleted when the owning user record is removed.

5.2.3 Account

The account table implements a multi-provider identity model. Rather than storing credentials directly on the user entity, the schema uses a separate account row for each authentication method a user has linked — one for email-and-password credentials, and one for each OAuth provider. This design permits a single user to authenticate via multiple methods without schema conflicts.

For credential-based authentication, the providerId is set to credential and the password column contains the scrypt hash of the user's password. For OAuth providers, providerId holds the provider name (such as github or discord), accountId holds the provider-issued user identifier, and the accessToken, refreshToken, idToken, scope, and expiry columns store the OAuth token lifecycle data needed for token refresh and API access on the user's behalf.

A composite unique index on (userId, providerId) enforces the constraint that a user may link at most one account per provider. A separate B-tree index on userId supports efficient lookup of all linked providers for a given user. All account rows cascade-delete with their parent user.

5.2.4 Verification

The verification table stores ephemeral, short-lived tokens used by the email verification and password reset flows. Each row represents a single pending verification request.

The identifier column holds the email address associated with the request, rather than a foreign key to the user table. The value column contains the token hash or verification code. The expiresAt column enforces a strict time-to-live — expired rows are treated as invalid during token consumption, and the framework purges them during subsequent operations.

This table intentionally carries no foreign key constraint. The absence of a direct user reference enables a stateless validation model: the token can be consumed without an authenticated session, which is essential for password reset flows where the user cannot sign in.

5.2.5 Two-Factor

The two-factor table stores the cryptographic material required for TOTP-based second-factor authentication. Each row contains a single user's TOTP shared secret and a set of one-time backup codes.

The secret column holds the encrypted TOTP shared secret that is also encoded into the QR code presented during enrolment. Authenticator applications use this secret to generate time-based six-digit codes at thirty-second intervals. The backupCodes column stores an encrypted JSON array of one-time recovery codes — each code is consumed and removed upon use, providing a fallback mechanism when the user's authenticator device is unavailable.

The table maintains a one-to-one relationship with the user entity via a foreign key with cascade-delete semantics. A B-tree index on userId ensures efficient lookup during the two-factor verification step of the sign-in flow.

5.2.6 Passkey

The passkey table stores WebAuthn credentials registered by users for passwordless authentication. Each row represents a single FIDO2 credential bound to a specific user and authenticator device.

The publicKey column holds the public half of the asymmetric key pair generated during registration — the corresponding private key never leaves the user's device. The credentialID uniquely identifies the credential across authentication ceremonies. The counter column is an integer that increments on each successful authentication; the server rejects any authentication attempt where the presented counter value does not exceed the stored value, providing replay attack protection.

The deviceType column distinguishes between single-device credentials (bound to hardware) and multi-device credentials (synchronised across a platform ecosystem). The backedUp flag indicates whether the credential is eligible for cloud synchronisation. The transports column records the communication channels available for the credential (such as internal platform authenticators, USB security keys, or Bluetooth), informing the browser's credential selection prompt. The aaguid (Authenticator Attestation GUID) identifies the make and model of the authenticator hardware.

All passkey rows cascade-delete with their parent user, and a B-tree index on userId supports efficient credential enumeration during authentication.


Design Decisions

Passwords in account rather than user. The decision to store password hashes in the account table rather than on the user entity directly reflects the multi-provider identity model. A user who registers via email and password receives an account row with providerId: credential and a populated password column. A user who registers via GitHub OAuth receives an account row with providerId: github and a null password. Should the same user later add email-and-password authentication, a second account row is created. This separation avoids nullable password columns on the user table, keeps provider-specific data isolated, and supports the composite unique constraint that enforces one account per provider per user.

Verification without a foreign key. The verification table deliberately omits a foreign key to the user table. This enables two critical flows that cannot assume an authenticated session: email verification after registration (where the user may not yet be signed in) and password reset (where the user cannot sign in). By identifying users through the identifier string — typically an email address — the framework can validate tokens statelessly, without requiring a session cookie or a user ID in the request. This also simplifies cleanup, as expired verification rows can be purged without cascading effects on other tables.

Two-factor as a separate table. The two-factor table exists as a distinct entity rather than additional columns on the user table because it is introduced by the two-factor authentication plugin. Better Auth's plugin architecture extends the schema additively — each plugin defines its own tables and, where necessary, adds columns to core tables (such as twoFactorEnabled on the user table). Isolating the TOTP secret and backup codes in a dedicated table preserves the separation of concerns between core identity data and plugin-managed authentication state, and ensures that disabling the plugin does not require a destructive migration on the user table.


Security Model

The authentication subsystem implements a defence-in-depth security posture that spans password storage, session management, multi-factor verification, transport security, and route protection. Each layer is designed to mitigate a specific class of attack, and the layers overlap such that a failure in any single mechanism does not compromise the system as a whole.


Password Security

Passwords are hashed using scrypt, a memory-hard key derivation function that is resistant to both brute-force attacks and hardware-accelerated cracking via GPUs and ASICs. Scrypt's computational cost derives from its requirement for large amounts of memory during hashing, which makes parallel cracking prohibitively expensive. The algorithm is recommended by OWASP as a Tier 2 option when Argon2id is unavailable, and is natively supported by the Node.js runtime without external dependencies.

Password hashes are stored exclusively in the account table within rows that have a credential provider identifier. The user table never contains password data in any form. Plaintext passwords exist only transiently in server memory during the hashing and verification operations and are never logged, serialised, or persisted.

Minimum password length is enforced at the application boundary through Zod validation schemas, which require a minimum of six characters for new passwords and a maximum of one hundred characters. This validation runs on both the client (for immediate feedback) and the server (as an authoritative check), ensuring that weak passwords are rejected before they reach the hashing layer.

The system implements email enumeration protection following OWASP best practices. When a user attempts to register with an email address that already exists, the response is identical to a successful registration — the same HTTP status code and response shape are returned regardless of whether the account was created or already existed. This prevents attackers from using the registration endpoint to determine which email addresses have accounts in the system.


Session Security

Sessions are managed through HTTP-only cookies, which prevent client-side JavaScript from accessing session tokens. This mitigates the most common vector for session theft via cross-site scripting (XSS) attacks — even if an attacker successfully injects a script into the page, the script cannot read the session cookie.

The Secure flag is set on session cookies in production, ensuring that cookies are transmitted only over HTTPS connections. This prevents session tokens from being intercepted during transit over unencrypted channels.

Session tokens are encoded using HS256 (HMAC-SHA256) JWT signing with a server-side secret. The cookie cache strategy stores session data in a signed JWT within the cookie itself, allowing the server to validate sessions without a database query on every request. The signing key is derived from the application secret and is never exposed to the client.

Session invalidation occurs on sign-out, when the session row is deleted from the database and the cookie is cleared from the client. The framework also supports bulk revocation — users can revoke all active sessions or all sessions except the current one, which is essential for incident response when a credential compromise is suspected.

Device metadata — specifically the client IP address and user agent string — is captured at session creation time and stored alongside the session record. This data serves as an audit trail, enabling users and administrators to identify suspicious sessions originating from unfamiliar devices or locations. The metadata is informational and does not participate in session validation.

Session lifetime is configured at seven days with a rolling refresh threshold of twenty-four hours. When a session is accessed after the refresh threshold has elapsed, the expiry is extended by the full lifetime, effectively implementing a sliding window that keeps active sessions alive whilst ensuring abandoned sessions expire.


Multi-Factor Security

The two-factor authentication implementation adds a time-based one-time password (TOTP) as a second verification step during credential-based sign-in. TOTP codes are generated by an authenticator application on the user's device using a shared secret and the current time, producing a new six-digit code every thirty seconds. The server accepts codes from one period before and one period after the current interval, providing a tolerance window of approximately ninety seconds to account for clock drift.

The TOTP shared secret is encrypted at rest in the database. When the user enables two-factor authentication, the framework generates the secret, encrypts it, and stores it in the two-factor table. The plaintext secret is presented to the user exactly once — encoded as a QR code — during the enrolment ceremony. The twoFactorEnabled flag on the user record is not set to true until the user has successfully verified a TOTP code, confirming that their authenticator application is correctly configured.

One-time backup codes provide a recovery path when the user's authenticator device is lost or unavailable. Backup codes are generated alongside the TOTP secret, encrypted, and stored as a JSON array. Each code is consumed upon use and removed from the stored array, ensuring that a compromised backup code cannot be reused. Users may regenerate the full set of backup codes at any time, which invalidates all previously issued codes.

Two-factor verification is scoped to credential-based sign-in flows only. OAuth and passkey authentication methods are exempt from the two-factor challenge because they already provide strong authentication guarantees — OAuth relies on the provider's own authentication mechanisms, and passkeys use public-key cryptography with device-level biometric or PIN verification.


WebAuthn Security

WebAuthn passkeys provide the strongest authentication mechanism in the system through three fundamental security properties.

Phishing resistance. Passkey credentials are cryptographically bound to the relying party's origin (domain). The browser will not present a credential to a site whose origin does not match the one recorded during registration. This makes credential phishing fundamentally impossible — a convincing replica of the login page hosted on a different domain cannot trigger the passkey authentication ceremony.

Public-key cryptography. Authentication uses asymmetric key pairs rather than shared secrets. The private key never leaves the user's device; the server stores only the public key. During authentication, the device signs a challenge with the private key, and the server verifies the signature against the stored public key. A database breach exposes only public keys, which are useless for impersonation.

Counter-based replay protection. Each passkey maintains an integer counter that increments on every successful authentication. The server stores the last-seen counter value and rejects any authentication attempt where the presented counter does not exceed the stored value. This detects cloned credentials — if an attacker duplicates a passkey, the counter values will diverge, and the server will reject the cloned credential's stale counter.

Device attestation. The schema records the authenticator attestation GUID (AAGUID), device type, backup status, and available transports for each credential. The device type distinguishes between single-device credentials (bound to a specific hardware authenticator) and multi-device credentials (synchronised across a platform ecosystem such as iCloud Keychain or Google Password Manager). The backed-up flag indicates whether the credential participates in cloud synchronisation, informing risk assessments about credential availability and exposure.


Transport Security

All authentication endpoints are served over HTTPS, ensuring that credentials, session tokens, and verification codes are encrypted in transit. The Secure flag on session cookies enforces this at the cookie layer, preventing accidental transmission over unencrypted connections.

SSRF protection is enforced for all HTTP-based integrations, including MCP server connections that use the HTTP transport. A URL validation guard blocks requests to RFC 1918 private address ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), localhost and loopback addresses, link-local addresses (169.254.0.0/16, fe80::/10), and cloud metadata endpoints (169.254.169.254). This prevents server-side request forgery attacks that could exploit the server's network position to access internal services or cloud instance credentials.

OAuth state parameter protection prevents cross-site request forgery during the OAuth authorisation flow. Better Auth generates a cryptographically random state value before redirecting the user to the OAuth provider, stores it server-side, and validates it when the provider redirects back. Any callback request with a missing or mismatched state parameter is rejected, preventing an attacker from initiating an OAuth flow on behalf of an unsuspecting user.


Route Protection Model

The application implements a dual-layer route protection model that enforces authentication at both the client and server boundaries. This defence-in-depth approach ensures that neither a client-side bypass nor a direct API call can reach protected resources without a valid session. Notably, the application does not use Next.js middleware for authentication — all protection logic resides at the component and action layers.

flowchart TD
    A([Client Request]) --> B{AuthenticatedLayout}
    B -->|No session| C([Redirect to Login])
    B -->|Session valid| D[Render Protected Page]
    D --> E[User Triggers Action]
    E --> F[Server Action Invoked]
    F --> G{requireSession}
    G -->|No session| H([Throw Unauthorized])
    G -->|Session valid| I[Execute Database Operation]

    subgraph Client Layer
        B
        C
        D
        E
    end

    subgraph Server Layer
        F
        G
        H
        I
    end
Loading

The client layer is implemented through an authenticated layout wrapper that consumes the reactive session hook. This hook queries the session state and provides a loading indicator, the session data, and the authentication status. When the component mounts or the session state changes, an effect evaluates whether a valid session exists. If the session is absent and the loading state has resolved, the component redirects the user to the login page. During the loading phase, a placeholder is rendered to prevent an unauthenticated flash of content. This layer provides immediate visual feedback and prevents unauthenticated users from seeing protected interface elements, but it is not a security boundary — it is a user experience optimisation that runs entirely in the browser.

The server layer is the authoritative security boundary. Every protected Server Action begins with a session validation call that reads the session token from the request headers, verifies it against the database, and throws an unauthorised error if validation fails. This check runs on the server within a trusted execution context and cannot be bypassed from the client. The session validation returns the full user and session objects, which are then used to scope all subsequent database queries to the authenticated user's data.

Both layers are necessary. The client layer provides a responsive user experience by preventing unnecessary round trips for unauthenticated users. The server layer provides the actual security guarantee, ensuring that even if the client-side check is circumvented — whether through a modified client, a direct HTTP request, or a browser extension — the server will reject the request before any data is accessed or modified.

Why not middleware. Next.js middleware runs at the edge before the request reaches the application server, making it a natural candidate for authentication checks. However, the application opts against this approach. The session guard pattern provides equivalent security at the action level with greater flexibility — each action can make fine-grained authorisation decisions based on the session context, and the protection logic is co-located with the business logic it protects. This also avoids the complexity of edge-compatible authentication libraries and cold-start latency concerns.


Transactional Email Integration

The application employs a dedicated transactional email subsystem to support authentication lifecycle events. All outbound emails are server-side operations, triggered exclusively by auth events handled within the Better Auth framework. No emails are dispatched from client-side code; the email layer operates entirely within the server boundary, ensuring that sensitive verification tokens and user data never transit through the browser.

Email Provider

Postmark serves as the sole transactional email provider. A singleton Postmark client is initialised from a server token environment variable at module load time, and all outbound messages are routed through a single dispatch function. The sender address is configured via a dedicated environment variable, ensuring consistent branding across all email types.

Every email is sent in dual format — both an HTML body and a plain-text fallback are included in each message. The HTML variant provides a styled, branded experience for modern email clients, whilst the plain-text variant ensures readability in text-only clients, assistive technologies, and environments where HTML rendering is disabled. This dual-format approach follows email deliverability best practices and reduces the likelihood of messages being classified as spam.

The email dispatch function is intentionally minimal: it accepts a recipient address, subject line, HTML body, and plain-text body, and delegates directly to the Postmark API. No templating engine or abstraction layer sits between the application and the provider — each email type constructs its own HTML and plain-text content inline. This design keeps the email subsystem simple and avoids unnecessary dependencies, at the cost of some duplication across email templates.

Email Types

The system dispatches four distinct email types, each tied to a specific authentication lifecycle event. All time-bound emails carry a 24-hour expiry window; if the recipient does not act within that period, the embedded token is invalidated and a new request must be initiated.

Welcome Email — Dispatched immediately after successful email-and-password registration only. The email greets the user by name and confirms that their account has been created. It is triggered by a Better Auth post-completion middleware hook that intercepts requests to the sign-up path. Because OAuth callbacks use a different route, OAuth sign-ups do not trigger a welcome email. The welcome email does not contain any actionable links or tokens; it serves purely as a confirmation and onboarding touchpoint.

Email Verification — Sent during the sign-up process when email verification is required. The message contains a verification link generated by Better Auth, which the user must click to confirm ownership of the email address. The link expires after 24 hours. Until verification is complete, the emailVerified flag on the user record remains false, and the user is unable to sign in. A resend mechanism is available on the client-side verification prompt, subject to a 30-second cooldown to prevent abuse. The application is configured with automatic sign-in after verification enabled, meaning that clicking a valid verification link both verifies the email and creates an active session.

Password Reset — Triggered when a user submits a forgotten-password request. Better Auth generates a time-bound reset token and embeds it in a URL, which is included in the email as a styled button and a plain-text link. The reset link directs the user to a dedicated reset-password page where they may set a new password. The link expires after 24 hours. If the user did not initiate the request, the email instructs them to disregard it. This email is dispatched via the password reset hook in the email-and-password configuration.

Account Deletion — Dispatched when a user initiates account deletion from the profile danger zone. Rather than deleting the account immediately, the system sends a confirmation email containing a verification link. The user must click this link within 24 hours to confirm the deletion. This two-step process prevents accidental or unauthorised account removal. The email is triggered by the deletion verification hook within Better Auth's user deletion configuration.

Email Flow

The following diagram illustrates how authentication lifecycle events trigger the dispatch of transactional emails. Registration produces two emails in quick succession — a welcome confirmation and an email verification request — whilst password reset and account deletion each produce a single targeted email.

flowchart TD
    A([User Action]) --> B{Action Type}

    B -->|Registration| C[Sign-Up Completes]
    C --> D[After Hook Fires]
    D --> E[Welcome Email Dispatched]
    C --> F[Verification Hook Fires]
    F --> G[Email Verification Dispatched]

    B -->|Forgot Password| H[Password Reset Requested]
    H --> I[Reset Password Hook Fires]
    I --> J[Password Reset Email Dispatched]

    B -->|Account Deletion| K[Deletion Initiated]
    K --> L[Delete Account Hook Fires]
    L --> M[Deletion Confirmation Email Dispatched]

    E --> N([Email Delivered via Postmark])
    G --> N
    J --> N
    M --> N

Loading

The welcome email and the email verification email are dispatched through independent hooks — the welcome email via a general post-completion middleware, and the verification email via the verification dispatch hook. This means both are triggered during registration but are not coupled to each other; either could be disabled independently without affecting the other.

A separate resend path exists for the email verification email. If the user closes the verification prompt or does not receive the initial email, they may request a re-send from the sign-in flow. When the system detects a sign-in attempt with an unverified email address, it presents the verification prompt with a resend button, which dispatches a fresh verification email with a new 24-hour token.


Account Management

The application provides a comprehensive account management interface, accessible to authenticated users via the profile section. All profile pages enforce authentication at both the client and server layers — the layout component redirects unauthenticated users on the client side, and each page performs an additional server-side session check before rendering. The profile section is organised into discrete tabs, each addressing a specific aspect of account management.

Profile Management

The general profile page allows users to update their display name and other profile information. Changes are submitted through a validated form and persisted directly to the user record in the database. Profile updates do not require re-authentication or password confirmation; the active session is considered sufficient authorisation for non-sensitive profile changes.

The profile update interface is intentionally lightweight — it does not expose email address changes or other fields that would require verification workflows. The email address associated with the account remains immutable through the profile interface, preventing scenarios where a user could change their email without verifying ownership of the new address.

Password Management

Password management is split into two distinct flows, depending on whether the user currently has a credential-based account.

Changing an existing password requires the user to provide their current password alongside the new one. This prevents an attacker who has gained temporary access to an authenticated session from silently changing the password. The new password must meet the application's minimum length requirement of six characters. An optional checkbox allows the user to revoke all other active sessions upon password change; this option is enabled by default, ensuring that any potentially compromised sessions are terminated when the password is updated.

Setting an initial password is available to users who registered exclusively through OAuth and do not have a credential-based account. In this scenario, the profile interface presents a button that triggers a password reset email rather than a password change form. The user receives a standard password reset link and follows the same flow as a forgotten-password recovery. This approach reuses the existing reset infrastructure rather than introducing a separate "set password" flow, maintaining consistency in the verification process.

The forgotten-password flow is accessible from the sign-in page. The user submits their email address, receives a reset email with a time-bound token, and is directed to a dedicated page where they enter their new password. The reset token is extracted from the URL search parameters and submitted alongside the new password. If the token has expired or is invalid, the user is informed and must request a new reset email.

Two-Factor Authentication Management

Two-factor authentication (2FA) uses the TOTP (Time-based One-Time Password) algorithm, as specified in RFC 6238. The management interface guides users through the full lifecycle of enabling, verifying, and disabling 2FA.

Enabling 2FA begins with a password confirmation step. Once the password is verified, the system generates a TOTP shared secret and presents it as a QR code rendered on the client side. The user scans the QR code with their preferred authenticator application (such as Google Authenticator, Authy, or 1Password), which registers the secret and begins generating time-based codes at 30-second intervals.

Verifying the setup is mandatory before 2FA is considered active. After scanning the QR code, the user must enter a valid six-digit TOTP code generated by their authenticator application. This verification step confirms that the secret was correctly transferred and that the authenticator is producing valid codes. Until this verification succeeds, the twoFactorEnabled flag remains false and 2FA is not enforced during sign-in.

Backup codes are generated and displayed to the user upon successful 2FA verification. These one-time-use codes serve as a recovery mechanism if the user loses access to their authenticator application. Each backup code can be used exactly once in place of a TOTP code during sign-in. Users are advised to store these codes securely. New backup codes can be regenerated at any time, which invalidates all previously issued codes.

Disabling 2FA requires password confirmation. Once disabled, the TOTP secret and backup codes are removed from the database, and subsequent sign-in attempts no longer prompt for a second factor.

The 2FA management section is hidden for users who registered exclusively via OAuth and do not have a credential-based account, as password confirmation is required for 2FA operations and these users have no password on file.

flowchart TD
    A([User Initiates 2FA Setup]) --> B[Password Confirmation Dialog]
    B --> C{Password Valid?}
    C -->|No| D[Error: Invalid Password]
    D --> B
    C -->|Yes| E[Generate TOTP Secret]
    E --> F[Display QR Code]
    F --> G[User Scans QR Code]
    G --> H[User Enters 6-Digit TOTP Code]
    H --> I{Code Valid?}
    I -->|No| J[Error: Invalid Code]
    J --> H
    I -->|Yes| K[Set twoFactorEnabled = true]
    K --> L[Generate Backup Codes]
    L --> M[Display Backup Codes to User]
    M --> N([2FA Setup Complete])

Loading

Passkey Management

Passkey management allows users to register, label, and remove WebAuthn/FIDO2 credentials. The system supports multiple passkeys per account, enabling users to register credentials on several devices (such as a laptop fingerprint reader, a mobile device, and a hardware security key).

Registering a new passkey is initiated through a dialog that prompts the user to provide a human-readable name for the credential (e.g. "Work Laptop" or "YubiKey"). Upon submission, the browser's WebAuthn API is invoked, prompting the user to authenticate via biometrics, PIN, or security key. The resulting public key credential is stored server-side in the passkey table, along with the credential ID, counter value, device type, and transport metadata.

Listing passkeys displays all registered credentials with their assigned names and creation dates. This allows users to identify and manage individual credentials across their devices.

Removing a passkey presents a confirmation dialog before deletion. Once confirmed, the credential is permanently removed from the database and can no longer be used for authentication. Users should ensure they have alternative authentication methods available before removing their last passkey.

OAuth Account Linking

The accounts page provides an interface for linking and unlinking third-party OAuth providers. The system supports GitHub and Discord as social authentication providers.

Linking an account initiates the standard OAuth authorisation flow for the selected provider. The user is redirected to the provider's consent screen, and upon approval, the provider's account is associated with the user's existing record. A new entry is created in the account table with the provider's identifier, access token, and refresh token. Linking an OAuth provider does not replace or remove any existing authentication methods — the user retains their email/password credentials, passkeys, and any previously linked providers.

Unlinking an account removes the association between the user's record and the OAuth provider. The corresponding entry in the account table is deleted, and the provider's tokens are discarded. Users should ensure they have at least one remaining authentication method before unlinking a provider.

Viewing connected providers displays the list of currently linked OAuth accounts alongside the available providers that have not yet been linked. Each linked provider shows its display name and offers an unlink action; each unlinked provider presents a link button that initiates the OAuth flow.

Session Management

The sessions page displays all active sessions associated with the user's account. Each session entry includes device metadata — browser name, operating system, and IP address — parsed from the user agent string stored at session creation time. The current session is visually distinguished with a badge to prevent the user from accidentally revoking their own active session.

Revoking individual sessions allows the user to terminate a specific session on another device. This is useful when the user suspects that a session may have been compromised or when they wish to sign out from a device they no longer have access to.

Revoking all other sessions provides a bulk action that terminates every session except the current one. This is commonly used after a password change or when the user suspects unauthorised access. The password change form includes an option to revoke other sessions automatically, which defaults to enabled.

Account Deletion

Account deletion is a destructive, irreversible operation housed in the profile danger zone. The deletion process follows a two-step confirmation pattern to prevent accidental data loss.

When the user initiates deletion, a confirmation dialog is presented. Upon confirmation, the system dispatches a deletion verification email containing a time-bound link. The user must click this link within 24 hours to finalise the deletion. If the link expires, the account remains intact and the user must reinitiate the process.

Upon successful verification, the user record is permanently removed from the database. All related data — sessions, account links, two-factor secrets, passkeys, and any application-specific records — are cascading-deleted through foreign key constraints defined at the database level. This ensures complete and consistent data removal without relying on application-layer cleanup logic.

No email is sent after deletion completes. The verification email described above — dispatched before deletion — serves as the sole confirmation mechanism, requiring the user to actively confirm the operation before any data is removed.


References

  1. Better Auth — Official Documentation
  2. Better Auth — Session Management
  3. Better Auth — Two-Factor Authentication Plugin
  4. Better Auth — Passkey Plugin
  5. Better Auth — Email and Password Authentication
  6. Better Auth — Social Sign-On
  7. Web Authentication: An API for Accessing Public Key Credentials — W3C Recommendation
  8. TOTP: Time-Based One-Time Password Algorithm — RFC 6238
  9. OWASP Password Storage Cheat Sheet
  10. The scrypt Password-Based Key Derivation Function — RFC 7914
  11. JSON Web Token (JWT) — RFC 7519
  12. Drizzle ORM — Official Documentation
  13. Postmark — Developer Documentation
  14. Next.js App Router — Official Documentation
  15. FIDO Alliance — Web Authentication Overview
  16. OWASP Authentication Cheat Sheet

Clone this wiki locally