Let's talk
security

The public quote link reuses the exporter, so it cannot leak anything the PDF would not

A quotation in a bespoke pricing system holds two prices for everything. There is the price the customer sees, and underneath it the internal cost, the gross profit per feature, the margin percentage, and the calculation trace showing which rates went into both.

Adding a public link so a customer can open their quote without logging in means building a second way for that data to leave the system. The first way — the branded document that gets emailed — has been carefully stripped over months of iteration. The second one starts from nothing.

That is the actual risk in a share link, and it is not the token.

Build the payload from something that is already redacted

The decision that mattered was to assemble the public payload from the same context builder the document and spreadsheet exports use, rather than serialising the quotation from the database directly.

That builder already omits internal financials and internal notes, because it feeds an artefact that goes to customers. It has been reviewed for that purpose. Every field it exposes is a field somebody has already decided a customer may see.

Reusing it means the public endpoint inherits every one of those decisions, including the ones nobody remembers making. More importantly it inherits future ones: when a new internal field is added and correctly excluded from the export, the share link excludes it too, automatically, without anyone remembering that a second code path exists.

The alternative — a separate serialiser with its own list of fields to omit — is a list that diverges. Not immediately. About four months in, when someone adds a cost field to the line and updates the export they were told about, and not the endpoint they did not know about.

Redaction should have one implementation and many consumers. If you are writing the second “strip the internal stuff” function in a codebase, one of the two is already wrong or will be soon.

What had to be added on top, and how it was constrained

The share page does more than list lines. It renders the parametric 3D preview of each item, which means the browser needs the raw dimension values and feature selections, plus enough of the catalogue to turn identifiers into the codes the geometry is keyed on.

That is genuinely additional exposure and it was constrained in two ways.

It is scoped to the quote. The lookup maps are built by collecting the identifiers actually referenced by the items on this version, then fetching exactly those rows. A share token cannot pull the catalogue. It can pull the names and codes of the specific things this customer is being quoted for, which they are about to read in the document anyway.

And every one of those lookups is filtered by the owning company as well as by identifier. The identifiers come from the quote, so they are already the right tenant’s — the company filter is redundant, and it stays because redundancy on a tenant boundary is not waste. It is the check that survives the day someone introduces a path where the identifiers are not already trusted.

Revocation and rotation are two operations

The token itself is a long random URL-safe string, generated from a cryptographic source. There is nothing clever there and there should not be.

What is worth copying is that sharing is two pieces of state, not one. There is the token, and there is a separate flag saying whether sharing is currently on.

That separation gives three distinct actions instead of one. Turn sharing off, and the link stops working while the token is preserved — turn it back on and the same link a customer already has resumes working. Regenerate, and a fresh token is minted, permanently breaking every copy of the old link that exists anywhere. Turn on for the first time, and a token is created if none exists.

Those are different intentions. “Pause this while we revise the price” and “that link went to the wrong person, kill it” should not be the same button, and if the only mechanism is deleting the token, the safe action and the convenient action are the same action and people will get it wrong.

Resolution checks all of it together: the token must match, sharing must be enabled, and the quotation must not be deleted. There is no path that looks up a token and then decides what to do about the flag afterwards, because that is exactly where an early return eventually gets added.

A shared link resolves to the quotation’s current version, falling back to the latest one if no current version is marked.

This is a small decision with a real consequence: the link is a live view, not a snapshot. Revise the quote and everyone holding the link sees the revision. For this business that is the desired behaviour — a link superseding itself is the point — but it needs saying out loud, because the opposite assumption is equally reasonable and the customer holding the link cannot tell which one they have.

If a link ever needs to be a snapshot, that is a different feature with a different token per version, not a configuration flag on this one.

The client asked for it to be removed

This is the part worth ending on.

The share link was built, it worked, and on the review call the business asked for it to be taken out of the flow.

Their reason was operational and completely reasonable. They send their customers a document. That document is their format, with their terms, their guarantees and their letterhead, and it is what the customer’s own procurement process expects to receive. Offering a second thing — a link, to a web page, showing the same quote in a different presentation — did not add anything. It created a question the salesperson then had to answer, and confusion in a sales conversation costs more than a preview is worth.

So the most carefully access-controlled surface in the product was switched off because it did not match how the business communicates. The engineering was not wrong. The assumption underneath it was — that a customer wants a richer view, when what the customer wants is the document they are used to, in the format their accounts department files.

I do not regret building it the way it was built, and I would have found that out sooner by asking how they currently send quotes before designing a new way to send them.

The rule

When you add a second export path for data that has a first one, build it on the first one’s redaction rather than beside it. Keep revocation and rotation as separate operations, because they answer different questions. And confirm the channel is wanted before hardening it, because a perfectly secured feature nobody uses is still a feature nobody uses.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch