The two apps
First Six is two front-end applications over one database. Understanding that split is the fastest way to reason about where your integration data lands, who can read it, and why the integration surface looks the way it does.
Two front ends, one backend
There are two user-facing apps:
| App | Who uses it | Talks to |
|---|---|---|
| Student app | Students | Supabase (PostgREST), scoped by RLS |
| Staff console | Wellbeing staff | Supabase (PostgREST) + privileged server routes |
Both are Next.js apps in the same monorepo. There is also a small embed app for the marketing site, but it carries no real data and you can ignore it for integration purposes.
The backend is Supabase: Postgres, exposed over PostgRESTThe auto-generated REST API Supabase puts in front of Postgres. Every request runs as the caller, so row-level security decides what it can see., fronted by row-level security. There is no separate API tier to integrate against. Your data flows in through the sync and SSO endpoints and is read back out through the same RLS-protected tables the apps use.
There is no middle service to call, no GraphQL gateway, no ORM layer you can hook. The database is the API. So the two things you actually integrate with are identity (SSO) and the roster push (SIS sync); everything else is the apps reading RLS-gated tables.
Row-level security is the perimeter
Every table that holds tenant data has RLSRow-level security: Postgres policies attached to each table that filter which rows a given caller can read or write. policies that key off the caller's identity. A student's session can only ever read that student's rows; a staff session can only read rows for cohorts within their institution and scope.
This means there is no "admin" client that quietly sees everything by default. The boundary is enforced in the database, not in application code, so a bug in a route handler cannot leak another tenant's data through it.
// A request carries the user's JWT. PostgREST runs every query as that
// user, so RLS decides what is visible. The app never widens the scope.
const supabase = createServerClient(); // attaches the caller's auth cookie
const { data } = await supabase
.from("check_ins")
.select("week, mood")
.order("week");
// `data` contains only rows RLS permits for this caller. No tenant filter
// in the query. The policy already applied it.How a request actually flows
- The browser holds an HttpOnly session, not a raw token
After SSO, First Six mints a session cookie that JavaScript cannot read. There is no Supabase token sitting in
localStoragefor an XSS to steal. - Calls are proxied server-side
Database and storage calls route through a server proxy that reads the HttpOnly cookie and attaches the bearer token to the upstream Supabase request. The token never touches the client.
- RLS applies, every time
PostgREST runs the query as the authenticated actor, and the table's policies filter the rows. The same query returns different data for different callers, by design.
The service-role boundary
A few operations legitimately need to cross the RLS line: a webhook writing on behalf of a student who is not logged in, or a SIS sync creating accounts in bulk. These run server side with the service role key, which bypasses RLS.
The service-role key bypasses every policy, so any route that uses it must re-impose tenant scoping itself, explicitly, on every query. Treat these routes as the highest-risk surface in the system and keep them small. The SIS sync endpoint is one such route.
For SIS sync, the sync secret authenticates the caller, and tenant scoping is enforced in the database against the cohort the caller supplies. The endpoint model is being tightened as part of the SIS rework.
The actor model
Identity has two layers. An auth_user_id is who signed in. An
actorA student or staff record within one institution. A signed-in person maps to exactly one actor per tenant.
(a student or a staff record) is what they are within an institution.
A single human can map to different actors across tenants, but never more than
one actor per tenant. When you integrate, you are almost always creating or
referencing actors: SIS sync provisions student actors, SSO links an
auth_user_id to the right actor at login time. Crucially, SSO links to a
pre-existing actor rather than creating one, so the roster has to be in place
first.
Common questions
Can we query the database directly?
Not as an external integrator. Reads happen through the apps over RLS-gated PostgREST; there is no public database credential handed out. The supported inbound surfaces are SSO and SIS sync. See the public surface.
Where does our institution's data boundary live?
In the database. Every tenant-owned row carries an institution id, and RLS policies key off it on every table, so a cross-tenant read is structurally impossible from an app client.
Do we need a service account?
For SIS sync, yes. A shared secret we issue you, used server-to-server. User-facing access is via SSO, not a service account.
Related
The fastest answer is usually one question away.