Skip to content

fix(db): separate mfa and email otp checks#2454

Open
riderx wants to merge 1 commit into
mainfrom
codex/fix-mfa-otp-aal-bypass
Open

fix(db): separate mfa and email otp checks#2454
riderx wants to merge 1 commit into
mainfrom
codex/fix-mfa-otp-aal-bypass

Conversation

@riderx

@riderx riderx commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary (AI generated)

  • Update public.verify_mfa() so MFA-gated RLS/admin checks rely only on Supabase AAL session assurance.
  • Add public.verify_email_otp_auth() as the separate first-factor OTP auth-method helper.
  • Make private/verify_email_otp call verify_email_otp_auth() with the verified OTP session before recording email_otp_verified_at.
  • Add pgTAP, endpoint, unit, and execute-grant regression coverage for the split behavior.

Motivation (AI generated)

Email OTP can be a first-factor aal1 login method, while completed MFA must be represented by aal2. Keeping those checks in one function let OTP first-factor sessions satisfy MFA-gated paths for users with enrolled MFA. Splitting the semantics keeps expected aal1 access for users without MFA while preventing OTP first-factor sessions from being treated as MFA.

Business Impact (AI generated)

This preserves org 2FA enforcement and platform-admin MFA expectations, reducing account-takeover blast radius for customers who enable 2FA or depend on admin impersonation protections. The email OTP reauthentication flow remains supported, but now records its marker only after the OTP-specific helper validates the returned OTP session.

Test Plan (AI generated)

  • bun lint
  • bun lint:backend
  • bun run supabase:with-env -- bunx vitest run tests/verify-email-otp-auth-session.unit.test.ts tests/verify-email-otp.test.ts
  • bunx supabase test db --local --workdir .context/supabase-worktrees/b3ffd30a supabase/tests/00-supabase_test_helpers.sql supabase/tests/58_test_mfa_session_otp_split.sql
  • bunx supabase test db --local --workdir .context/supabase-worktrees/b3ffd30a supabase/tests
  • bun run supabase:with-env -- bunx vitest run tests/security-definer-execute-hardening.test.ts
  • bun test:backend
  • bun typecheck
  • EXPLAIN check for the MFA factor lookup stayed bounded by user_id: local plan used an index-backed scan on auth.mfa_factors, Execution Time: 0.082 ms, Buffers: shared hit=1.

Generated with AI

Summary by CodeRabbit

  • New Features

    • Added email OTP authentication verification with session validation during the verification flow.
  • Tests

    • Added comprehensive test coverage for OTP authentication verification and MFA session handling.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@riderx, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 37 minutes and 47 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2a954c01-7575-411b-9578-2366f7fad095

📥 Commits

Reviewing files that changed from the base of the PR and between b78afd4 and e3c8e89.

📒 Files selected for processing (7)
  • src/types/supabase.types.ts
  • supabase/functions/_backend/private/verify_email_otp.ts
  • supabase/functions/_backend/utils/supabase.types.ts
  • supabase/migrations/20260608114543_split_mfa_session_and_email_otp_checks.sql
  • supabase/tests/58_test_mfa_session_otp_split.sql
  • tests/security-definer-execute-hardening.test.ts
  • tests/verify-email-otp-auth-session.unit.test.ts
📝 Walkthrough

Walkthrough

This PR adds email OTP session authentication verification by introducing SQL security-definer functions to check JWT OTP methods and MFA assurance, updating TypeScript types, implementing a session-verification helper, integrating it into the OTP handler, and adding comprehensive tests.

Changes

Email OTP Session Authentication

