Security
What leaves your workspace, and what never does.
What we hold and what we drop before the page is built. Written for the person who has to approve this.
What we store
We hold a copy of your records
| Attribute | On our servers | In the viewer’s browser |
|---|---|---|
| An attribute you switched on | Stored | Sent to the viewer |
| An attribute that sorts the view or scopes its rows | Stored | Never sent |
| An attribute your filter tests | Not stored. Attio evaluates the filter | Never sent |
| Everything else in your workspace | Never stored | Never sent |
Exposure
Nothing is visible until you switch it on
A new view publishes nothing. Not a sensible default set, not the first five columns. Zero. The publish screen flags the attributes worth a second look rather than counting them: currency, references to people, email addresses, phone numbers, personal names, and any label that reads like salary, cost, margin, internal or private. Then it asks you to confirm, in those words, before a view goes out with no password, no email gate and no expiry. After it is live, a view whose membership jumps sharply pauses itself and asks you, and a weekly digest tells you what of yours is currently public.
Rendering
Hidden attributes never reach the browser
Only the attributes you made visible are ever serialized to a viewer. Not filtered out in the browser, not collapsed with CSS. It is enforced twice: the repository selects only those keys out of the stored values, and a single projector the read path cannot go around does the serializing. Pagination is where this sort of promise usually leaks, so the cursor is a server-side signed token rather than an encoded sort value. A hidden attribute cannot ride out inside something a reviewer reads as opaque.
Writes
Read-only until you decide otherwise
Every view is read-only until you mark specific attributes editable, per attribute and enforced on our server. By default a viewer’s change is a proposal: it waits in your dashboard beside the value Attio holds now, and we re-read that one attribute at approval rather than writing over something that moved. A view can instead apply edits straight away. Only a viewer with a verified email address can edit one, every change runs the same checks, and the value it replaced is recorded. The target row is resolved through the viewer’s own accessor first, so a row key belonging to a record outside the view returns a 404 byte-identical to the one for a record that does not exist. Write endpoints are rate-limited per session, per view and per tenant.
Access
One way in, and it fails closed
Pick one way in: an unlisted link, a shared password, or an email or domain allowlist. A password and an allowlist can’t both be on, because a password proves someone knows a secret and an allowlist proves who they are. On top of whichever you pick, the link can close on a date or after a number of opens. With an allowlist a viewer types a listed address, and you can make them confirm it by email; showing each viewer only their own rows, or letting their edits apply without approval, turns that confirmation on and keeps it on. A gate never tells a stranger anything: an address that is not on the list gets the same screen as one that is. Viewer sessions and publisher sessions are different cookie namespaces signed with different secrets, and the code that serves published views has no import path to a publisher session, to the database handle, or to Attio. We check that through the whole import graph on every build rather than leaving it to reviewers.
Revocation
Unpublish takes effect on the next request
Published views are never held in a shared cache. No incremental static regeneration, no CDN copy of the HTML, no shared max-age; the rows are cached on our own infrastructure and the view itself is assembled per request. So no request that begins after you pause sees data. The status is read before any row is, and the same transaction signs out every existing viewer session. That costs us a little speed and buys the one property this product cannot do without.
Isolation
One customer cannot reach another
Every application table carries a tenant id and it cannot be null. The application never holds a raw database handle. It holds a repository with the tenant closed over at construction, so no method takes a tenant argument, there is no argument to pass wrong, and an id belonging to another workspace returns not found rather than confirming that it exists. Foreign keys are compound on the row and the tenant together. We do not use Postgres row-level security and would rather say why than quote it as a feature: on our managed database the application role can bypass those policies, so enabling them would produce rules that read as protection and are silently ignored on every query.
Found something? support@publicviews.app
Send enough detail to reproduce it. We will confirm we have it, tell you what we found, and tell you when it is fixed.
Publish your first view