16 KiB
Client Magic Link Auth Replacement
Date: 2026-04-30 Status: Approved Supersedes:
docs/superpowers/specs/2026-04-15-client-magic-links-design.mddocs/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, notauth.usersorpublic.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/clientsauthenticates 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
profiles.is_clientmodels client users as a special subtype of main authenticated users.- Client identity currently depends on
auth.usersandpublic.profiles, which conflicts with the requirement for a separate client auth system. apps/clientscurrently depends on Supabase Auth sessions and browser-side Supabase reads.- The existing invite flow is centered around onboarding a Supabase-authenticated account rather than establishing a standalone client session model.
- The current architecture makes it hard to express a clean separation between collaborator auth and client auth.
Goals
- Remove
profiles.is_clientas 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/clientsto 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
BetterAuthis 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.authfor 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
- An admin invites a client email to a tablo.
- The backend upserts a client identity and ensures a client access grant exists.
- The backend creates a one-time magic-link record and emails the link.
- The client clicks the link.
- The backend validates and consumes the link, then sets a persistent JWT cookie.
apps/clientsuses that cookie for authenticated access to backend APIs.- 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)
public.client_magic_links
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_issuedlogin_link_issuedmagic_link_consumedlogin_failedlogout
Session Model
Cookie shape
The backend issues a stateless JWT session cookie after a successful magic-link exchange.
Recommended cookie settings:
HttpOnlySecureSameSite=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_idemailtype:client_sessioniatexp
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
- A collaborator in
apps/maininvites a client email to a specific tablo. - The backend normalizes the email.
- The backend upserts a row in
public.clients. - The backend ensures an active
client_accessrow exists for that(client_id, tablo_id). - The backend creates a
client_magic_linksrow withpurpose = 'invite'. - The backend emails a one-time magic link pointing to a backend exchange endpoint or a portal route that immediately exchanges the token.
- When consumed successfully, the link is marked used and cannot be reused.
First access
- The client clicks the invite link.
- The backend validates:
- the token signature or identity
- the existence of the backing
client_magic_linksrow - not expired
- not already consumed
- The backend sets the client session cookie.
- The backend marks the link consumed.
- The browser is redirected into
clients.xtablo.com, ideally to the invited tablo route.
Self-serve login flow
- The client visits the client login page.
- The client enters their email address.
- The backend returns a neutral success response regardless of whether access exists.
- If the email maps to a client with active access, the backend creates a
purpose = 'login'magic link and sends the email. - The client clicks the link and goes through the same exchange path as an invite.
Logout
- The client chooses logout in the portal.
- The backend clears the session cookie.
- 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:
- verify the JWT cookie
- load the client identity
- check requested resource access against
client_access - 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_clientchecks in middleware and frontend routing - remove obsolete client invite/setup code that depends on Supabase Auth
- drop
profiles.is_clientwhen rollout is complete
Security And Failure Handling
Magic-link rules
- 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 minutesto1 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:
- source IP
Cookie failure handling
If session verification fails:
- clear the cookie
- return an unauthorized response or redirect to the client login page
Link reuse handling
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
BetterAuthcould eventually help with
Recommendation
Proceed with a custom backend-owned client auth system based on:
public.clientspublic.client_accesspublic.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.