Auth Flow
How users authenticate, how sessions are refreshed on every request, and how the role check propagates.
Basics
Authentication is Supabase Auth. Specifically:
- Email + password is the only enabled provider for end users.
- Sessions are stored in HTTP-only cookies via
@supabase/ssr. - Every request passes through
src/middleware.tswhich refreshes the session and gates routes.
The middleware gate
src/middleware.ts matches every URL except static assets and images. It calls updateSession(request) from src/lib/supabase/middleware.ts, which:
- Reads cookies from the incoming request.
- Calls
supabase.auth.getUser()to verify and refresh the session. - If there is no user AND the path is not in the allowlist, redirects to
/login. - Otherwise, passes the request through with refreshed cookies.
The allowlist of unauthenticated paths today is:
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth') &&
!request.nextUrl.pathname.startsWith('/documentation') &&
!request.nextUrl.pathname.startsWith('/api/test') &&
!request.nextUrl.pathname.startsWith('/api/metrics/external')
) {
// redirect to /login
}/api/test/*are dev/debug endpoints, hardcoded to Denver. Safe to leave open in non-prod environments./api/metrics/externalis the n8n push endpoint, authenticated by a bearer token (N8N_WEBHOOK_SECRET) instead of a Supabase session./documentation(this site) is public for onboarding convenience.
Login flow
The profiles table
Supabase Auth maintains auth.users. We mirror it into public.profiles via a database trigger so we can attach role and metadata to every user.
-- profiles table (simplified)
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
email text,
role text check (role in ('admin', 'client')) default 'client',
client_id uuid references public.clients (id), -- only set for client-role users
created_at timestamptz default now()
);For a client-role user, profiles.client_id binds them to exactly one client. The dashboard reads this and forces the route to that client's page.
Reading the user server-side
Every server component or route handler that needs the current user uses the server Supabase factory:
import { createClient } from '@/lib/supabase/server'
export default async function Page() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Read role
const { data: profile } = await supabase
.from('profiles')
.select('role, client_id')
.eq('id', user!.id)
.single()
// ... use role
}Inviting a new user
There are two ways a user lands in the system:
| Path | Role | How |
|---|---|---|
| Manual | admin | Team lead invites via Supabase Auth dashboard, then UPDATEs profiles.role to admin. |
| App invite flow | client | Admin clicks "Invite Client" on a client page. /api/auth/invite-client creates a Supabase user with profiles.role = client and profiles.client_id set, then sends a magic link. |
Logout
The Sidebar component has a logout button that calls supabase.auth.signOut() via the browser client. The middleware then sees no session on the next request and redirects to /login.