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).
Sessions on the server
Section titled “Sessions on the server”- 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 fromprocess.envat 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).
The double guard
Section titled “The double guard”Every server function that touches tenant data is guarded twice:
- A route guard, for the user experience. The
_authedlayout’sbeforeLoadsends signed-out browsers to/login, usinggetUser()fromsrc/lib/auth.ts. It doesn’t protect anything: a server function can be called directly. - Middleware plus an authz helper, for security. The function declares
authMiddleware(validates the session and setscontext.user, or redirects to/login) ororgMiddleware(the same, for org-scoped work). Then its handler calls a helper fromsrc/server/lib/authz.tsbefore 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_idand needs an org-level role, look up the map’s organisation on the server and check that. Never trust anorg_idpassed alongside amap_id. - Some functions that act on a whole organisation use
authMiddlewarerather thanorgMiddleware:deleteOrg,renameOrg,getOrgandgetOrgBySlug, among others. The reads rely on RLS. See Server functions.
RLS or the service role
Section titled “RLS or the service role”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. |
Sign-in flows
Section titled “Sign-in flows”/loginoffers 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/confirmhandles links in Auth emails:{{ .SiteURL }}/auth/confirm?token_hash=…&type=…. Its loader calls a server function that runsverifyOtp(setting the session cookies), then redirects to/dashboard, or to/loginif it fails. It accepts any email OTP type: signup confirmation, magic link, invite./auth/callbackexchanges an OAuthcodefor a session (exchangeCodeForSession) and redirects the same way. No OAuth provider is set up; adding one is Supabase configuration only./logoutsigns out in its loader and redirects to/.
/auth/confirm and /auth/callback work before the app is set up (see
First-run setup).
Invitations
Section titled “Invitations”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_membertrigger). - 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. removeMemberandchangeMemberRolerefuse to leave an organisation with noorg_admin(assertNotLastOrgAdmininworkspace.ts). This is application code, not an RLS policy: the policies would let a lone admin demote themselves.
API keys
Section titled “API keys”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.