Layer / File(s) Summary
SQL security-definer functions
supabase/migrations/20260608114543_split_mfa_session_and_email_otp_checks.sql
New public.verify_mfa() checks JWT aal claim against verified MFA factors; new public.verify_email_otp_auth() checks whether JWT amr contains OTP method. Both use SECURITY DEFINER with restricted execution grants.
Supabase type definitions
src/types/supabase.types.ts, supabase/functions/_backend/utils/supabase.types.ts
Added verify_email_otp_auth RPC type signature with no arguments (Args: never) and boolean return in both client and backend type definition files.
Session verification helper
supabase/functions/_backend/private/verify_email_otp.ts
Imported supabaseClient and added exported verifyEmailOtpAuthSession helper that invokes the verify_email_otp_auth RPC using bearer token authorization, returning verification status and error details.
OTP handler integration
supabase/functions/_backend/private/verify_email_otp.ts
After successful OTP validation, handler calls verifyEmailOtpAuthSession with the verified session access token; on failure, records failed auth attempt, logs error context, and returns 401 invalid_otp_auth.
Test coverage
supabase/tests/58_test_mfa_session_otp_split.sql, tests/verify-email-otp-auth-session.unit.test.ts, tests/security-definer-execute-hardening.test.ts
SQL tests verify MFA and OTP session scenarios across multiple JWT aal/amr configurations; Vitest unit tests mock supabaseClient and verify helper success/failure paths; security-definer test adds verify_email_otp_auth to authenticated-only procedures allowlist.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Cap-go/capgo#1966: Execute-grant hardening for the same public.verify_email_otp_auth() security-definer function across roles.
  • Cap-go/capgo#2004: Parallel modifications to the supabase/functions/_backend/private/verify_email_otp.ts email-OTP handler for rate limiting and auth attempt bookkeeping.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(db): separate mfa and email otp checks' clearly and concisely summarizes the main change—splitting MFA and email OTP authentication checks into separate functions.
Description check ✅ Passed The description includes a comprehensive summary of changes, detailed motivation, business impact, and a thorough test plan with all checks marked complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codspeed-hq

