Power Pages ships with an Anonymous Users web role and a permissive default. Most teams flip on “public site” for marketing, never look back, and end up exposing Dataverse rows via the portal Web API or unauthenticated Liquid tags. The exposure is not theoretical. We have seen production sites leaking contact PII through a single misconfigured table permission.
The default state nobody audits
When you provision a site, two web roles exist: Anonymous Users and Authenticated Users. The anonymous role has zero table permissions by default. That sounds safe. It is not. The instant someone adds a {% fetchxml %} Liquid tag or enables the Web API for a table without scoping, the anonymous role inherits whatever the page-level controls allow. Page-level access in Power Pages is permissive — table permissions are the real gate.
Three exposure paths
- Liquid
entitylistandentityformcontrols that render Dataverse rows server-side. - Web API endpoints under
/_api/<entity>enabled per table via site settingWebapi/<entity>/enabled. - Custom
fetchxmlblocks in page templates that bypass table permissions entirely if the table permission is not bound.
The Web API path is the most dangerous because it returns JSON, gets indexed by crawlers, and looks innocuous in network traces.
Audit your site in 10 minutes
Run this FetchXML against your environment to list every table permission scoped to the Anonymous web role:
<fetch>
<entity name="adx_entitypermission">
<attribute name="adx_entityname" />
<attribute name="adx_scope" />
<attribute name="adx_read" />
<attribute name="adx_create" />
<attribute name="adx_write" />
<attribute name="adx_delete" />
<link-entity name="adx_entitypermission_webrole"
from="adx_entitypermissionid"
to="adx_entitypermissionid">
<link-entity name="adx_webrole" from="adx_webroleid" to="adx_webroleid">
<filter>
<condition attribute="adx_anonymoususersrole"
operator="eq" value="1" />
</filter>
</link-entity>
</link-entity>
</entity>
</fetch>
Anything in that result set is internet-readable. Treat the output as a leak report, not an inventory.
Scope is the only control that matters
Table permission scope determines blast radius:
- Global: anyone with the role sees every row. Almost never appropriate for anonymous.
- Contact: rows owned by the current portal contact. Requires authentication, so irrelevant for anonymous.
- Account: rows linked to the current contact’s account. Same caveat.
- Parent: rows linked via a parent permission. This is the only scope that makes sense for anonymous, paired with a parent record like a published
cr_articlerow. - Self: only the contact record itself. Authenticated only.
If your anonymous table permission is Global with Read = Yes, you are publishing that table to the internet. Period.
Web API enablement is per-table
A site setting controls Web API exposure. The pattern is:
Webapi/contact/enabled = true
Webapi/contact/fields = firstname,lastname,emailaddress1
The fields setting is column-level. Without it, every column is exposed when the table is enabled. The number of times we have seen mobilephone and birthdate leak through this is depressing.
Cache poisoning via Liquid
Power Pages caches Liquid output aggressively. If an authenticated user renders a page that leaks PII into the HTML, then the cache key is not scoped to the user, the anonymous next-hit gets the cached HTML with the PII baked in. The mitigation is to set Header/Cache-Control/private on settings for pages that render user-scoped data, or render those sections client-side via the Web API with explicit auth.
Form CAPTCHA is not access control
Adding CAPTCHA to an entity form stops bots, not attackers. CAPTCHA runs before form submission, but the form’s underlying table permission still governs whether the row is created. If your anonymous role has Create on lead, an attacker can hit the Web API directly and skip the form entirely. CAPTCHA on the form template never sees that request.
Site visibility is not isolation
The “Site visibility: public” vs “private” toggle gates whether the site is indexable and reachable without an invitation. It does not change table permissions. A private site with bad anonymous permissions still leaks data to anyone with the URL. Treat the toggle as discoverability control only.
The hardening checklist
- List every anonymous table permission with the FetchXML above.
- For each one, justify the scope in writing. If you cannot, delete it.
- Audit
Webapi/<entity>/enabledsite settings. Disable any that are not actively used. - Pair every Web API enablement with an explicit
fieldswhitelist. - Add Content Security Policy headers via the
ContentSecurityPolicysite setting to block exfiltration vectors. - Enable diagnostic logging and review
mspp_logsfor anonymous Web API patterns weekly. - Run Dataverse security role anti-patterns against your portal app user — anonymous still operates under an app user identity.
Pixel notes
Anonymous-only pages should visually telegraph their state. We use a discreet “Public page” badge in the page header on portal designs so editors never confuse anonymous content with member content. The badge is a <span> styled via a theme token and toggled by {% if user == null %} in the page template. Cheap, but it has caught at least two pre-launch misconfigurations.
Bottom line
- Anonymous in Power Pages is not “off by default” — it is “off until the next checkbox click”.
- Table permissions, not page settings, are the real boundary.
- Web API enablement plus default column exposure is the most common leak path.
- Audit, justify, whitelist columns, and treat the cache as a leak surface.
- Site visibility is discoverability, not security.