xtablo-source/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md
2026-04-30 18:21:38 +02:00

16 KiB

Client Magic Link Auth Replacement

Date: 2026-04-30 Status: Approved Supersedes:

  • docs/superpowers/specs/2026-04-15-client-magic-links-design.md
  • docs/superpowers/specs/2026-04-18-client-password-invite-flow-design.md

Overview

Replace the current profiles.is_client model and Supabase-auth-based client portal flow with a fully separate client authentication system owned by the application backend.

The new model is:

  • client identity lives in public.clients, not auth.users or public.profiles
  • client access to tablos lives in a dedicated access table, separate from collaborator access
  • invitation and login both use magic links sent by email
  • the magic link is a one-time identity proof
  • successful exchange sets a persistent stateless JWT session cookie
  • apps/clients authenticates against backend-owned cookies and backend APIs, not browser Supabase Auth sessions

This keeps Supabase as the database, but removes Supabase Auth from the client-portal trust model.

Problem Statement

The current implementation couples client access to the main authentication system too tightly.

Current issues

  1. profiles.is_client models client users as a special subtype of main authenticated users.
  2. Client identity currently depends on auth.users and public.profiles, which conflicts with the requirement for a separate client auth system.
  3. apps/clients currently depends on Supabase Auth sessions and browser-side Supabase reads.
  4. The existing invite flow is centered around onboarding a Supabase-authenticated account rather than establishing a standalone client session model.
  5. The current architecture makes it hard to express a clean separation between collaborator auth and client auth.

Goals

  • Remove profiles.is_client as the source of truth for client identity
  • Introduce a separate client identity table in the public schema
  • Support one global client identity per email across all tablos
  • Use email magic links for both invitations and later self-serve login
  • Exchange a valid link for a persistent stateless JWT session cookie
  • Keep client access independent from Supabase Auth tables and sessions
  • Move apps/clients to backend-authorized APIs instead of direct browser Supabase data access

Non-Goals

  • Keeping client users represented in auth.users
  • Using Supabase Auth sessions for the client portal
  • Building immediate session revocation into v1
  • Supporting self-service signup without invitation
  • Embedding the complete authorization graph inside the session JWT

Approved Product Decisions

The following decisions were explicitly approved during brainstorming:

  • client auth is separate from Supabase Auth
  • the invite flow uses a magic link
  • successful link exchange results in a persistent browser session
  • client identity is global by normalized email, not scoped per tablo
  • the session mechanism starts simple with a stateless JWT cookie
  • access revocation does not need to terminate active sessions immediately
  • when a session expires, clients can re-enter their email and receive a new login link
  • BetterAuth is not required for the first version

Chosen Approach

Implement a custom minimal client-auth layer in the backend instead of introducing BetterAuth in v1.

The backend owns:

  • client identity records
  • client access grants
  • magic-link issuance and one-time consumption
  • session cookie creation and verification
  • authorization checks for client routes and client data

BetterAuth remains a possible future evolution if richer auth lifecycle features become necessary, but it is not part of the first implementation.

This approach is recommended because it fits the current codebase better than introducing a second auth framework while apps/clients is still being migrated away from direct Supabase browser access.

Architecture

Core boundary

There are now two distinct auth systems:

  • collaborator auth for app.xtablo.com
  • client auth for clients.xtablo.com

They must not share session mechanisms or identity tables.

Trust model

For the client portal:

  • the browser does not hold a Supabase Auth session
  • the browser does not use supabase.auth for login state
  • the browser does not directly query protected client data from Supabase
  • the backend verifies the client cookie and performs authorization on every protected client API request

High-level flow

  1. An admin invites a client email to a tablo.
  2. The backend upserts a client identity and ensures a client access grant exists.
  3. The backend creates a one-time magic-link record and emails the link.
  4. The client clicks the link.
  5. The backend validates and consumes the link, then sets a persistent JWT cookie.
  6. apps/clients uses that cookie for authenticated access to backend APIs.
  7. When the cookie expires, the client can request a fresh login link by email.

Data Model

public.clients

One row per normalized email.

Suggested columns:

Column Type Notes
id uuid or bigint PK Stable client identity
email text Original or canonical email
normalized_email text Lowercased and normalized, unique
first_name text nullable Optional
last_name text nullable Optional
phone text nullable Optional
last_login_at timestamptz nullable Updated on successful login
created_at timestamptz Default now()
updated_at timestamptz Default now()