codspeed-hq Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing codex/fix-mfa-otp-aal-bypass (e3c8e89) with main (8f3eccb)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@riderx riderx marked this pull request as ready for review June 8, 2026 12:09
@riderx riderx force-pushed the codex/fix-mfa-otp-aal-bypass branch from 542d05a to b78afd4 Compare June 8, 2026 12:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/functions/_backend/private/verify_email_otp.ts`:
- Around line 116-125: The current logic treats any non-verified result from
verifyEmailOtpAuthSession(c, verifyData.session.access_token) the same and calls
recordFailedAccountAuth(c, auth.userId), which will penalize users when the
RPC/backend fails; change the conditional to only call recordFailedAccountAuth
and return an "invalid_otp_auth" response when emailOtpAuth.verified === false
and emailOtpAuth.error is falsy (i.e., an explicit user auth failure); if
emailOtpAuth.error exists (indicating an RPC/internal failure), log the error
via cloudlog with context and return a 500/appropriate transient error without
recording a failed account auth. Ensure you reference verifyEmailOtpAuthSession,
emailOtpAuth.error, emailOtpAuth.verified, and recordFailedAccountAuth when
making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 328714a1-dc4f-4c11-8b11-393278b7c083

📥 Commits

Reviewing files that changed from the base of the PR and between 8f3eccb and b78afd4.

📒 Files selected for processing (7)
  • src/types/supabase.types.ts
  • supabase/functions/_backend/private/verify_email_otp.ts
  • supabase/functions/_backend/utils/supabase.types.ts
  • supabase/migrations/20260608114543_split_mfa_session_and_email_otp_checks.sql
  • supabase/tests/58_test_mfa_session_otp_split.sql
  • tests/security-definer-execute-hardening.test.ts
  • tests/verify-email-otp-auth-session.unit.test.ts

Comment thread supabase/functions/_backend/private/verify_email_otp.ts
@riderx riderx force-pushed the codex/fix-mfa-otp-aal-bypass branch from b78afd4 to e3c8e89 Compare June 8, 2026 12:32
@sonarqubecloud

sonarqubecloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

@WcaleNieWolny WcaleNieWolny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ This breaks log_as (admin impersonation) for users who have 2FA enabled

Removing the amr.method = 'otp' branch from verify_mfa() closes a real bypass, but that branch is load-bearing for the admin spoof (private/log_as). As written, this PR locks platform admins out of exactly the 2FA accounts they most need to support, and log_as.ts isn't touched to compensate.

How log_as actually works

  1. Verify the calling admin is a platform admin → is_platform_admin() → checks the admin's own verify_mfa() (admin has real TOTP → aal2, fine).
  2. Mint a session for the target user: supabaseAdmin.auth.admin.generateLink({ type: 'magiclink' })verifyOtp({ type: 'email' }).
  3. Return that JWT to the frontend, which setSessions it. The admin is now browsing as the target user.

The session minted in step 2 is single-factor, so its claims are:

  • aal = aal1 (the target user's TOTP was never completed — the admin can't complete it)
  • amr = [{ method: 'otp' }]

Why removing the branch breaks it

The target user's apps, orgs, channels, etc. carry the RESTRICTIVE policy USING (verify_mfa()) ("Prevent non 2FA access"). For a 2FA-enrolled user, the impersonation session must pass verify_mfa() or the admin sees an empty account.

aal1 + amr.otp, user has TOTP
Old verify_mfa() first branch fails, OR (amr has 'otp') → TRUE ✅ spoof works
New verify_mfa() (this PR) first branch fails, no otp branch → FALSE ❌ RLS denies every row

This isn't incidental — the otp branch was added in commit 292ad2741 "fix: 2fa for admins" specifically to make impersonation of 2FA users work.

Why the obvious workarounds don't help

  • "Just disable magic-link sign-in in Supabase" — Supabase exposes magic link and email OTP as one /otp provider; there's no toggle to disable first-factor magic-link login while keeping email OTP. And the app needs that endpoint on: emailOtp.ts (sendEmailOtpVerificationsignInWithOtp) and ManageTwoFactor.vue use it for MFA-enrollment / sensitive-action step-up — the very email_otp_verified_at flow this PR hardens. So the same endpoint is both the attack vector and a required feature; you can't disable it.
  • Can't tell impersonation from abuse by amr — both a real first-factor email-OTP login and a log_as impersonation token are aal1 + method: 'otp'. They differ only by provenance: log_as mints server-side via service-role generateLink, a path the public can't reach.

Suggested fix (ship alongside the branch removal)

Mark the impersonation session explicitly so verify_mfa() can trust impersonation without trusting any otp session. A session-bound marker is the tightest, lowest-footprint option (no auth hook, no Supabase config change):

1. log_as.ts — record the minted session (service_role):

const sessionId = JSON.parse(atob(jwt.split('.')[1])).session_id
await supabaseAdmin.from('impersonation_sessions').insert({
  session_id: sessionId,
  target_user_id: user_id,
  admin_user_id: callerId,
  expires_at: new Date(Date.now() + 60 * 60_000).toISOString(),
})

2. verify_mfa() — replace the removed otp branch with a narrowly-scoped, trusted one:

OR EXISTS (
  SELECT 1 FROM public.impersonation_sessions s
  WHERE s.session_id = (auth.jwt() ->> 'session_id')
    AND s.expires_at > now()
)

3. Lock impersonation_sessions behind deny-all RLS, GRANT to service_role only — so the public signInWithOtp flow can never forge it.

Why this beats a custom access-token hook here:

  • Bypass is bound to the one session_id log_as minted — not to target_user_id, so the real user's own aal1 sessions never inherit it.
  • session_id is stable across refresh and auto-expires via expires_at — no global hook re-firing on every login/refresh to reason about.
  • Provenance enforced in the code path (service-role insert), not a remote console toggle that can be silently flipped back on.

TL;DR

The amr.otp branch is simultaneously the vulnerability and the mechanism behind admin impersonation of 2FA users. Removing it without giving log_as a trusted, distinguishable marker ships an admin-impersonation regressin for 2FA accounts. log_as.ts + verify_mfa() need to change together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants