Repository navigation
Stop exposing every user's online status and last-active time #434
Copy link
Copy link
Closed
Labels
ProfileSecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Milestone
Description
Summary
anon and authenticated can read users.status and users.online_at for every row, and public.users is in the Realtime publication. The webapp writes status every 60 seconds while a tab is visible, and on every tab hide or show. Since #417, each of those writes also stamps online_at. So anyone with the public anon key can follow, live, when each signed-in person opens or leaves a tab.
- Severity: Medium (privacy)
- Area: Supabase grants and Realtime publication on
public.users, admin dashboard - Source: security review of 2026-10-06, finding M8
Production check (2026-10-06, read-only)
public.usersis in thesupabase_realtimepublication.anonhas SELECT onusers.statusandusers.online_at.emailandnotification_preferencesstay hidden.
Where
- Row policies are
USING (true):users_select(packages/supabase/scripts/13-RLS.sql:60-62) andusers_public_anon_select(:322-324). - Column SELECT grants that include
statusandonline_at:authenticated:packages/supabase/scripts/13-RLS.sql:71-75.anon:packages/supabase/scripts/29-lint-hardening.sql:92-95.
- Publication:
packages/supabase/scripts/17-realtime-replica.sql:14-19. Its comment names the wrong path; the page isapps/admin-dashboard/src/pages/users.tsx. - The only
postgres_changesconsumer ofusers:apps/admin-dashboard/src/pages/users.tsx:108-126. It only triggers a debouncedrefetch(). The page loads its rows from/api/admin/usersand has a refresh button (:311). - Heartbeat:
apps/webapp/src/hooks/useHandleUserStatus.ts:34-41(every 60 s),:70-78(tab hide or show) and:99-116(unload). online_atis stamped on every status write byupdate_user_online_at:packages/supabase/scripts/10-1-func-users.sql:140-157.- The webapp selects both columns in
USER_PROFILE_COLUMNS(apps/webapp/src/api/users/getUserById.ts:14-15).updateUserreuses that list as its RETURNING select (apps/webapp/src/api/users/updateUser.ts:6-13). Only the heartbeat callsupdateUser; Settings saves go through theupdate_profileRPC. No webapp code readsonline_at. Presence shows other users' status, not this table. - Server-side readers run as
postgres, so the revoke does not touch them:internal.is_user_online(packages/supabase/scripts/10-1-func-users.sql:174), called fromsecurity definertriggers.enqueue_push_notification(packages/supabase/scripts/07-4-push-notifications-pgmq.sql:173).- The
update-user-statuscron job (packages/supabase/scripts/16-cron-jobs.sql:9-14).
Fix plan
- Webapp first. Remove
statusandonline_atfromUSER_PROFILE_COLUMNSingetUserById.ts.- This is required. A PostgREST update with
.select()runs UPDATE … RETURNING, which needs SELECT on each returned column. Without this edit, every heartbeat fails with42501. The hook swallows the error, soonline_atgoes stale and push stops skipping online users. applySignedInProfile.ts:27already sets the localstatusto'ONLINE', so the store keeps a value.
- This is required. A PostgREST update with
- Revoke the columns. In one new migration:
revoke select (status, online_at) on public.users from anon, authenticated;. Keepstatusin theauthenticatedUPDATE grant, so the heartbeat still writes.- Mirror it by removing both columns from the grant lists in
13-RLS.sql:72-75and29-lint-hardening.sql:92-95.
- Mirror it by removing both columns from the grant lists in
- Drop the table from Realtime. In the same migration, run
alter publication supabase_realtime drop table public.users. Guard it onpg_publication_tables, so a re-run does not fail.- Mirror it in
17-realtime-replica.sql: removepublic.usersand its comment.
- Mirror it in
- Admin page. Delete the
useRealtimeSubscription({ table: 'users', ... })call and its debounce timer inapps/admin-dashboard/src/pages/users.tsx:108-126. The refresh button stays. KeepuseRealtimeSubscriptionitself;pages/channels.tsxstill uses it.
Ship step 1 before or with the migration. The migration alone breaks the heartbeat for any tab still running the old webapp.
Repo rules that apply:
- Regenerate the seed with
bun run --filter @docs.plus/supabase_back seed. Never hand-editpackages/supabase/seed.sql. - Run
bun run --filter @docs.plus/supabase_back types. Grants and the publication do not change the generated types, so expect no diff.
Out of scope
- The public profile directory (
id,username,full_name, avatar). Chat sender info and the mention picker read it. Narrowing it is a separate design. - Presence peers see the
statusthat the client broadcasts (A forged presence payload can change the avatar on live chat messages #428).
Acceptance criteria
- With the anon key,
GET /rest/v1/users?select=status,online_atis refused. - A signed-in client's select of
statusoronline_aton another user is refused. - A
postgres_changessubscription onpublic.usersreceives no events. - The heartbeat still succeeds, and
online_atstill moves on each heartbeat. - Signing in and a Settings profile save still work.
- Push and email still skip users who are online.
- The admin Users page loads and refreshes.
Verify
- Apply locally:
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql. - In
psql, insidebegin; ... rollback;:set local role anon; select status from public.users limit 1;must fail with42501.select count(*) from pg_publication_tables where pubname = 'supabase_realtime' and tablename = 'users';must return 0.
- Sign in to the local webapp. Watch the network tab for the 60 s
PATCH /rest/v1/usersand confirm 200. Then checkonline_ataspostgres. - Sign out and in again, then save a profile change in Settings. Sign-in (
applySignedInProfile.ts) anduseProfileSync.tsread the profile throughgetUserById. - Run
cd apps/webapp && bun run test.
Related
- Bring back push on Chrome and Android devices the server switched off #417 made each status write stamp
online_at. - A forged presence payload can change the avatar on live chat messages #428 fake presence.
Generated by Claude Code
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
ProfileSecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Activity
[-][Privacy] Anyone can list every user and watch their activity live[/-][+]Stop exposing every user's online status and last-active time[/+]fix(supabase): hide user status and last-active time from other users
docs(hocuspocus): list the status removal as Breaking and record the …
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
refactor(supabase): tighten 9 Oct migration notes and drop dead checks
refactor(webapp): trim user API reads and rename the public profile f…
HMarzban commentedon Oct 9, 2026
Fixed and live.
8ecf60fd7bbdaae66c(production run 37976785981, green).20261009120500is applied. anon and authenticated cannot SELECTusers.statusorusers.online_at, authenticated keeps UPDATE onstatus, andpublic.usersis out ofsupabase_realtime.