Required constraints:

  • unique index on normalized_email

public.client_access

Source of truth for which tablos a client may access.

Suggested columns:

Column Type Notes
id bigint PK
client_id FK -> clients.id
tablo_id FK -> tablos.id
granted_by FK -> profiles.id Collaborator/admin who granted access
granted_at timestamptz Default now()
revoked_at timestamptz nullable Null means active
created_at timestamptz Default now()

Required constraints:

  • one active access row per (client_id, tablo_id)

Tracks invite and login links and enforces one-time use.

Suggested columns:

Column Type Notes
id bigint PK
client_id FK -> clients.id
email text Denormalized for audit/debugging
purpose text invite or login
token_hash text nullable Preferred if storing hashed token
jti text nullable Alternative lookup key if JWT carries jti
redirect_to text nullable Optional target route after exchange
expires_at timestamptz
consumed_at timestamptz nullable One-time-use marker
created_by uuid nullable Admin profile id for invite flow, null for self-serve login
created_at timestamptz Default now()

Notes:

  • even if the magic link payload is JWT-based, the backend still stores a row
  • this table is required for one-time use, expiry enforcement, and auditability

Optional audit table

public.client_auth_events is optional in v1 but recommended.

Suggested event types:

  • invite_issued
  • login_link_issued
  • magic_link_consumed
  • login_failed
  • logout

Session Model

The backend issues a stateless JWT session cookie after a successful magic-link exchange.

Recommended cookie settings:

  • HttpOnly
  • Secure
  • SameSite=Lax
  • scoped to the client portal domain

Suggested TTL:

  • start with 7 days

JWT claims

The session JWT should stay minimal and represent identity, not the full access graph.

Suggested claims:

  • sub: client_id
  • email
  • type: client_session
  • iat
  • exp

Optional future claim:

  • access_version

Revocation behavior

This design intentionally accepts eventual revocation.

If a client loses access while holding a still-valid cookie:

  • the cookie may remain structurally valid until expiry
  • a new login link should not be issued once the client has no active access
  • authorization checks for protected resources should still evaluate access against client_access

This means the design does not need a server-stored session table to invalidate cookies early. The cookie lifecycle and the resource-authorization lifecycle remain separate.

The design does not require a client_sessions table in v1.

End-To-End Flows

Admin invite flow

  1. A collaborator in apps/main invites a client email to a specific tablo.
  2. The backend normalizes the email.
  3. The backend upserts a row in public.clients.
  4. The backend ensures an active client_access row exists for that (client_id, tablo_id).
  5. The backend creates a client_magic_links row with purpose = 'invite'.
  6. The backend emails a one-time magic link pointing to a backend exchange endpoint or a portal route that immediately exchanges the token.
  7. When consumed successfully, the link is marked used and cannot be reused.

First access

  1. The client clicks the invite link.
  2. The backend validates:
    • the token signature or identity
    • the existence of the backing client_magic_links row
    • not expired
    • not already consumed
  3. The backend sets the client session cookie.
  4. The backend marks the link consumed.
  5. The browser is redirected into clients.xtablo.com, ideally to the invited tablo route.

Self-serve login flow

  1. The client visits the client login page.
  2. The client enters their email address.
  3. The backend returns a neutral success response regardless of whether access exists.
  4. If the email maps to a client with active access, the backend creates a purpose = 'login' magic link and sends the email.
  5. The client clicks the link and goes through the same exchange path as an invite.

Logout

  1. The client chooses logout in the portal.
  2. The backend clears the session cookie.
  3. No DB session cleanup is required in v1.

Authorization Model

Principle

The cookie proves client identity. Authorization remains server-side.

The session JWT must not be treated as the source of truth for tablo-level permissions.

Per-request behavior

On each protected client API request:

  1. verify the JWT cookie
  2. load the client identity
  3. check requested resource access against client_access
  4. allow or deny based on active access

Why not store access in the JWT

Embedding tablo access claims in the session token would make revocation and multi-tablo growth harder to manage. It is simpler and safer to keep the JWT small and query client_access during authorization.

API Design Implications

New backend responsibilities

The backend needs a dedicated client-auth surface, for example:

  • invite a client to a tablo
  • request a login link by email
  • exchange a magic link for a cookie
  • clear a client session cookie
  • fetch current client identity
  • fetch client-visible tablos and tablo-scoped resources

