The list that returned more than it showed

The worst Power Pages go-live I have been called into looked clean for nine days. A permit application site, one public form, one list where a signed-in applicant could see their own submissions. Four people on the project team had tested it and all four said it worked.

On day ten a resident emailed the service desk a file holding three hundred other applications, with names, home addresses and the mobile numbers the form never displayed. Nothing had been broken into. The Web API had been switched on for that table during development so a developer could test a lookup, and nobody switched it off. The read permission granted to the authenticated users role applied everywhere the table could be reached, not only to the dashboard list.

A Power Pages site is the only part of a Power Platform estate that strangers can reach without an account in your tenant. A mistake inside a managed environment lands in front of people who already hold some access. A mistake on a public site lands in front of anyone with a browser. Here is the review I run before a site faces the internet, and some of it is genuinely fiddly.

Web roles decide what a table permission means

A table permission names a Dataverse table, a set of privileges and an access scope, and it does nothing until you attach it to a web role. Two roles exist on every site from the start. The anonymous role covers every visitor who has not signed in. The authenticated users role covers every signed-in contact, including one who registered ninety seconds ago.

The trap that catches most teams is scope. A permission belongs to the table, never to the page you happened to be looking at when you created it. Grant global read on the account table so one lookup on one form can show a company name, and every list, every form and every Web API call served to that role can now read every account row.

Go through the permissions one row at a time and write down the table, the role, the privileges, the scope and the page that needed it. Global scope on a business table for either built-in role deserves a named reason and a named person who agreed to it. Contact and account scope depend on the visitor's contact being related to the data the way you assumed, so test a record with no parent account.

Page permissions are a separate control and they only decide whether a page renders. A visitor who cannot open a page can still query the table behind it. Page permissions keep a menu tidy. Table permissions keep data private.

A hidden column still comes back in the response

Taking a field off a form takes it out of the rendering and does nothing else. The row still carries the value, and a list with a different view, an OData request to the Web API or a Liquid query in a template will return it. The control has to sit on the data rather than on what the page chooses to draw.

Column-level security in Dataverse is the mechanism for that, and it is fiddly. Profiles are set per column and per user or team, the site's application user has to be covered correctly, and a misconfiguration looks perfectly fine on screen. The blunter alternative survives contact with a real project better. Keep the columns you would hate to publish on a table the site cannot read at all, and reach them from server logic in a cloud flow.

Either way, verify by querying rather than by looking. Request the row through the Web API using a session that holds only a visitor's roles, and read the response. If the column is in there, the control is not doing its job, whatever the form displays. Lists need the same treatment, because a view is easy to change six months later.

Lookups and attachments carry access you never granted

A permitted record will happily expose an unpermitted one through a relationship. A case scoped to the signed-in contact stays safe until the form shows the customer name from the account lookup, somebody notices a blank field, and the fix is global read on account. The record a visitor may see has become a route into a table nobody reviewed.

Check every lookup on every form the site serves, and check what the lookup control itself can search. A lookup that can browse all account rows hands a visitor your customer list whether or not they ever open a single record. Subgrids need the same pass, because a subgrid is a list and a list runs on a table permission of its own.

Notes and files are the ones I find most often. Attachments live on the notes table, and teams grant read on notes so applicants can open their own documents, which publishes every note in the environment including the internal ones staff typed about those same applicants. File columns and document locations fail differently, because storage access rarely follows the record permission. Go and see what a visitor can download.

Registration settings decide who gets a contact record

Open registration means any stranger can create a contact row in your production environment and immediately hold the authenticated users role. Everything you decided about that role now applies to people you have never heard of. Reasonable for a public sign-up site, wrong for a partner portal, and the setting looks identical either way, so decide it on purpose.

Invitation-only registration is the safer default for anything with named customers. Invitations carry a code, an expiry and a redemption limit, and all three are worth setting rather than accepting. Check that a redeemed invitation cannot be redeemed again by whoever was forwarded the email.

Then the identity question, where these sites age badly. A contact links to a sign-in through an external identity record holding the issuer and the subject from the provider. Change provider, move tenant or let someone change the address they sign in with, and that link either breaks or, worse, a fresh sign-in matches on email and lands on another person's contact. Email is a contact detail and never an identifier, which is the argument in how external identity should be wired up.

An admin session is the worst way to check a public site

Never verify a public site from the browser profile you administer it in. Your session carries a Dataverse user with privileges no visitor will ever have, plus maker cookies and cached role assignments that make broken permissions look correct. Use a different browser with no session, then repeat the walkthrough on a freshly registered account holding only what a customer gets.

Site visibility is the setting people forget in both directions. A site left public through development gets indexed by search engines before anyone remembers to restrict it. A site meant to be public sits private for a week after launch while support fields the calls. Check the current value against what the business asked for, and record who agreed it.

The Web API is the other setting to check cold. It is enabled per table through site settings, and those settings travel in a solution import from a developer environment where somebody switched it on for a reason that no longer applies. List every table with the Web API enabled, name the feature that needs each one, and turn off the rest. Then run the pass below.

  • Every table permission written down with its web role, privileges, scope and the page that needs it.
  • No global read on a business table for the anonymous or authenticated users role without a named owner.
  • Web API enabled only for tables you can name, with the field list restricted rather than left open.
  • Each sensitive column requested through a query with no admin session and confirmed absent.
  • Every lookup and subgrid on a public form checked for what it can search and return.
  • Notes, file columns and document locations reviewed against the records they hang from.
  • Registration mode confirmed, with invitation codes carrying an expiry and a redemption limit.
  • Site visibility set on purpose, with the decision and the person who made it recorded.
  • A named owner for disabling a contact when someone leaves the customer organisation.
  • One walkthrough from a private window with no session, then one from a brand new account.

Put a date on the second pass

One review before go-live will not hold, because the surface keeps moving. Every new page and every solution import carrying a site setting or a table permission changes what a stranger can reach. Fold the anonymous query test into the release process so it runs on the way to production rather than after a phone call, in the spirit of governance that keeps low-code moving.

Tomorrow morning, before you sign into anything, open the production site in a private window and request the three tables you would least like a stranger to read. If any of them answers, you have the first item on your list and a reason to talk about the launch date.