Invitation security

The invite landing page needs to render "You've been invited to Acme by Jane" before the recipient signs in. That means one small piece of the database has to be reachable by anonymous callers. This page documents exactly what that surface is, why it's safe, and who is expected to call it.

The function

public.peek_invitation(_raw_token text) is a SECURITY DEFINER, STABLE SQL function with EXECUTE granted to both anon and authenticated. It is the only privileged invitation function exposed to anoncreate_invitation, redeem_invitation, and revoke_invitation are authenticated-only.

How the token is protected

  • The raw token is generated server-side when the invite is created and delivered only in the invitation email link.
  • The database never stores the raw token. It stores token_hash, a SHA-256 digest computed via encode(extensions.digest(_raw_token, 'sha256'), 'hex').
  • peek_invitation recomputes the same hash from the caller's input and looks up a single row by token_hash. A leaked hash from the database cannot be replayed — you need the original token from the email.
  • Tokens are single-lookup, time-bound (expires_at) and tracked with a status that flips to redeemed / revoked once used.

What the function returns

Only the fields the invite landing page needs to render context — no IDs, no emails of other members, no role/permission data, no workspace internals:

kind                        invitation_kind
email                       text   -- the invited recipient's own email
workspace_name              text
inviter_display_name        text
client_name                 text
collaborator_company_name   text
group_name                  text
new_company_name            text
expires_at                  timestamptz
status                      invitation_status

Notably absent: workspace_id, invited_by_user_id, role, token_hash, and every other column on public.invitations. An attacker who guesses a valid token learns only what the legitimate recipient would already see in their email.

Expected callers

  • Invite landing page (/invite/$token) — the sole intended caller. Runs pre-auth so the recipient can see who invited them before signing up or signing in.
  • Server function peekInvitation in src/lib/invitations.functions.ts wraps the RPC. All other invitation server functions require requireSupabaseAuth.

Nothing else in the app calls peek_invitation, and no client code should. Redemption always goes through the authenticated redeem_invitation path, which enforces the recipient's session identity and marks the token consumed atomically.

Regression coverage

The anon-exposure surface is pinned by two automated checks that run in CI on every migration change:

  • tests/security-definer-exec-grants.test.ts asserts the exact allow-list of SECURITY DEFINER functions that anon may execute. Adding a new anon-callable privileged function fails the build until the allow-list is updated deliberately.
  • tests/rls-cross-workspace-admin.test.ts verifies that no role — anonymous or authenticated — can pivot from a peek_invitation result into cross-workspace reads or admin escalation.