Skip to content

Authentication

Authentication is handled by BetterAuth integrated with Convex via the @convex-dev/better-auth adapter. All configuration is dynamic — stored in the Convex config table and applied per request, so changes take effect without redeployment.

The primary authentication method. Supports sign-up, sign-in, password reset, and email verification.

import { authClient } from "@/lib/core/auth-client"
// Sign up
await authClient.signUp.email({ name, email, password })
// Sign in
await authClient.signIn.email({ email, password })
// Request password reset
await authClient.forgetPassword({ email })
// Sign out
await authClient.signOut()

Google, GitHub, Apple, and Microsoft can be enabled dynamically via the admin config panel. Each provider requires a clientId and clientSecret set through the socialProviders.* config keys.

await authClient.signIn.social({ provider: "google" })

When auth.passkeyEnabled is true, users can register FIDO2 passkeys. A custom passkeyEnforce plugin ensures that once a user has a registered passkey, password sign-in creates then immediately revokes the session, returning { passkeyRequired: true } to trigger the WebAuthn flow instead.

// Register a passkey
await authClient.passkey.addPasskey()
// Sign in with passkey
await authClient.signIn.passkey()

Users can enable TOTP-based 2FA. The mfaOrgGuard plugin enforces MFA at the organization level — if an org’s metadata includes require2FA, members must have TOTP or passkeys configured before they can activate that organization.

// Enable 2FA (returns TOTP URI + backup codes)
await authClient.twoFactor.enable({ password })
// Verify during sign-in
await authClient.twoFactor.verifyTotp({ code })

Sent automatically on sign-up when auth.requireEmailVerification is true. Users who sign up through an organization invitation skip verification — the invitationVerify plugin auto-verifies their email.

Multi-tenant organization support is built in. Each user can belong to multiple organizations with role-based access.

RoleScope
ownerFull control — update, delete org, manage members, all resources
adminUpdate org, manage members, all resources (cannot delete org)
memberRead access, create own resources
// Create an organization
await authClient.organization.create({ name, slug })
// Switch active organization
await authClient.organization.setActive({ organizationId })
// Invite a member
await authClient.organization.inviteMember({ email, role: "member" })

Organization deletion is protected — all non-owner members must be removed first.

Invitations are sent by email with a link to /accept-invitation/{id}. Both authenticated and unauthenticated users can accept. Unauthenticated users are guided through sign-up with automatic email verification.

A two-tier role system separates global and organization-scoped permissions.

RolePurpose
adminSystem administrator — bypasses all org-level permission checks
memberRegular user (default on sign-up)

Backend Convex functions declare their required permissions at definition time:

import { authenticatedQuery } from "../../../functions"
export const listBooks = authenticatedQuery({
args: {},
permissions: ["book", ["read"]],
handler: async (ctx) => {
// Only runs if the user's org role grants book:read
},
})

The requirePermission function checks in order:

  1. Global admin → always allowed
  2. API key → checks explicit permission scopes
  3. Org role → checks role-based access control matrix
BuilderAuthPermissions
publicQuery / publicMutationNoneNone
authenticatedQuery / authenticatedMutationRequiredRequired
zAuthenticatedQuery / zAuthenticatedMutationRequiredRequired + Zod validation
adminQuery / adminMutationRequiredGlobal admin only

API keys provide programmatic access with fine-grained permissions. Keys are prefixed with ak_ and scoped to a specific organization.

// Create an API key (frontend admin)
await authClient.apiKey.create({
name: "My Integration",
permissions: { book: ["read", "create"] },
})

API keys authenticate via token exchange: POST /api/v1/auth/token with the API key returns a short-lived JWT. Permissions are checked per-request against the key’s explicit scopes — not role-based.

Sessions are stored in the Convex database via the BetterAuth adapter. Key settings:

  • Duration: configurable via security.sessionDuration (default: 24 hours)
  • Multi-session: up to 5 concurrent sessions per user
  • Secure cookies: HTTPS-only in production
  • Active organization: tracked per session, resolved on each request
// List device sessions
await authClient.multiSession.listDeviceSessions()
// Revoke a specific session
await authClient.multiSession.revoke({ sessionToken })

Global admins have access to user management through the admin plugin:

  • List, search, and filter users
  • Ban/unban users (with optional expiration)
  • Impersonate users (max 1 hour, cannot impersonate other admins)
  • Revoke all sessions for a user
  • Reset user passwords
  • Delete user accounts

Auth behavior is extended through a chain of BetterAuth plugins, applied in order:

  1. convex — Core Convex database binding
  2. passkeyEnforce — Enforce passkey when registered
  3. mfaOrgGuard — Block org activation without MFA
  4. twoFactor — TOTP 2FA management
  5. passkey — WebAuthn support (conditional)
  6. multiSession — Device session management
  7. admin — User management and impersonation
  8. organization — Multi-tenant organizations
  9. apiKey — API key generation and validation
  10. waitlistGate — Sign-up token validation (conditional)
  11. invitationVerify — Auto-verify invited user emails
  12. billingProvision — Provision billing on org creation
  13. auditTrail — Log all auth events
  14. crossDomain — Cross-origin auth (must be last)

All auth settings are managed through the config table and editable from the admin panel:

KeyDefaultDescription
auth.allowSelfSignuptrueAllow public registration
auth.requireEmailVerificationtrueRequire email verification on sign-up
auth.allowOrgCreationtrueAllow users to create organizations
auth.passkeyEnabledtrueEnable WebAuthn passkey support
security.sessionDuration86400Session lifetime in seconds
security.rateLimitWindow60Rate limit window in seconds
security.rateLimitMax10Max requests per window
security.passwordMinLength10Minimum password length
organization.membershipLimit50Max members per organization
organization.invitationExpiration604800Invitation TTL in seconds (7 days)
FilePurpose
packages/backend/src/convex/lib/core/auth/convex_auth.tsMain BetterAuth setup and plugin chain
packages/backend/src/convex/lib/core/auth/auth.tsrequirePermission enforcement
packages/backend/src/convex/lib/core/permissions.tsRole and access control definitions
packages/backend/src/convex/functions.tsAuthenticated function builders
packages/web-core/src/lib/auth-client.tsFrontend BetterAuth client
apps/web/src/app/routes/auth/sign-in.tsxSign-in / sign-up / password reset UI