Security Model
Leave Localhost implements a layered security model: authentication via Better Auth, authorization via role + capability checks, and step-up verification for sensitive actions.
Leave Localhost implements a layered security model: authentication via Better Auth, authorization via role + capability checks, and step-up verification for sensitive actions.
Authentication
Authentication is handled by Better Auth with
the @convex-dev/better-auth adapter for Convex. The setup lives in
packages/backend/convex/auth.ts.
Supported Methods
| Method | Plugin | Always available |
|---|---|---|
| Email + password | Built-in | Yes |
| Magic link (passwordless) | magicLink | Yes (requires Resend) |
| Google OAuth | Built-in | Yes |
| Microsoft OAuth | Built-in | When credentials configured |
| TOTP (2FA) | twoFactor | Opt-in per user |
Session Management
Better Auth manages sessions via cookies. The Next.js proxy at
apps/app/src/proxy.ts runs on every matched request and performs an early,
cookie-based redirect guard: it checks for the presence of a Better Auth
session cookie to decide where to route the request.
- Unauthenticated users accessing protected routes are redirected to
/login. - Authenticated users accessing auth pages (
/login,/verify-2fa) are redirected to/.
This proxy check looks only at cookie presence, not validity. It is an early routing and UX optimization, not the authoritative auth layer — real session validation and authorization still happen in Convex queries, mutations, actions, and server-side route logic.
Next 16 naming: the request boundary lives in
proxy.ts, which is the current Next.js 16 convention. The oldermiddleware.tsname is deprecated; do not rename this file back tomiddleware.tswhile the app is on Next 16.
Authorization
Authorization combines two layers:
1. Role-Based Access (Better Auth)
Every organization member has a role. Four roles are defined in
auth/organizationAccess.ts:
| Role | Organization | Members | Billing | Features |
|---|---|---|---|---|
| owner | read, update, delete | full CRUD | read, manage | all |
| admin | read, update | full CRUD | read | all |
| member | read | read | — | all |
| viewer | read | read | — | — |
Roles are enforced via Better Auth's access control system. Each permission
rule maps to a Better Auth statement like { organization: ["delete"] }.
2. Capability-Based Entitlements
Capabilities are granted by billing plans and tracked in the billing_grants
table. Defined capabilities:
| Capability | Granted by |
|---|---|
feature.pro | Any Pro plan |
workspace.members.invite | Pro plans |
workspace.members.limit.10 | Pro plans |
workspace.members.limit.unlimited | (reserved) |
billing.portal | Pro plans |
usage.ai.generate | (reserved) |
How Permission Checks Work
Every protected function calls requireAppPermission() or
canAppPermission(). The check evaluates three conditions in order:
- Actor resolution — is the user authenticated, does the app user record exist, and is the user a member of the target organization?
- Role check — does the member's role satisfy the Better Auth permission requirement?
- Capability check — does the organization have the required capabilities (from active billing grants)?
- Resource policy — if the permission rule specifies one, does the
additional business rule pass? (e.g.,
cannotRemoveLastOwner,memberLimitNotExceeded)
// Example: require billing.read permission
const actor = await requireAppPermission(ctx, {
permission: "billing.read",
});App Permission Keys
All permission keys and their rules are defined in
permissions/appPermissions.ts:
| Permission | Role requirement | Capabilities | Resource policy |
|---|---|---|---|
organization.read | organization: ["read"] | — | — |
organization.update | organization: ["update"] | — | organizationMustBeActive |
organization.delete | organization: ["delete"] | — | organizationMustBeActive |
member.read | member: ["read"] | — | — |
member.invite | member: ["create"] | workspace.members.invite | memberLimitNotExceeded |
member.updateRole | member: ["update"] | — | cannotModifyOwnerUnlessOwner |
member.remove | member: ["delete"] | — | cannotRemoveLastOwner |
billing.read | billing: ["read"] | — | — |
billing.manage | billing: ["manage"] | — | — |
feature.pro.use | feature: ["pro.use"] | feature.pro | — |
Resource Policies
Four resource policies add context-aware rules:
organizationMustBeActive— blocks operations on suspended/deleted orgs.cannotRemoveLastOwner— prevents removing the only owner.cannotModifyOwnerUnlessOwner— only owners can change an owner's role.memberLimitNotExceeded— blocks invites when the plan's member limit is reached.
Step-Up Verification
Destructive or high-risk actions require step-up verification. This is a separate layer that sits above normal permissions.
Risk Levels
| Level | Policy | Examples |
|---|---|---|
| 0 | No step-up | (Reserved for custom actions) |
| 1 | Recent sign-in (30 min) | Open billing portal |
| 2 | Recent sign-in, or password/email code | Remove a member |
| 3 | Password, email code, or TOTP only | Change password, cancel subscription |
| 4 | Same as 3, single-use grant, 5 min TTL | Delete account, delete organization |
Sensitive Actions Catalog
| Action ID | Risk | Org-scoped |
|---|---|---|
account.delete | 4 | No |
account.changeEmail | 3 | No |
account.changePassword | 3 | No |
account.disableTwoFactor | 3 | No |
account.regenerateBackupCodes | 3 | No |
organization.delete | 4 | Yes |
organization.changeMemberRole | 3 | Yes |
organization.removeMember | 2 | Yes |
billing.cancelSubscription | 3 | Yes |
billing.openPortal | 1 | Yes |
Verification Flow
- The client calls the protected mutation/action.
- The function calls
requireSensitiveAction(). - If no valid grant exists, a
SENSITIVE_VERIFICATION_REQUIREDerror is thrown with metadata about which methods are accepted. - The client shows a verification dialog (password, email code, or TOTP).
- The user submits the verification, which creates a time-limited grant.
- The client retries the original action — the grant satisfies the check.
Security Properties
- Verification codes are salted and peppered before hashing; raw codes are never stored.
- Grants are purpose-specific, organization-scoped (when applicable), session-scoped, and optionally single-use.
- Expired challenges and grants are pruned by the hourly cron job.
Next Reads
- Roles and Permissions — more on roles and capabilities.
- Entitlements and Grants — how capabilities are granted by billing.
Server Actions and Functions
All backend logic runs as Convex functions. This page explains the function types, patterns, and conventions used throughout the starter.
AI Agent Guidelines
This page documents the conventions that AI coding agents (Copilot, Cursor, Codex, etc.) should follow when working on the Leave Localhost codebase.