Documentation
Authentication

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.ts which 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:

  1. Reads cookies from the incoming request.
  2. Calls supabase.auth.getUser() to verify and refresh the session.
  3. If there is no user AND the path is not in the allowlist, redirects to /login.
  4. Otherwise, passes the request through with refreshed cookies.

The allowlist of unauthenticated paths today is:

ts
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
}
Why /api/test and /api/metrics/external?
  • /api/test/* are dev/debug endpoints, hardcoded to Denver. Safe to leave open in non-prod environments.
  • /api/metrics/external is 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

1
User hits a protected URL
Middleware sees no session, redirects to /login.
2
User enters email + password
The /login page calls supabase.auth.signInWithPassword() via the browser client.
3
Supabase sets cookies
The session is stored in cookies (sb-access-token, sb-refresh-token).
4
Redirect to /dashboard
Successful login navigates to the dashboard. Middleware sees the user, allows the request.
5
Server reads role
The dashboard server component queries profiles.role for the logged-in user. Role determines the default tab and what is editable.

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.

sql
-- 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:

ts
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:

PathRoleHow
ManualadminTeam lead invites via Supabase Auth dashboard, then UPDATEs profiles.role to admin.
App invite flowclientAdmin 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.