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:
- Fresh session — the user signed in recently enough (by session creation time), and the action allows it (low/medium risk only).
- 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):
| Level | Meaning | Methods |
|---|---|---|
| 1 | Fresh session is enough | fresh session |
| 2 | Medium | fresh session / password / email |
| 3 | High (no fresh-session bypass) | password / email |
| 4 | Critical (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) andorganization.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
- Add the action id to
security/validators.tsand an entry toSENSITIVE_ACTIONSinsecurity/sensitiveActions.ts(label, risk level, org scope). - In the backend function, call
requireSensitiveActionInMutation(mutations) orrequireSensitiveActionInAction(actions), passing the authenticated appuserIdandorganizationIdfor org-scoped actions. - In the UI, wrap the call:
useSensitiveAction().runSensitiveAction({ run })and render the returneddialog. For active-workspace actions the client can omitorganizationId— 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 windows —
POLICY_BY_LEVELinsecurity/sensitiveActions.ts(one table tunes the whole product). - Email copy —
email/templates/sensitiveActionVerificationEmail.tsx. - Rate limits —
rateLimitConfiginrateLimit.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_grantsinschema.ts; step-up error codes inerrors.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.
Related
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.