PublicViews

Security

Publishing CRM data should feel risky. Here is what we do about it.

You are not publishing your own information. You are publishing something a client, an investor, or a candidate handed you. Nobody wants to find out at a board meeting that a toggle was set wrong. So the product is built to make over-sharing hard on purpose, and we would rather show you the mechanics than show you a badge.

The uncomfortable part, first

We hold a copy of your records.

We cache the records behind your published view, encrypted at rest and partitioned per workspace. That cache is how a page loads without calling Attio every time somebody opens it, and it is also the reason a rush of traffic to your client’s page never touches your Attio rate limit. It is the part a careful buyer would have gone looking for, so we would rather say it here than have you find it in a subprocessor list.

What we store is not the record. It is a projection: the attributes your views need, and nothing else. Written as a rule we can be held to, nothing outside that set is ever written to our database.

AttributeOn our serversIn the viewer's browser
An attribute you switched onStoredSent to the viewer
An attribute you marked editableStoredSent. An editable attribute is always a visible one
The attribute a board groups byStoredSent. A column header is a value the viewer reads
The attribute a page sorts onStoredNever sent
The attribute that decides which viewer sees which rowsStoredNever sent
A linked recordStored as the reference, plus its display nameSent as the display name, and only where you made the linking attribute visible
An attribute your filter testsNot stored at all. Attio evaluates the filterNever sent

Two rows there are the ones worth reading twice. We store the attribute a page sorts on and the attribute that decides which viewer sees which rows, and we never send either to anybody. The reason is arithmetic. To show one person only the rows carrying their email address, that address has to sit somewhere we can compare it against, and you almost certainly do not want it printed on the page.

So the exposure preview lists those attributes too, under used to decide who sees which rows, never displayed. You should never be surprised that we hold an attribute you did not publish.

Attio keeps the original either way. We never hold the only copy of anything, which is also the honest answer to what happens if we go away: a link stops working and you are back where you started, with every record still in your CRM.

Before you publish

Nothing is visible until you switch it on.

A new view publishes nothing. Not a sensible default set, not the first five columns. Zero. You switch on each attribute you want a viewer to see, one at a time, and rename it if the internal label is not the one you would use with a client.

Then, at publish, we render the page. Not a summary of your settings and not a mock: the same renderer your viewer gets, with your real records and only the attributes you chose. You check it against reality rather than against a configuration screen, which is a different question from the one a settings screen answers.

The same screen argues with you in four specific places, on purpose:

  • It 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.
  • A source that matches zero records, and a source with no filter at all, each need an explicit confirmation before they publish.
  • The filter builder says out loud that is not also matches records where the field is empty. A filter reading “stage is not Lost” publishes every record with no stage set, which is rarely what the person writing it pictured.
  • Publishing a page with no password, no email gate and no expiry asks you to confirm that, in those words, because a link that works for anyone who receives it also works for anyone they forward it to.

After it is published, two things keep watching. A view whose membership jumps by half again, and by at least 25 records, pauses itself and asks you. And a weekly digest tells you what of yours is currently public, because the dangerous view is the one published in March and forgotten.

On the wire

Hidden attributes never reach the browser.

The rule the read path is built around: only the attributes you made visible are ever serialized to a viewer. Not filtered out in the browser, not collapsed with CSS. Never sent.

It is enforced twice. The repository selects only those keys out of the stored values, so a hidden value is never loaded into memory on the read path at all, and a single projector function that the read path cannot go around does the serializing. Viewer search runs over the visible attributes only. View source on a published page shows what the page shows.

Pagination is the place 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.

The viewer gets told about the withholding, which is unusual and deliberate: the page carries a strip saying how many attributes they are and are not being shown. Your client knowing there is more is better for you than your client assuming there is not.

Writes

Read-only until you decide otherwise, and then still not automatic.

Every view is read-only until you mark specific attributes editable. That permission is per attribute, off by default, and enforced on our server. The flag in the browser is advisory. The server check is the feature, and a viewer with dev tools open gets the same answer as a viewer without them.

When a viewer does change an editable attribute, it does not reach Attio. It becomes a pending edit in your dashboard. You see the proposed value beside the value Attio holds right now, read at the moment you look, and it is written only when you approve it. There is no setting that skips this and no plan that turns it off.

Underneath that, five things happen to every submitted edit:

  • The target row is resolved through the viewer’s own accessor before anything else. A row key belonging to a record outside that view returns a 404 byte-identical to the one for a record that does not exist.
  • The value is validated against the attribute type before it queues, and validated rather than coerced. A viewer cannot stage something the field would reject.
  • At approval we re-read that single attribute from Attio. If it moved since the viewer proposed the change, we do not write over it. We show you both values and let you decide.
  • Only simple attributes can be made editable in this version: text, number, date, checkbox, and single-select and status, where Attio reports the attribute as writable and it is not multi-select. Multi-select, linked records, locations, currency and ratings are read-only, because getting one of those writes wrong changes more than the record you were looking at.
  • Write endpoints are rate-limited per session, per view and per tenant, and every edit lands in the audit log with a timestamp and, on a gated view, the viewer’s verified email address.

Access

Four locks, and they fail closed.

