How Potluck protects its data, what it stores, and where the known weak spots are. To report a vulnerability, follow SECURITY.md. The mechanics (grants, RLS, definer RPCs, storage policies, edge function auth) are in architecture.md.
- Money history. Who paid whom, how much, and when. Tampering with it moves real debts between friends.
- The social graph. Who is in which group, who is friends with whom, who blocked whom.
- Plans. Availability grids and event locations say where people will be and when.
- Receipts. Photos can carry addresses, card digits and location metadata. An expense holds up to five (photos and PDFs), all in the private
receiptsbucket. - Account data. Email addresses and display names.
| Actor | Can | Stopped by |
|---|---|---|
| Anyone on the internet | call the API with the publishable key | grants: anon can read app_config, call invite_preview, invite_preview_v2, friend_invite_preview (each needs a link code), email_unsubscribe (needs a signed token) and the two guest link RPCs (need a guest link code), and read the public buckets. Nothing else |
| A signed-in stranger | call every RPC, query every table | RLS on every table, require_member() in every group RPC |
| A group member overreaching | edit others' expenses, promote themselves, delete the group | role checks inside RPCs and policies, the role matrix in architecture.md |
| A removed or departed member | keep reading the group | membership is checked on every query. Realtime re-applies RLS per change |
| Someone holding an invite link | see the group name, member count, first names; join | expiry, max uses, revocation, optional email binding |
| Someone holding a guest link | see one plan's title, window, the link creator's first name and how many people are free per slot (once it has a time, only guests who answered see it); add or edit one answer per browser | expiry (24 hours to 30 days), revocation, a guest cap per link, plan and group state checked on every call, rate limits, the token kept as a sha256 hash, removed answers blocked |
| Someone holding a calendar feed URL | read the owner's scheduled events | 32-character random token, rotatable with calendar_feed_token(true) |
| A client with a tampered bundle | send any payload to any RPC | the server derives splits, FX rates, payer of a settlement and created_by itself |
| Someone calling edge functions directly | trigger push or FX sync | x-potluck-secret shared secret, constant-time compare |
| Someone guessing unsubscribe links | turn off another person's emails | each link carries an HMAC of user id and category; the key stays in private.signing_keys. A preview shows the address masked |
Someone calling email_outbox_report |
mark emails sent or failed | executable by the service role only |
| Someone who learned a password | sign in as that person | two-step sign in when it is on: the database refuses the half signed in session (below) |
| Someone taking a seat that is not theirs | claim another person's placeholder and its history | a claim link bound to an email only works for an account with that email, confirmed (email_not_verified); automatic claiming needs a confirmed matching email; an unbound link is single use and only its holder has it; nobody already in the group can claim (ADR 19) |
| A member spamming addresses | make Potluck email strangers by adding placeholders | one invite plus one reminder per placeholder, at most 3 addresses per placeholder and 20 emailed invites a day per person; every email has one-click unsubscribe |
| Someone holding a friend link | see the inviter's first name and face; become their friend | email links are single use and bound to a confirmed email; the personal link can be rotated; blocks win and read like a dead link (no name shown) |
| Someone who was blocked | find out, or keep reaching the person who blocked them | every refusal reads like a revoked link or a stranger (invite_revoked, not_a_friend, a plain no from can_split_with, previews with no name); notifications whose actor the recipient blocked are dropped, shared groups included; a seat's invite and reminder are not sent to an account with a block with the sender; in a 1:1 tab the blocked side can no longer change money |
Out of scope: a compromised Supabase or Cloudflare account, a malicious maintainer, and a stolen unlocked phone with an active session.
These are real today. Each should become an issue.
- Closed accounts keep their calendar feed.
delete_my_account()does not delete thecalendar_feed_tokensrow, andics-feeddoes not checkprofiles.deleted_at. A closed account that still has an open balance in a group keeps that group's membership, so its old feed URL keeps listing that group's events. - Removed attachments can linger. Taking a file off an expense deletes it only when the person removing it uploaded it or is an admin (the bucket's delete rule); otherwise the file stays in the group folder, readable by members with nothing pointing at it, until the daily
receipts-sweepdeletes it once it is a day old. Deleting an expense keeps its files (it can be restored). - Avatar files outlive the account. The profile's
avatar_pathis cleared, but the file stays in the publicavatarsbucket, at a URL anyone who saw it can still open. - Admin invite rule mismatch.
can()in core lets admins create admin invites. The server refuses, so this is a UI bug, not a hole. - Previews use production data. PR preview builds point at the production Supabase project. Anyone who can open a PR that gets a preview deploy can ship code that runs against real accounts. Today previews only build for branches in this repository, because fork PRs do not get the Cloudflare secret.
- Edge function CORS is
*. Harmless for the secret- and token-protected functions, anddelete-accountrequires a JWT, but it is wider than the app needs. - Invite previews show first names to anyone with the code. That is the point of the join page, but it means an invite link is a small leak of who is in the group. A claim link also shows the saved seat's first name and its masked email.
- A placeholder's name and masked email are visible to its group. The full address is readable by no client; the person who typed it in knows it.
- Guest links are open doors until they close. Anyone with the code can add names and times up to the link's cap, and the per link limits (20 new guests per 10 minutes, 60 saves per guest per hour) are counted in the database, not per IP. Organizers clean up with remove and turn off. The preview itself is a read and is not rate limited.
- Signing out elsewhere is not instant. "Sign out everywhere" and a password change revoke every other refresh token, but an access token already handed out keeps working until it expires (up to an hour,
jwt_expiry). - A fresh stolen session can change the password without the old one. Within a day of signing in, Supabase sets a new password for the session alone; the Me page asks for the current password first, but the API does not unless the project turns on "require current password" (a dashboard setting, not in
config.toml; the app already sendscurrent_password). Older sessions need an emailed code (secure_password_change). Either way the account gets a "password changed" email. - A half signed in session can edit its own user metadata. Supabase Auth allows it at
aal1and the database never sees that write. Nothing there grants access (see ADR 13).
- Methods. Email and password, a magic link, and Google or Apple once their flags are on. Production requires a confirmed email before the first sign in.
- Two-step sign in. An authenticator app (TOTP) from the Me page. Signing in with any method then asks for a code. The database enforces it (ADR 13): until the code is in, every REST and RPC call fails with
mfa_required, Storage reads and writes nothing in any bucket, and Realtime sends nothing. Supabase Auth refuses that session's email, password and factor changes. Turning it off needs a fresh code. - Recovery codes. Ten one-time codes are shown once at setup (and again whenever they are replaced); the dialog only closes once the person says they saved them. They are stored as SHA-256 hashes, which is enough because they are random. One code, together with the first sign in step, removes the authenticator so the person can set it up again. Five tries per account per fifteen minutes, then
too_many_attempts. Turning two-step sign in off clears them. Without the codes, an operator has to remove the factor by hand after checking who is asking. - Changing the email sends a link to the old and to the new address (
double_confirm_changes); in production the switch happens only when both are opened, so a stolen session cannot quietly move the account to another inbox. (The local stack auto-confirms email, and Supabase then takes either link as enough.) A pending change can be called off from Me (cancel-email-change), which voids its links. - Changing the password asks for the current one (accounts that only ever used Google or Apple set a first password without it), and for an emailed code when the session is more than a day old (
secure_password_change). Then every other session is signed out. - Security emails. Supabase emails the account when its password or email changes and when an authenticator is added or removed (
[auth.email.notification.*]). A recovery code removes the authenticator with the admin API, which sends no email, somfa-recoverfiles anaccount.mfa_removednotice instead: inbox and push right away, email through the app's outbox once email notifications are on. Itssecuritycategory is required: it has no setting,set_notification_prefrefuses it, unsubscribe links (one category or all) leave it on, and these emails carry no unsubscribe links or headers. - Sign out everywhere revokes every session of the account, this one included.
| Data | Where | Visible to |
|---|---|---|
| Email, password hash, sessions | auth.users (Supabase Auth) |
nobody through the API. Supabase operators |
| Display name, avatar, time zone, locale, default currency | profiles |
you, people you share a group with or who left one of your groups, friends |
| Groups, members, invites | groups, group_members, group_invites |
members. Invites only to admins and the invite's creator |
| People not on Potluck yet: name, invite email | profiles, placeholders |
members of that group (the email masked); the address is used only for their invite and reminder, and deleted when they claim the seat or the group goes |
| Friend links | friend_invites |
only you |
| Blocks, reports | blocks, reports |
only you (the Potluck team reads reports) |
| Expenses, splits, settlements, comments | money tables, comments |
members of that group |
| Availability, RSVPs, events | plans tables | members of that group |
| Guest names and times | event_guests, event_guest_availability |
members of that group. Guests see only counts per slot, never names. The guest's token is stored as a sha256 hash nobody can read |
| Busy blocks, calendar links | busy_blocks, calendar_connections |
only you. OAuth tokens would live in Vault (feature is off) |
| Notifications, push subscriptions | notifications, push_tokens |
only you |
| Notification settings | notification_prefs |
only you |
| Receipts and other attachments (up to five per expense) | private receipts bucket, expense_attachments |
members of the group (authenticated download or signed URL) |
| Avatars, group covers | public buckets | anyone with the URL |
| Authenticator secrets, recovery code hashes | auth.mfa_factors, private.mfa_recovery_codes |
nobody through the API. Supabase operators |
There is no analytics or tracking code in the app.
- Push messages go through the browser vendor's push service (Google, Mozilla, Apple). Web push payloads are encrypted for the subscribing browser, so the service cannot read the title and body, which may include an expense description or a comment.
- Receipt scanning sends nothing anywhere. The photo is read in a web worker on the device (tesseract.js); the reader's code and English model are downloaded from Potluck's own origin, not a CDN. The photo leaves the device only if the person keeps it attached and saves. Photos are re-encoded to JPEG before upload, which drops their EXIF metadata (location included); HEIC photos the browser cannot decode, and PDFs, are uploaded as they are.
- FX sync sends nothing about users to Frankfurter. It requests public reference rates.
- Exports and backups (Me, Your data; group settings) are built in the browser and saved to the person's device; nothing is sent elsewhere.
export_my_data()returns only groups the caller is in now, with the names of everyone those groups mention, and the caller's own sign in email; anon cannot call it. Imports run as the importing member with every checkcreate_expense_v2makes (ADR 18). - Calendar feeds contain event titles, descriptions, locations and group names for whoever has the URL.
- Auth emails go out through Supabase's SMTP.
- Notification emails (off until
app_config.email_notificationsis on) go through the configured provider (Resend). They carry the recipient's address, names, amounts and comment excerpts, so the provider can read them. That includes invites to people who are not on Potluck yet, sent to the address a group mate typed in. - Friend invites by email are not sent by Potluck: the app opens the inviter's own mail app with the link.
Users close their account from the app, which calls the delete-account edge function with their JWT. The flow is drawn in sequences.md.
What is removed: push subscriptions, calendar links, busy blocks, notifications, availability, availability requests, RSVPs, friendships, blocks, and memberships in groups where the balance is zero. Open invites the user created are revoked, except the claim links of seats they added, which stay usable. Groups they owned pass to the longest-standing admin, else the longest-standing member, and are deleted if nobody else is left. The auth user is soft deleted, so the email can no longer sign in.
What stays, and why: the profile row stays as a tombstone named "Former member" with no avatar, because other people's expenses and settlements reference it and their balances must not change. Memberships with an open balance stay so nobody's debt disappears. Expenses, splits, settlements, comments and activity the user took part in stay in their groups.
Closed accounts get account_deleted from every RPC and never receive notifications.
- Never commit secrets.
.envand.env.*are git-ignored (except.env.example, which holds no values). The PR template asks every author to check. - The publishable key is public. It ships in the web bundle and in
VITE_*variables. That is fine, because RLS and grants decide what it can do. Never rely on it being secret. - The service role key never leaves Supabase. Only edge functions use it, through
_shared/admin.ts, and only after they have authorized the caller. It is never in GitHub, in.envfiles or in the web app. EDGE_SECRETlives in two places: the edge function secrets and the Vault entryedge_secret. Rotate both together (deployment.md).RESEND_API_KEYis an edge function secret only. Without it no notification email is sent.- The unsubscribe signing key is generated by a migration into
private.signing_keysand never leaves the database. Replacing that row breaks every unsubscribe link already sent. - VAPID private key is an edge function secret only. Rotating it invalidates every existing push subscription, and browsers have to subscribe again.
- CI secrets (listed in deployment.md) are GitHub Actions secrets, scoped as narrowly as the providers allow. Fork PRs do not receive them.
- If a secret leaks, rotate it first, then investigate. Report leaks in this repository through private vulnerability reporting.