Skip to content

Auth and permissions

Supabase Auth manages identities (auth.users). @supabase/ssr keeps the session in cookies, so server functions and route loaders can read it. Display data lives in app.profile (see Data model).

  • Use supabase.auth.getClaims(), which checks the JWT signature every time.
  • Never use getSession() on the server. It reads the cookie without checking it. Nothing enforces this, so watch for it in review.
  • Get a fresh client per request. getSupabaseServerClient() (src/lib/supabase/server.ts) reads and writes the current request’s cookies. Never keep a client across requests.
  • getSupabaseServiceRoleClient() is a plain client with the secret key. It reads the key from process.env at call time, so the key never ends up in a browser bundle.

When SUPABASE_BRANCH_REF is set, both clients send an x-supabase-branch header. It’s always unset today (see The shared dev database).

Every server function that touches tenant data is guarded twice:

  1. A route guard, for the user experience. The _authed layout’s beforeLoad sends signed-out browsers to /login, using getUser() from src/lib/auth.ts. It doesn’t protect anything: a server function can be called directly.
  2. Middleware plus an authz helper, for security. The function declares authMiddleware (validates the session and sets context.user, or redirects to /login) or orgMiddleware (the same, for org-scoped work). Then its handler calls a helper from src/server/lib/authz.ts before touching data:
Helper Checks
assertOrgMember(orgId) The caller is a member of the organisation.
assertOrgRole(orgId, roles) The caller has one of the roles in the organisation, or is a system admin.
assertMapAccess(mapId) The caller can reach the map: they’re a member of the organisation that owns it.
assertSystemAdmin() The caller is a system admin.

orgMiddleware can’t check membership itself: the organisation or map id is in the handler’s validated input, which middleware can’t see. Each helper uses its own RLS-scoped client, so auth.uid() in the SQL behind it is the caller.

  • When a function takes a map_id and needs an org-level role, look up the map’s organisation on the server and check that. Never trust an org_id passed alongside a map_id.
  • Some functions that act on a whole organisation use authMiddleware rather than orgMiddleware: deleteOrg, renameOrg, getOrg and getOrgBySlug, among others. The reads rely on RLS. See Server functions.

Use the RLS-scoped client by default, so Postgres policies enforce tenancy. The helper then turns an opaque RLS failure into a clear error.

Use the service role only where RLS can’t work. Then the authz helper is the only boundary, so it must run first, and every write must set map_id (and user_id where relevant) itself.

Where Why the service role
createOrg, initializeApp A new organisation has no members yet to satisfy RLS.
acceptInvitation, listMyInvitations An invitee isn’t a member, so RLS hides the invitation. The email is matched against the caller’s confirmed address, checked through the admin API.
getSignedUploadUrl, Storage reads in imports, the detached ingest task Only the service role can sign uploads. The ingest task runs after the request ends, with no session.
deleteMap Deleting the map’s Storage files.
Building invitation links, getMembers, isEmailAlreadyMember The Auth admin API and app.get_user_emails.
getAppStatus, setup No user exists yet.
POST /api/maintenance/purge A cron with no user. Guarded by MAINTENANCE_PURGE_SECRET.
The public API (/api/v1) Requests carry an API key, not a session. See below.
  • /login offers a password sign-in (signInWithPassword) and a magic link (signInWithOtp). The magic-link function always returns { ok: true }, so the page never reveals whether an email is registered.
  • /auth/confirm handles links in Auth emails: {{ .SiteURL }}/auth/confirm?token_hash=…&type=…. Its loader calls a server function that runs verifyOtp (setting the session cookies), then redirects to /dashboard, or to /login if it fails. It accepts any email OTP type: signup confirmation, magic link, invite.
  • /auth/callback exchanges an OAuth code for a session (exchangeCodeForSession) and redirects the same way. No OAuth provider is set up; adding one is Supabase configuration only.
  • /logout signs out in its loader and redirects to /.

/auth/confirm and /auth/callback work before the app is set up (see First-run setup).

createInvitation and resendInvitation build the invitation’s sign-in link with the Auth admin API’s generateLink. They try type: 'invite' first, then 'magiclink' for an address that already has an account. The link points at /auth/confirm on SITE_URL, or on a PR preview’s own URL. If no link can be made, it falls back to /login. The email goes out through Resend (src/server/lib/email.ts) when the Resend variables are set.

Rules the database and server enforce:

  • One pending invitation per email per organisation (a partial unique index).
  • An address that’s already a member can’t be invited (the invitation_not_existing_member trigger).
  • Accepting needs a confirmed email that matches the invitation, ignoring case, and an invitation under 7 days old.
  • Accepting twice is harmless. An existing membership, or a unique-violation from a concurrent accept, counts as success, and status updates only move rows that are still pending.
  • removeMember and changeMemberRole refuse to leave an organisation with no org_admin (assertNotLastOrgAdmin in workspace.ts). This is application code, not an RLS policy: the policies would let a lone admin demote themselves.

The public API doesn’t use sessions. A request sends Authorization: Bearer mmk_…. A key belongs to one member of one organisation, and only its SHA-256 hash is stored (app.api_key, service role only). Every request re-checks that the member still has a role in a live organisation. Removing or demoting the member, or deleting the organisation, stops their keys at once. See Public API internals.