Client Portal

Multi-tenant client portal with magic-link auth, scoped document exchange, and a status timeline driven by events.

Status

Reference build

Stack

Next.jsTypeScriptPostgreSQL RLSSupabase Auth

Date

Jun 15, 2026

The Problem

A client portal is a multi-tenant system sold as a convenience feature. The convenience part ships. The multi-tenant part is usually a where tenant_id = ? clause that every future developer must remember to write. One forgotten clause is the difference between a portal and a data breach.

The failure modes this build exists to handle:

  • TENANT_DATA_LEAK — one client sees another client's records because isolation lives in query discipline instead of the database.
  • EXPIRED_LINK_DEAD_END — a client clicks a week-old email link and lands on an error page with no way forward.
  • FILE_ACCESS_WITHOUT_SCOPE — documents are reachable by URL to anyone holding the URL, indefinitely.
  • STALE_STATUS_STATE — the status a client sees is an editable field someone forgot to update, so the portal reports "in review" three weeks after delivery.

The System

Four entities: tenants, users, documents, and events. Every table that holds tenant data carries a tenant_id column, and PostgreSQL row-level security enforces it below the application:

alter table documents enable row level security;

create policy tenant_isolation on documents
  for all
  using (tenant_id = (select auth.jwt() ->> 'tenant_id')::uuid);

The application still scopes every query. RLS is the floor, not the strategy — it is what stands when the application code is wrong. Why isolation belongs in the database is argued in multi-tenant data isolation.

Auth. Clients sign in with magic links: single-use, 15-minute expiry. An expired or consumed link never dead-ends. It routes to a re-request screen with the email pre-filled, one click from a fresh link. Portal users are occasional users; the flow assumes the link in front of them is old.

Documents. Files live in per-tenant storage prefixes. Downloads never expose storage paths: the client requests a document, the server checks tenant scope and the user's role within that tenant, then issues a signed URL valid for 60 seconds. A leaked URL is a 60-second problem, not a permanent one.

Status timeline. Status is not a column anyone edits. It is derived from an append-only events table — project.submitted, review.started, deliverable.posted. The timeline the client sees is the event history rendered directly, so it cannot drift from what happened: either the event exists or it does not.

The isolation fixture. The test suite seeds two tenants with mirrored data and runs every data-access path twice — once asserting tenant A sees its own rows, once asserting tenant A sees zero of tenant B's rows. A new query path without a two-tenant test does not pass review.

Constraint

The email inbox is the authentication channel. A compromised inbox is a compromised portal account. Short expiry and single-use links narrow the window; they do not remove it.

Design Decisions

RLS in the database, not filters in the ORM. Query-layer filtering fails open: forget the clause and everything leaks. RLS fails closed: forget the policy context and queries return nothing. An empty page is a bug. Another tenant's data is an incident. Tradeoff: policies make query plans harder to read and debugging slower — the two-tenant fixture exists partly to compensate.

Magic links, not passwords. Portal users log in a few times a quarter. Passwords for occasional users mean reset flows, and reset flows are magic links with extra steps and worse failure states. Tradeoff: auth security now equals inbox security, which is stated in the constraints above rather than hidden.

Short-lived signed URLs, not proxied downloads. Proxying every file through the app server gives per-request authorization but puts file throughput on the application. Signed URLs hand transfer to storage infrastructure and keep the authorization decision at issuance. Tradeoff: for its 60-second lifetime, the URL is a bearer token — anyone holding it can fetch the file.

Events, not an editable status field. An editable status is a claim; an event log is a record. Deriving status from events removes the failure mode where a human forgets to update the field. Tradeoff: correcting a mistaken event means appending a correcting event, not editing history. More modeling work, but the history stays honest.

Named first, then built against. The write-up above covers the mechanism for each.

TENANT_DATA_LEAK

EXPIRED_LINK_DEAD_END

FILE_ACCESS_WITHOUT_SCOPE

STALE_STATUS_STATE

Constraint

Magic links expire after 15 minutes and are single-use. Expiry always routes to a re-request screen, never to an error page.

Constraint

Signed file URLs live for 60 seconds. A link copied out of the portal goes stale by design.

Constraint

Row-level security is treated as the isolation floor. Application code is still written and reviewed as if it were absent.
status_timeline
CAPTURE PENDING // status_timeline
document_exchange
CAPTURE PENDING // document_exchange

Need a system like this behind your business? Same protocol, scoped to your failure modes.