Publish to an unlisted link. Add a password. Restrict it to named email addresses or to everyone at a domain. Expire it on a date or after a number of opens. Stack as many as the situation deserves, and set read access separately from edit access.

  • A gate never tells a stranger anything. Submitting an address that is not on the list gets the same screen as submitting one that is, because telling somebody their address is not on the list tells them whose is. A view that does not exist and a view that was deleted return the same page.
  • Sign-in links last 15 minutes, are stored only as a hash, and are consumed once in a single atomic step, so a link that a scanner opened is a link that is spent.
  • A viewer’s cookie is scoped to one view’s path, so the browser does not send it to another view at all. That sits underneath a check on the token itself, not instead of it.
  • Viewer sessions and publisher sessions are different cookie namespaces signed with different secrets, and the code that serves published pages has no import path to the publisher session, to the database handle, or to Attio. That is checked through the whole import graph on every build, not left to reviewers.
  • Everything fails in the safe direction. A filter that fails to compile issues zero queries rather than a wider one. An access mode your plan stops permitting becomes more restrictive rather than open: a domain allowlist becomes a password prompt, never a public link.

Turning it off

Unpublish takes effect on the very next request.

Published pages are never stored 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 invalidated in one step; the page itself is assembled per request.

That costs us a little speed and buys the one property this product cannot do without. “Pause takes effect immediately” and “an edge node somewhere may keep serving the old page for a while” cannot both be true, and for a product whose whole pitch is controlled exposure, a page that keeps serving after you hit pause is the worst failure available.

Three ways off, and each one is a defined procedure rather than a delete statement:

  • Pause or unpublish a view. No request that begins after the transaction commits sees data: the status is read before any row is, and the same transaction revokes every existing viewer session. The cached rows are not deleted and do not need to be, because nothing can reach them while the view is down. Your configuration is kept, so you can publish it again.
  • Disconnect a workspace. Every cached record from it is deleted, the stored token is overwritten, and the views built on it are paused rather than removed, so reconnecting does not mean rebuilding. We do not delete your work to punish a disconnect.
  • Delete the account. Every view is paused and audited, every connection is disconnected, and the deletion cascades through every table that carries your tenant id. A check afterwards queries for any row anywhere still carrying it. Erasure has to complete without anyone here intervening, because you are the controller and we are the processor.

Isolation

One customer cannot reach another.

Every application table carries a tenant id and it cannot be null. On top of that, three mechanisms, none of which is trusted on its own:

  • The application never holds a raw database handle. What it holds is a repository with the tenant closed over at construction. No method takes a tenant argument, so 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 id and the tenant id together, so a row that crosses a workspace boundary is a database error rather than a code review question.
  • Build guards fail the build if a query builder appears outside the repository, if a table is added without a tenant column, or if any of the import boundaries above is crossed anywhere in the graph.

We do not rely on Postgres row-level security, and we would rather explain 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. That is worse than not having them. The three mechanisms above are the actual isolation.

Secrets and logs

Two places hold an email address, and both are on purpose.

Attio access tokens are encrypted at rest with AES-256-GCM under a key id, so keys can be rotated without re-consenting every workspace. Attio’s tokens do not expire, which makes a stored one worth more than the usual secret and rotation worth more than the usual effort.

Our logger serializes through an allowlist of key names, so a key nobody thought of is dropped rather than printed. A denylist would fail open on exactly the field nobody anticipated. Passing record values to a log call is a build failure, not a guideline. Viewer IP addresses are stored hashed.

Two tables hold a viewer’s email address and they are the only two permitted to: the verified viewer session, purged aggressively, and the per-viewer access log on Business, kept for that plan’s analytics retention. Both are scoped to your workspace, both are deleted when the view or the account is, and neither value ever reaches a log line or an analytics rollup.

The subprocessor list names every vendor that can reach any of this, and the draft DPA sets out the processor terms. Both are drafts awaiting legal review and say so at the top.

One limit we did not choose

What our Attio app is allowed to ask for.

A single registered Attio app has one fixed set of scopes for every publisher who connects it. There is no way for us to request narrower permissions for a publisher whose views are all read-only, so everyone consents to the same set, including write.

We would rather state that than bury it. Read-only is enforced entirely in our layer: a view with no editable attributes has no code path that writes to Attio at all. The connect screen says the same thing in the same words. If Attio’s authorize endpoint turns out to honour a narrower scope request, this improves and we will say so here.

The gaps

What we do not have.

A security page that only lists strengths is an advertisement. The other half, current as of 20 August 2026.

  • No SOC 2, no ISO 27001, no third-party penetration test. We hold no security certification of any kind and are not going to imply one with a badge or with a phrase standing in for one. When we complete an audit, the report will be named here with its date and its auditor.
  • Per-viewer row scoping is not available yet. It is in the launch build and it is not shipped. Until it is, everyone who can open a view sees every record in that view, and any use that depends on one person not seeing another person’s row should wait for it.
  • Single sign-on for your own team is not built. It is priced on Enterprise and there is no screen behind it today.
  • Everything runs in the United States. The database is in AWS us-east-2. EU data residency is not available.
  • No uptime number. Nothing has been measured, so there is nothing honest to publish. The status page says what it will report once there is something to report.
  • No bug bounty. There is an inbox and a person who answers it.
  • No customer has run a security review of this product yet, because there are no customers yet. If yours is the first, we would rather take the questions than dodge them.

Reporting something

If you find a vulnerability, email help@publicviews.app with enough detail to reproduce it. We will confirm we have it, tell you what we found, and tell you when it is fixed. We will not argue about whether it counts.

Read the page as your client, before you send it.

Connect Attio, publish one view free, and check the exposure preview against reality. Nothing is published until you publish it.

Publish your first view