fix(auth): block sensitive account actions for unverified users#1690
fix(auth): block sensitive account actions for unverified users#1690
Conversation
📝 WalkthroughWalkthroughAdds frontend email-verification guards that redirect unverified users to a resend-email flow, and a new PostgreSQL Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client as Client
participant Router as Router/Guard
participant Auth as Auth Store
participant UI as ResendEmail UI
participant DB as Postgres (delete_user)
Client->>Router: navigate to /settings or /delete_account
Router->>Auth: ensure sessionUser / main.auth (hadAuth?)
Auth-->>Router: sessionUser + email_confirmed_at
alt email not verified and needsVerifiedEmail
Router->>Client: redirect to /resend_email?reason=email_not_verified&return_to=...
Client->>UI: show verification notice (optional return_to)
else email verified
Router->>Client: allow route
Client->>DB: call stored procedure -> public.delete_user()
DB-->>Client: success (schedules deletion, deletes API keys)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 SQLFluff (4.0.4)supabase/migrations/20260226090000_require_verified_email_for_delete_user.sqlUser Error: No dialect was specified. You must configure a dialect or specify one on the command line using --dialect after the command. Available dialects: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ebd8f08f53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
src/modules/auth.ts
Outdated
| // User is already authenticated, but check if account got disabled | ||
| // (only if not already on account disabled page) | ||
| if (to.path !== '/accountDisabled') { | ||
| if (!main.auth.email_confirmed_at && needsVerifiedEmail) { |
There was a problem hiding this comment.
Use fresh session data for verified-email checks
The verified-email gate in the authenticated branch reads main.auth.email_confirmed_at, but main.auth is only set once when it is initially empty and is never refreshed from getSession(). If a user confirms their email after login (for example via a link opened in another tab/device), the in-memory store can remain stale and keep redirecting them to /resend_email, blocking settings access until they fully sign out or reload.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/modules/auth.ts`:
- Around line 172-174: The redirect uses a stale store value
main.auth.email_confirmed_at which can trap recently-verified users; instead use
the fresh sessionUser returned by getSession() (the sessionUser variable in
scope) when checking verification. Replace the condition that reads
main.auth.email_confirmed_at with a check against sessionUser.email_confirmed_at
(or sessionUser?.email_confirmed_at) while keeping the needsVerifiedEmail logic
and the redirect next('/resend_email') the same so the guard uses the up-to-date
session data.
In
`@supabase/migrations/20260224193302_auto_restore_account_after_password_reset.sql`:
- Around line 1-15: The SECURITY DEFINER function
restore_pending_delete_on_password_change currently has no explicit owner and
will default to the migration-runner role; add an ALTER FUNCTION statement to
set its owner to "postgres" after the function definition (use ALTER FUNCTION
public.restore_pending_delete_on_password_change() OWNER TO "postgres") so the
function runs with the intended postgres privileges.
In
`@supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql`:
- Around line 61-70: The INSERT into to_delete_accounts can raise a unique-key
violation on account_id if delete_user() is called twice before the row is
processed; modify the INSERT that uses user_id_fn to include an ON CONFLICT
(account_id) clause (e.g., ON CONFLICT (account_id) DO NOTHING) so duplicate
attempts inside the reauth window don't error the transaction and cause the
pgmq.send rollback.
- Around line 29-32: The INSERT into to_delete_accounts (used by delete_user())
can fail with a unique constraint if called twice; modify the INSERT statement
that adds account_id into to_delete_accounts to include an ON CONFLICT
(account_id) DO NOTHING clause (or DO UPDATE/RETURNING as appropriate) so
repeated calls within the 5-minute window are idempotent and do not raise an
unhandled exception; locate the INSERT related to to_delete_accounts.account_id
in the delete_user() flow and add the ON CONFLICT handling there.
In `@tests/test-utils.ts`:
- Around line 83-84: Add a seed row for the reset user constants so tests can
find USER_ID_DELETE_USER_RESET and USER_EMAIL_DELETE_USER_RESET: insert a
user/profile record in your seed SQL with id = USER_ID_DELETE_USER_RESET and
email = USER_EMAIL_DELETE_USER_RESET, mirroring the same columns/values used for
the other seeded users (auth.users and any profiles/metadata the tests expect,
e.g., created_at, confirmed/protected fields, and matching stale/fresh pattern
if applicable) so the test constants resolve to an actual seeded user.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/modules/auth.tssupabase/migrations/20260224193302_auto_restore_account_after_password_reset.sqlsupabase/migrations/20260226090000_require_verified_email_for_delete_user.sqltests/test-utils.ts
| CREATE OR REPLACE FUNCTION "public"."restore_pending_delete_on_password_change"() | ||
| RETURNS "trigger" | ||
| LANGUAGE "plpgsql" | ||
| SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| BEGIN | ||
| IF NEW."encrypted_password" IS DISTINCT FROM OLD."encrypted_password" THEN | ||
| DELETE FROM "public"."to_delete_accounts" | ||
| WHERE "account_id" = NEW."id"; | ||
| END IF; | ||
|
|
||
| RETURN NEW; | ||
| END; | ||
| $$; |
There was a problem hiding this comment.
Add ALTER FUNCTION ... OWNER TO "postgres" for the SECURITY DEFINER function.
The companion migration (20260226090000) explicitly sets the function owner to postgres. Without the same statement here, the owner defaults to the migration-runner role, which on hosted Supabase is supabase_admin rather than postgres. A SECURITY DEFINER function running under supabase_admin may have different effective privileges than intended.
🛡️ Proposed addition (after line 15)
+
+ALTER FUNCTION "public"."restore_pending_delete_on_password_change"() OWNER TO "postgres";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@supabase/migrations/20260224193302_auto_restore_account_after_password_reset.sql`
around lines 1 - 15, The SECURITY DEFINER function
restore_pending_delete_on_password_change currently has no explicit owner and
will default to the migration-runner role; add an ALTER FUNCTION statement to
set its owner to "postgres" after the function definition (use ALTER FUNCTION
public.restore_pending_delete_on_password_change() OWNER TO "postgres") so the
function runs with the intended postgres privileges.
supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql
Show resolved
Hide resolved
supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql
Outdated
Show resolved
Hide resolved
tests/test-utils.ts
Outdated
| export const USER_ID_DELETE_USER_RESET = 'd9c3f1a7-3e6b-4b2f-9a1d-8c7e5f4a3210' | ||
| export const USER_EMAIL_DELETE_USER_RESET = 'delete-user-reset@capgo.app' |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Search for the new test user in seed.sql
rg -n 'd9c3f1a7-3e6b-4b2f-9a1d-8c7e5f4a3210|delete-user-reset@capgo.app' supabase/seed.sqlRepository: Cap-go/capgo
Length of output: 38
🏁 Script executed:
# Check seed.sql exists and examine test user seeding patterns
cat supabase/seed.sql | head -100
echo "---"
# Search for existing test users mentioned in test-utils.ts
rg -i 'stale|fresh' supabase/seed.sql | head -20Repository: Cap-go/capgo
Length of output: 11451
🏁 Script executed:
# Search for usage of the new constants in test files
rg -r 'USER_ID_DELETE_USER_RESET|USER_EMAIL_DELETE_USER_RESET' tests/ --type ts --type jsRepository: Cap-go/capgo
Length of output: 1217
🏁 Script executed:
# Search for literal usage of the new constants
rg 'USER_ID_DELETE_USER_RESET|USER_EMAIL_DELETE_USER_RESET' tests/ -g '*.test.ts' -g '*.test.js' -v 'include:' -v 'globalSetup'Repository: Cap-go/capgo
Length of output: 50488
🏁 Script executed:
# Search for the new constants in test files - simpler approach
rg 'USER_ID_DELETE_USER_RESET|USER_EMAIL_DELETE_USER_RESET' tests/ --max-count 5Repository: Cap-go/capgo
Length of output: 249
Add corresponding seed data for the new reset user constants before tests can reliably use them.
USER_ID_DELETE_USER_RESET and USER_EMAIL_DELETE_USER_RESET match the stale/fresh pattern but lack corresponding entries in supabase/seed.sql. The stale and fresh variants are both seeded; the reset user must be added to prevent test failures when these constants are used.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/test-utils.ts` around lines 83 - 84, Add a seed row for the reset user
constants so tests can find USER_ID_DELETE_USER_RESET and
USER_EMAIL_DELETE_USER_RESET: insert a user/profile record in your seed SQL with
id = USER_ID_DELETE_USER_RESET and email = USER_EMAIL_DELETE_USER_RESET,
mirroring the same columns/values used for the other seeded users (auth.users
and any profiles/metadata the tests expect, e.g., created_at,
confirmed/protected fields, and matching stale/fresh pattern if applicable) so
the test constants resolve to an actual seeded user.
e14ddef to
cf1467a
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/modules/auth.ts (1)
96-96: Consider replacing hard-coded sensitive paths with route meta policy.
to.path.startsWith('/settings') || to.path === '/delete_account'works, but a route-levelmeta.requiresVerifiedEmailflag would be easier to maintain and less error-prone as routes evolve.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/modules/auth.ts` at line 96, Replace the hard-coded path checks in the auth guard (the needsVerifiedEmail calculation) with a route meta flag: check for to.meta?.requiresVerifiedEmail and use that boolean to determine needsVerifiedEmail; keep the existing startsWith('/settings') || to.path === '/delete_account' as a backward-compatible fallback only when the meta flag is undefined to avoid breaking routes that haven't been migrated; update places referencing needsVerifiedEmail so new routes can signal the requirement via their route definitions (meta.requiresVerifiedEmail).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/modules/auth.ts`:
- Line 96: Replace the hard-coded path checks in the auth guard (the
needsVerifiedEmail calculation) with a route meta flag: check for
to.meta?.requiresVerifiedEmail and use that boolean to determine
needsVerifiedEmail; keep the existing startsWith('/settings') || to.path ===
'/delete_account' as a backward-compatible fallback only when the meta flag is
undefined to avoid breaking routes that haven't been migrated; update places
referencing needsVerifiedEmail so new routes can signal the requirement via
their route definitions (meta.requiresVerifiedEmail).
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/modules/auth.tssupabase/migrations/20260226090000_require_verified_email_for_delete_user.sql
🚧 Files skipped from review as they are similar to previous changes (1)
- supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql
299bba4 to
b463239
Compare
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/pages/resend_email.vue`:
- Around line 50-59: Replace the hardcoded English strings in the verification
banner with i18n keys so non-English locales render correctly: use the Vue i18n
translate function (e.g. $t or i18n.t) for the three texts inside the div
controlled by emailVerificationBlockingReason — the main message ("You cannot
access this action right now because your email is not verified yet."), the
helper sentence about verifying email and account actions, and the attempted
destination label shown when returnTo is present (interpolating returnTo). Add
corresponding i18n keys such as verification.banner.blocked,
verification.banner.details, and verification.banner.attemptedDestination in
your locale files and update the template to call the translator for these keys
where those hardcoded strings currently are.
In
`@supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql`:
- Around line 48-74: The pgmq.send call (on_user_delete) runs before the INSERT
into to_delete_accounts so retries can enqueue duplicate events; change the
ordering so the scheduling INSERT into "public"."to_delete_accounts" (currently
using user_id_fn, removal_date, removed_data) is executed first with INSERT ...
RETURNING (or check GET DIAGNOSTICS row_count) and then only call PERFORM
"pgmq"."send"('on_user_delete', ...) when the INSERT actually inserted a row
(i.e., when RETURNING returned a row or row_count > 0); keep the DELETE FROM
"public"."apikeys" as-is or run it after the send if intended, but ensure
pgmq.send and downstream cleanup happen only on a new insert to make
on_user_delete idempotent.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/modules/auth.tssrc/pages/delete_account.vuesrc/pages/resend_email.vuesupabase/migrations/20260226090000_require_verified_email_for_delete_user.sql
| <div v-if="emailVerificationBlockingReason" class="mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-100"> | ||
| <p class="mb-1"> | ||
| You cannot access this action right now because your email is not verified yet. | ||
| </p> | ||
| <p class="text-xs leading-5"> | ||
| Verify your email to continue. If you were trying to access account settings or delete your account, please confirm your email first. | ||
| </p> | ||
| <p v-if="returnTo" class="mt-2 text-xs font-medium"> | ||
| Attempted destination: {{ returnTo }} | ||
| </p> |
There was a problem hiding this comment.
Localize the new verification banner copy.
Line 52-59 introduces hardcoded English text, which causes mixed-language UI on non-English locales.
🌐 Suggested i18n update
- <p class="mb-1">
- You cannot access this action right now because your email is not verified yet.
- </p>
- <p class="text-xs leading-5">
- Verify your email to continue. If you were trying to access account settings or delete your account, please confirm your email first.
- </p>
- <p v-if="returnTo" class="mt-2 text-xs font-medium">
- Attempted destination: {{ returnTo }}
- </p>
+ <p class="mb-1">
+ {{ t('email-not-verified-banner-title', 'You cannot access this action right now because your email is not verified yet.') }}
+ </p>
+ <p class="text-xs leading-5">
+ {{ t('email-not-verified-banner-body', 'Verify your email to continue. If you were trying to access account settings or delete your account, please confirm your email first.') }}
+ </p>
+ <p v-if="returnTo" class="mt-2 text-xs font-medium">
+ {{ t('attempted-destination', 'Attempted destination:') }} {{ returnTo }}
+ </p>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/pages/resend_email.vue` around lines 50 - 59, Replace the hardcoded
English strings in the verification banner with i18n keys so non-English locales
render correctly: use the Vue i18n translate function (e.g. $t or i18n.t) for
the three texts inside the div controlled by emailVerificationBlockingReason —
the main message ("You cannot access this action right now because your email is
not verified yet."), the helper sentence about verifying email and account
actions, and the attempted destination label shown when returnTo is present
(interpolating returnTo). Add corresponding i18n keys such as
verification.banner.blocked, verification.banner.details, and
verification.banner.attemptedDestination in your locale files and update the
template to call the translator for these keys where those hardcoded strings
currently are.
| PERFORM "pgmq"."send"( | ||
| 'on_user_delete'::text, | ||
| "jsonb_build_object"( | ||
| 'payload', "jsonb_build_object"( | ||
| 'old_record', old_record_json, | ||
| 'table', 'users', | ||
| 'type', 'DELETE' | ||
| ), | ||
| 'function_name', 'on_user_delete' | ||
| ) | ||
| ); | ||
|
|
||
| -- Mark the user for deletion | ||
| INSERT INTO "public"."to_delete_accounts" ( | ||
| "account_id", | ||
| "removal_date", | ||
| "removed_data" | ||
| ) VALUES | ||
| ( | ||
| user_id_fn, | ||
| NOW() + INTERVAL '30 days', | ||
| "jsonb_build_object"('email', user_email, 'apikeys', COALESCE((SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn), '[]'::jsonb)) | ||
| ) | ||
| ON CONFLICT ("account_id") DO NOTHING; | ||
|
|
||
| -- Delete the API keys | ||
| DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; |
There was a problem hiding this comment.
Prevent duplicate on_user_delete queue events on retries.
With ON CONFLICT ("account_id") DO NOTHING, repeated calls now succeed and still execute pgmq.send on Line 48, which can enqueue duplicate side effects. Queue send (and downstream cleanup) should run only when scheduling is newly inserted.
🐛 Proposed idempotency-safe ordering
DECLARE
user_id_fn uuid;
user_email text;
old_record_json jsonb;
last_sign_in_at_ts timestamptz;
email_confirmed_at_ts timestamptz;
+ did_schedule integer;
BEGIN
@@
- -- Trigger the queue-based deletion process
- -- This cancels the subscriptions of the user's organizations
- PERFORM "pgmq"."send"(
- 'on_user_delete'::text,
- "jsonb_build_object"(
- 'payload', "jsonb_build_object"(
- 'old_record', old_record_json,
- 'table', 'users',
- 'type', 'DELETE'
- ),
- 'function_name', 'on_user_delete'
- )
- );
-
-- Mark the user for deletion
INSERT INTO "public"."to_delete_accounts" (
"account_id",
"removal_date",
"removed_data"
@@
(
user_id_fn,
NOW() + INTERVAL '30 days',
"jsonb_build_object"('email', user_email, 'apikeys', COALESCE((SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn), '[]'::jsonb))
)
- ON CONFLICT ("account_id") DO NOTHING;
+ ON CONFLICT ("account_id") DO NOTHING
+ RETURNING 1 INTO did_schedule;
+
+ IF did_schedule IS NULL THEN
+ RETURN;
+ END IF;
+
+ -- Trigger the queue-based deletion process
+ -- This cancels the subscriptions of the user's organizations
+ PERFORM "pgmq"."send"(
+ 'on_user_delete'::text,
+ "jsonb_build_object"(
+ 'payload', "jsonb_build_object"(
+ 'old_record', old_record_json,
+ 'table', 'users',
+ 'type', 'DELETE'
+ ),
+ 'function_name', 'on_user_delete'
+ )
+ );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql`
around lines 48 - 74, The pgmq.send call (on_user_delete) runs before the INSERT
into to_delete_accounts so retries can enqueue duplicate events; change the
ordering so the scheduling INSERT into "public"."to_delete_accounts" (currently
using user_id_fn, removal_date, removed_data) is executed first with INSERT ...
RETURNING (or check GET DIAGNOSTICS row_count) and then only call PERFORM
"pgmq"."send"('on_user_delete', ...) when the INSERT actually inserted a row
(i.e., when RETURNING returned a row or row_count > 0); keep the DELETE FROM
"public"."apikeys" as-is or run it after the send if intended, but ensure
pgmq.send and downstream cleanup happen only on a new insert to make
on_user_delete idempotent.



Summary (AI generated)
auth.users.email_confirmed_at.public.delete_user()to reject deletes when email is not verified./settings*and/delete_account, redirecting them to/resend_email.Motivation (AI generated)
Prevent account lifecycle denial-of-service where attackers can register arbitrary unverified emails and place legitimate owners into a 30-day pending deletion state without proof of ownership.
Business Impact (AI generated)
This blocks abuse that can lock customer emails out of the platform, reduces support overhead, and preserves account integrity during onboarding and recovery.
Test Plan (AI generated)
bun lintGenerated with AI
Summary by CodeRabbit