Leave Localhost logoLeave LocalhostDocs

Sensitive Action Protection

The app adds step-up verification for dangerous actions, built on Convex and Better Auth. It is deliberately separate from login-time 2FA:

The app adds step-up verification for dangerous actions, built on Convex and Better Auth. It is deliberately separate from login-time 2FA:

2FA protects supported login flows. Sensitive action verification protects dangerous actions after login.

This matters because Better Auth only challenges credential (email + password) sign-in. Google, Microsoft, and magic-link users are never challenged by app-level 2FA — so a destructive action like deleting an organization needs its own protection that works for every account type, whether or not 2FA is on. See Multi-Factor Authentication for the login-time 2FA demo.

How it works

When a protected backend function runs, it calls requireSensitiveAction server-side. That call succeeds only if one of these is true:

  1. Fresh session — the user signed in recently enough (by session creation time), and the action allows it (low/medium risk only).
  2. A valid grant exists — the user recently passed an explicit check (password or email code) for this exact action.

Otherwise it throws SENSITIVE_VERIFICATION_REQUIRED, and the frontend opens a verification dialog. On success the backend mints a short-lived, action-scoped grant; the original call is retried and now passes. Critical (level-4) grants are single-use.

The check is always server-side — hiding a button is convenience only, never the guarantee, because a valid session token can call the endpoint directly.

Verification methods

  • Fresh session — for low/medium-risk actions only.
  • Password confirmation — verified through Better Auth's own verifier (no new login, no password change). Unavailable for OAuth-only / magic-link users, who are routed to email automatically.
  • Email verification code — a 6-digit, single-use, hashed, rate-limited code emailed to the user. Works for every account type, so it is the universal fallback.

Risk levels and protected actions

Each action has a risk level driving its policy (registry in packages/backend/convex/security/sensitiveActions.ts):

LevelMeaningMethods
1Fresh session is enoughfresh session
2Mediumfresh session / password / email
3High (no fresh-session bypass)password / email
4Critical (single-use grant)password / email

Wired end-to-end today:

  • organization.delete (L4) — org settings danger zone.
  • account.delete (L4) — account settings.
  • organization.changeMemberRole (L3) and organization.removeMember (L2, escalated to L3 when the target is an owner/admin) — members settings.
  • billing.cancelSubscription (L3) — enforced in the backend action. There is no in-app cancel button (billing is managed via the provider portal), so it is protected for when a buyer adds one.

Intentionally not wrapped

  • account.changeEmail / account.changePassword — defined in the registry but there is no in-app UI for them yet. The dialog and backend already support them; wire a wrapper action when the UI exists.
  • Two-factor management (disable 2FA, regenerate backup codes) — Better Auth already requires the current password for these on credential accounts (its own step-up), so they are not in this registry. See Multi-Factor Authentication.
  • Opening the billing provider portal — not in the registry: it is a low-risk read-only redirect to a portal that re-authenticates on the provider side, and a fresh-session-only gate has no password/email fallback for the dialog. Add it back if your provider portal warrants it.

Adding a new protected action

  1. Add the action id to security/validators.ts and an entry to SENSITIVE_ACTIONS in security/sensitiveActions.ts (label, risk level, org scope).
  2. In the backend function, call requireSensitiveActionInMutation (mutations) or requireSensitiveActionInAction (actions), passing the authenticated app userId and organizationId for org-scoped actions.
  3. In the UI, wrap the call: useSensitiveAction().runSensitiveAction({ run }) and render the returned dialog. For active-workspace actions the client can omit organizationId — the challenge/password mutations resolve the active org server-side so the grant scope matches enforcement.

That's it — no copy-pasted verification logic.

Customizing

  • TTLs / methods / fresh-session windowsPOLICY_BY_LEVEL in security/sensitiveActions.ts (one table tunes the whole product).
  • Email copyemail/templates/sensitiveActionVerificationEmail.tsx.
  • Rate limitsrateLimitConfig in rateLimit.ts (sensitiveEmailChallengeCreate, sensitivePasswordConfirm).
  • Hashing pepper — reuses the existing BETTER_AUTH_SECRET; codes and session scopes are stored only as salted/peppered SHA-256, never in plaintext.

Key files

Backend (packages/backend/convex/security/):

  • sensitiveActions.ts — action registry + policy + display-metadata query.
  • requireSensitiveAction.ts — the single enforcement mechanism (mutation + action variants).
  • grants.ts — grant storage, scope matching, atomic single-use claim.
  • challenges.ts / challengeLogic.ts — email code create/consume.
  • passwordConfirmation.ts — password verification via Better Auth.
  • freshSession.ts, sessionScope.ts, crypto.ts, organizationScope.ts, cleanup.ts — supporting helpers + the expiry cleanup cron.
  • Tables security_verification_challenges / security_verification_grants in schema.ts; step-up error codes in errors.ts.

Frontend (apps/app/src/components/security/):

  • use-sensitive-action.tsx — wraps any protected call and runs the ceremony.
  • verify-identity-dialog.tsx — password + email-code verification UI.

Sensitive-action verifications are recorded by the audit log as a cross-cutting concern rather than inside this feature. See Audit Log and Write an Audit Event.

On this page