Client app data access

apps/clients should move from this model:

  • browser Supabase session
  • browser supabase.from(...)

to this model:

  • browser requests to backend APIs
  • cookie attached automatically by the browser
  • backend performs identity resolution and authorization

This is the most important architectural migration in the proposal.

Frontend Impact On apps/clients

Login UI

The login page becomes an email-only form for magic-link requests.

Behavior:

  • submit email
  • show neutral confirmation state
  • no password field
  • no self-service signup

Session bootstrap

The link target should perform an exchange step and then redirect into the client portal.

Possible shapes:

  • backend endpoint directly exchanges and redirects
  • frontend route receives the token and immediately calls the backend exchange endpoint

The backend-driven exchange-and-redirect path is preferred because it reduces client-side token handling.

Auth gate

apps/clients should no longer check Supabase session state.

Instead it should:

  • call a backend "current client" endpoint or equivalent
  • treat cookie-backed success as authenticated
  • redirect unauthenticated users to the email login page

Protected data

Every client-visible page should be backed by backend endpoints that enforce client_access.

Migration Strategy

Phase 1: Schema introduction

  • add public.clients
  • add public.client_access
  • add public.client_magic_links
  • optionally add public.client_auth_events

Phase 2: Auth backend

  • add magic-link issuance endpoints
  • add exchange endpoint
  • add cookie verification middleware
  • add logout endpoint

Phase 3: Client portal backend surface

  • add backend endpoints for client-visible data currently read directly from Supabase in apps/clients
  • enforce client authorization there

Phase 4: Client portal frontend migration

  • replace Supabase Auth session handling with cookie-backed auth
  • replace direct Supabase browser queries with backend API calls
  • update logout behavior

Phase 5: Invite flow switch

  • stop creating or depending on profiles.is_client
  • stop creating client auth users in Supabase Auth
  • switch admin invite actions to the new client-auth flow

Phase 6: Cleanup

  • remove old is_client checks in middleware and frontend routing
  • remove obsolete client invite/setup code that depends on Supabase Auth
  • drop profiles.is_client when rollout is complete

Security And Failure Handling

  • magic links must be one-time use
  • magic links must expire quickly relative to the session cookie
  • expired, missing, or consumed links must fail clearly

Suggested starting TTL:

  • invite/login link: 15 minutes to 1 hour

Login enumeration resistance

Self-serve login initiation must return a neutral response such as:

If this email can access the client portal, a connection link has been sent.

This avoids exposing which emails exist in the client system.

Rate limiting

Rate limit login-link issuance by:

  • email
  • source IP

If session verification fails:

  • clear the cookie
  • return an unauthorized response or redirect to the client login page

If a link was already consumed:

  • show a clear invalid-link state
  • offer a way to request a fresh login link

Testing Strategy

Database tests

  • unique normalized email in clients
  • unique active access for (client_id, tablo_id)
  • one-time consumption behavior for client_magic_links

API tests

  • inviting a new email upserts a client and grants access
  • inviting an already-known email reuses the same global client identity
  • self-serve login only issues a link when active access exists, while still returning a neutral response shape
  • exchange endpoint consumes a valid link exactly once
  • expired or already-consumed links are rejected
  • protected client endpoints require a valid cookie
  • protected client endpoints enforce client_access
  • logout clears the cookie

Frontend tests

  • login page sends email-link requests and shows a neutral success state
  • invalid or expired links show the right recovery path
  • successful exchange redirects to the intended tablo
  • authenticated portal routes rely on backend-authenticated cookie state
  • logout returns the user to the login page

Trade-Offs

Why this is simpler

  • no second auth framework in v1
  • no session table in v1
  • no dependency on Supabase Auth lifecycle for client users
  • clean data separation between collaborators and clients

What this defers

  • immediate revocation of active sessions
  • device/session inventory
  • logout-all-devices
  • richer account security features that a framework like BetterAuth could eventually help with

Recommendation

Proceed with a custom backend-owned client auth system based on:

  • public.clients
  • public.client_access
  • public.client_magic_links
  • one-time magic links for invite and login
  • stateless JWT session cookie
  • backend-only authorization for client portal resources

This is the cleanest replacement for profiles.is_client and best matches the approved product direction.