PowerApps Portals and Power Pages: Building External-Facing Applications
Introduction
Internal PowerApps serve employees, but Power Pages extend Dataverse to external usersβcustomers, partners, vendors. This guide covers Power Pages architecture, authentication options (anonymous, Azure AD B2C, local accounts), web roles and table permissions for security, Liquid templates for customization, entity forms and lists for data interaction, and deployment best practices.
Power Pages Overview
Evolution from PowerApps Portals
Timeline:
2016: PowerApps Portals released (Dynamics 365 Portals renamed)
2022: Power Pages announced (unified external app platform)
2024: Power Pages design studio (low-code template-based)
Power Pages = PowerApps Portals + modern design studio + AI copilot
Licensing:
Power Pages:
βββ Per page view (authenticated users): $200/month for 100,000 views
βββ Per user (authenticated): $4/user/month
βββ Anonymous capacity: 100,000 views included
βββ Dataverse capacity: Required for tables
Users need:
βββ No license for anonymous access (public pages)
βββ Assigned capacity or per-user license for authenticated
Portal Architecture
Components:
Power Pages Site:
βββ Web Pages (URLs, content, navigation)
βββ Web Templates (Liquid templates, reusable components)
βββ Entity Forms (create/edit/view Dataverse records)
βββ Entity Lists (display/search/filter Dataverse records)
βββ Web Roles (permissions groups)
βββ Table Permissions (row-level security)
βββ Site Settings (configuration key-value pairs)
βββ Content Snippets (reusable text blocks)
Publishing:
βββ Draft changes in Portal Management app
βββ Sync to integrate Dataverse changes
βββ Clear cache to see updates
Site structure example:
https://contoso.powerappsportals.com
βββ /home (anonymous - marketing content)
βββ /products (anonymous - catalog)
βββ /signin (authentication required beyond this)
βββ /account/profile (authenticated - user profile)
βββ /cases (authenticated - view support cases)
βββ /cases/create (authenticated - create case)
βββ /admin (admin role only - site management)
Authentication Options
Anonymous Access
Public pages:
Portal Management app β Web Pages β Home page
βββ Publishing State: Published
βββ Page Template: Home template
βββ Enable Anonymous: Yes (default for public pages)
βββ No authentication required
Configuration:
Site Settings β Authentication/Registration/ExternalLoginEnabled = false
Anonymous user tracking:
{% comment %} Track anonymous sessions {% endcomment %}
{% assign anonymousId = request.cookies['anonymousId'] %}
{% if anonymousId == blank %}
{% assign anonymousId = now | date: '%s' | append: request.ip %}
{% cookie 'anonymousId', anonymousId, 365 %}
{% endif %}
<span>Session ID: {{ anonymousId }}</span>
Azure AD B2C Integration
Setup Azure AD B2C:
# Create Azure AD B2C tenant
az ad b2c tenant create \
--resource-group rg-portals \
--name contosoexternal \
--domain contosoexternal.onmicrosoft.com \
--location "United States"
# Register application
az ad app create \
--display-name "Contoso Customer Portal" \
--sign-in-audience AzureADandPersonalMicrosoftAccount \
--web-redirect-uris "https://contoso.powerappsportals.com/signin-aad-b2c"
# Create user flows
User flows β Sign up and sign in β Create
βββ Name: B2C_1_signupsignin
βββ Identity providers: Email signup
βββ User attributes: Given Name, Surname, Email
βββ Application claims: Display Name, Email, Object ID
Configure Power Pages provider:
Portal Management app β Site Settings
Authentication/OpenAuth/AzureADB2C/Authority = https://contosoexternal.b2clogin.com/contosoexternal.onmicrosoft.com/B2C_1_signupsignin/v2.0
Authentication/OpenAuth/AzureADB2C/ClientId = {app-id}
Authentication/OpenAuth/AzureADB2C/ClientSecret = {client-secret}
Authentication/OpenAuth/AzureADB2C/RedirectUri = https://contoso.powerappsportals.com/signin-aad-b2c
Authentication/OpenAuth/AzureADB2C/ExternalLogoutEnabled = true
Authentication/Registration/Enabled = true
User registration flow:
{% comment %} Custom registration page {% endcomment %}
<form method="post" action="/account/register">
{% csrf %}
<input type="email" name="email" placeholder="Email" required />
<input type="text" name="firstname" placeholder="First Name" required />
<input type="text" name="lastname" placeholder="Last Name" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Register</button>
</form>
{% if errors %}
<ul class="alert alert-danger">
{% for error in errors %}
<li>{{ error.message }}</li>
{% endfor %}
</ul>
{% endif %}
Local Authentication
Username/password accounts:
Site Settings β Authentication/Registration/Enabled = true
βββ LocalLoginEnabled = true
βββ OpenRegistrationEnabled = true (self-registration)
βββ RequireUniqueEmail = true
βββ PasswordPolicy = ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d@$!%*?&]{8,}$
Contact table (Dataverse):
βββ Email address (username)
βββ Password hash (automatic)
βββ Email confirmed (boolean)
βββ Lockout enabled (boolean)
Password reset:
{% comment %} Forgot password page {% endcomment %}
<form method="post" action="/account/forgotpassword">
{% csrf %}
<input type="email" name="email" placeholder="Email" required />
<button type="submit">Send Reset Link</button>
</form>
{% comment %} Email template for reset link {% endcomment %}
Subject: Password Reset Request
Click the link below to reset your password:
{{ reset_url }}
This link expires in 24 hours.
Social Identity Providers
Microsoft, Google, Facebook, LinkedIn:
Portal Management app β Site Settings
Authentication/OpenAuth/Microsoft/ClientId = {app-id}
Authentication/OpenAuth/Microsoft/ClientSecret = {client-secret}
Authentication/OpenAuth/Google/ClientId = {client-id}
Authentication/OpenAuth/Google/ClientSecret = {client-secret}
Authentication/OpenAuth/Facebook/AppId = {app-id}
Authentication/OpenAuth/Facebook/AppSecret = {app-secret}
Authentication/OpenAuth/LinkedIn/ClientId = {client-id}
Authentication/OpenAuth/LinkedIn/ClientSecret = {client-secret}
Sign-in page:
<h2>Sign in</h2>
<a href="/signin-microsoft" class="btn btn-primary">
<i class="fab fa-microsoft"></i> Sign in with Microsoft
</a>
<a href="/signin-google" class="btn btn-danger">
<i class="fab fa-google"></i> Sign in with Google
</a>
<a href="/signin-facebook" class="btn btn-info">
<i class="fab fa-facebook"></i> Sign in with Facebook
</a>
<hr />
<form method="post" action="/account/login">
{% csrf %}
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Sign in</button>
</form>
<a href="/account/register">Create account</a>
<a href="/account/forgotpassword">Forgot password?</a>
Web Roles and Permissions
Web Roles
Role hierarchy:
Web Roles (Portal Management app):
βββ Anonymous (default for unauthenticated)
βββ Authenticated (default after sign-in)
βββ Customer (self-service portal users)
βββ Partner (external business partners)
βββ Administrator (portal content managers)
Contacts (Dataverse):
βββ Related Web Roles (N:N relationship)
Create web role:
Portal Management app β Web Roles β New
Name: Customer
Website: Contoso Portal
Authenticated Users Role: Yes (auto-assign on sign-in)
Anonymous Users Role: No
Related:
βββ Table Permissions: Link to table permissions
βββ Web Page Access Control Rules: Restrict specific pages
Table Permissions
Row-level security:
Portal Management app β Table Permissions β New
Name: Customer Own Cases
Table: Case (incident)
Access Type: Global (all records) | Contact (user's own) | Account (related to user's account) | Parental (related to parent record)
Website: Contoso Portal
Privileges:
βββ Read: Yes
βββ Write: Yes
βββ Create: Yes
βββ Delete: No
βββ Append: No
βββ Append To: No
Web Roles: Customer (required to apply permissions)
Access type examples:
Contact (user's own records):
βββ Relationship: regarding (contact) = current user
Account (company records):
βββ Relationship: customer (account) = user's parent account
Example: All cases for user's company
Parental (cascading):
βββ Parent: Account (company)
Child: Case (case for company)
Example: User accesses company cases, then related notes
Multiple permissions:
Customer can:
βββ Read own cases (Contact scope)
βββ Read knowledge articles (Global scope)
βββ Create cases (Contact scope with Create privilege)
βββ Cannot delete cases (Delete privilege = No)
Partner can:
βββ Read all cases for their company (Account scope)
βββ Write cases for their company (Account scope)
βββ Read all products (Global scope)
Web Templates (Liquid)
Liquid Template Language
Basic syntax:
{% comment %} Variables {% endcomment %}
{% assign userName = user.fullname %}
<p>Welcome, {{ userName }}!</p>
{% comment %} Conditionals {% endcomment %}
{% if user %}
<p>Signed in as {{ user.email }}</p>
{% else %}
<a href="/signin">Sign in</a>
{% endif %}
{% comment %} Loops {% endcomment %}
<ul>
{% for case in cases %}
<li>{{ case.title }} - {{ case.statuscode.label }}</li>
{% endfor %}
</ul>
{% comment %} Filters {% endcomment %}
<p>Posted {{ case.createdon | date: '%B %d, %Y' }}</p>
<p>{{ case.description | truncate: 100 }}</p>
Dataverse Queries
EntityList (multiple records):
{% entitylist
name: "Active Cases"
pagesize: 10
filter: "statuscode eq 1"
order: "createdon desc"
%}
<table class="table">
<thead>
<tr>
<th>Case Number</th>
<th>Title</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for case in entitylist.records %}
<tr>
<td>{{ case.ticketnumber }}</td>
<td><a href="/cases/{{ case.id }}">{{ case.title }}</a></td>
<td>{{ case.statuscode.label }}</td>
<td>{{ case.createdon | date: '%m/%d/%Y' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% comment %} Pagination {% endcomment %}
<nav>
<ul class="pagination">
{% if entitylist.page > 1 %}
<li><a href="?page={{ entitylist.page | minus: 1 }}">Previous</a></li>
{% endif %}
<li>Page {{ entitylist.page }} of {{ entitylist.total_pages }}</li>
{% if entitylist.page < entitylist.total_pages %}
<li><a href="?page={{ entitylist.page | plus: 1 }}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endentitylist %}
EntityView (single record):
{% entityview
logical_name: 'incident'
id: request.params.id
%}
<h1>{{ entityview.title }}</h1>
<dl>
<dt>Case Number</dt>
<dd>{{ entityview.ticketnumber }}</dd>
<dt>Status</dt>
<dd>{{ entityview.statuscode.label }}</dd>
<dt>Priority</dt>
<dd>{{ entityview.prioritycode.label }}</dd>
<dt>Created</dt>
<dd>{{ entityview.createdon | date: '%B %d, %Y %I:%M %p' }}</dd>
<dt>Description</dt>
<dd>{{ entityview.description }}</dd>
</dl>
{% if entityview.customer %}
<p>Customer: {{ entityview.customer.fullname }}</p>
{% endif %}
{% endentityview %}
Reusable Components
Header template:
{% comment %} Web Template: Header {% endcomment %}
<header class="site-header">
<div class="container">
<a href="/" class="logo">
<img src="{{ site.logo }}" alt="{{ site.title }}" />
</a>
<nav>
<ul class="nav">
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/support">Support</a></li>
{% if user %}
<li><a href="/account">My Account</a></li>
<li><a href="/signout">Sign Out</a></li>
{% else %}
<li><a href="/signin">Sign In</a></li>
{% endif %}
</ul>
</nav>
</div>
</header>
{% comment %} Include in page template {% endcomment %}
{% include 'Header' %}
Entity Forms
Create Form
Configuration:
Portal Management app β Entity Forms β New
Name: Create Case Form
Table: Case (incident)
Form Name: Information (Dataverse form)
Mode: Insert
Success Message: "Your case has been created. Case number: {ticketnumber}"
On Success: Redirect
Redirect URL: /cases/{id}
Entity Form Metadata (field overrides):
βββ Title β Required, Label: "What can we help with?"
βββ Description β Required, Rows: 5
βββ Priority β Default value: Normal
βββ Customer β Hidden, Auto-populate: Current user
Embed in web page:
{% comment %} Web Template: Create Case {% endcomment %}
<h1>Create Support Case</h1>
<p>Describe your issue and we'll get back to you within 24 hours.</p>
{% entityform name: 'Create Case Form' %}
{% comment %} Custom styling {% endcomment %}
<style>
.entityform .form-group { margin-bottom: 20px; }
.entityform label { font-weight: bold; }
.entityform .required:after { content: "*"; color: red; }
</style>
Edit Form
Update existing record:
Entity Form: Edit Case Form
Mode: Edit
Record Source Type: Query String
Record ID Parameter Name: id
Table Permissions required:
βββ Customer can Write their own cases
On Success: Display message "Case updated successfully"
Edit page with security:
{% entityview logical_name: 'incident', id: request.params.id %}
{% comment %} Check user owns this case {% endcomment %}
{% if entityview.customer.id == user.id %}
<h1>Edit Case: {{ entityview.ticketnumber }}</h1>
{% entityform name: 'Edit Case Form' %}
{% else %}
<div class="alert alert-danger">
You do not have permission to edit this case.
</div>
{% endif %}
{% endentityview %}
Read-Only Form
View mode:
Entity Form: View Case Form
Mode: ReadOnly
Form Name: Information
Show Unsupported Fields: No (hide complex controls)
Embed:
{% entityform name: 'View Case Form' %}
Entity Lists
Basic List
Configuration:
Portal Management app β Entity Lists β New
Name: My Cases List
Table: Case (incident)
Views: Active Cases (Dataverse view)
Page Size: 10
Web Page: My Cases page
Grid Configuration:
βββ Enable search: Yes
βββ Enable filtering: Yes
βββ Enable sorting: Yes
View Actions:
βββ Details: Enabled, Target: /cases/{id}
βββ Create: Enabled, Target: /cases/create
Embed in page:
{% comment %} Web Template: My Cases {% endcomment %}
<h1>My Support Cases</h1>
{% entitylist
name: "My Cases List"
%}
{% comment %} Custom rendering instead of default grid {% endcomment %}
<div class="cases">
{% for case in entitylist.records %}
<div class="case-card">
<h3>
<a href="/cases/{{ case.id }}">{{ case.title }}</a>
</h3>
<p class="case-number">Case #{{ case.ticketnumber }}</p>
<p class="case-status">
<span class="badge badge-{{ case.statuscode.value }}">
{{ case.statuscode.label }}
</span>
</p>
<p class="case-date">Created {{ case.createdon | date: '%m/%d/%Y' }}</p>
<p>{{ case.description | truncate: 150 }}</p>
</div>
{% endfor %}
</div>
{% include 'Pagination' with entitylist %}
{% endentitylist %}
Advanced Filtering
Filter configuration:
Entity List β Web Page for Entity List β Filter
Filter Name: Status Filter
Attribute: statuscode
Type: Dropdown
Options:
βββ Active (value: 1)
βββ Resolved (value: 5)
βββ Closed (value: 6)
Custom filter UI:
<form method="get" class="case-filters">
<label>Status:</label>
<select name="statuscode">
<option value="">All</option>
<option value="1" {% if request.params.statuscode == "1" %}selected{% endif %}>Active</option>
<option value="5" {% if request.params.statuscode == "5" %}selected{% endif %}>Resolved</option>
<option value="6" {% if request.params.statuscode == "6" %}selected{% endif %}>Closed</option>
</select>
<label>Priority:</label>
<select name="prioritycode">
<option value="">All</option>
<option value="1" {% if request.params.prioritycode == "1" %}selected{% endif %}>High</option>
<option value="2" {% if request.params.prioritycode == "2" %}selected{% endif %}>Normal</option>
<option value="3" {% if request.params.prioritycode == "3" %}selected{% endif %}>Low</option>
</select>
<button type="submit">Filter</button>
</form>
{% entitylist
name: "My Cases List"
filter: "statuscode eq {{ request.params.statuscode | default: '' }} and prioritycode eq {{ request.params.prioritycode | default: '' }}"
%}
{% comment %} Render list {% endcomment %}
{% endentitylist %}
Site Settings and Configuration
Key site settings:
Portal Management app β Site Settings
Search:
βββ Search/Enabled = true
βββ Search/IndexQueryName = Portal Search
βββ Search/Filters = incident,knowledgearticle
Header/Footer:
βββ Header/Content = {% include 'Header' %}
βββ Footer/Content = {% include 'Footer' %}
βββ Header/OutputCache/Enabled = true
Performance:
βββ Portal/OutputCache/Enabled = true
βββ Portal/OutputCache/Duration = 300 (seconds)
βββ HTTP/max-age = 3600
Security:
βββ HTTP/HSTS/Enabled = true
βββ HTTP/HSTS/max-age = 31536000
βββ Authentication/TwoFactorAuthentication/Enabled = true
βββ Captcha/Enabled = true
Best Practices
- Authentication: Use Azure AD B2C for scalability, not local accounts
- Permissions: Always configure table permissions for authenticated users
- Performance: Enable output caching, optimize Dataverse queries
- Security: Enforce HTTPS, implement rate limiting, use CAPTCHA
- SEO: Configure meta tags, sitemap, friendly URLs
- Monitoring: Enable Application Insights for portal telemetry
Troubleshooting
User cannot access page:
- Check web page Publishing State = Published
- Verify Web Page Access Control Rules (if restricted)
- Check user has assigned Web Role
- Confirm table permissions granted to web role
Entity form not saving:
- Verify table permissions include Create/Write privileges
- Check Dataverse form exists and is published
- Review required fields are populated
- Check for business rules/plugins blocking save
Performance issues:
- Enable output caching for static pages
- Reduce entity list page size (10-20 records)
- Optimize Dataverse views (limit columns, add indexes)
- Use CDN for images/CSS/JavaScript
Key Takeaways
- Power Pages extends Dataverse to external users (customers, partners)
- Azure AD B2C provides scalable authentication for thousands of users
- Web roles and table permissions enforce row-level security
- Liquid templates enable custom page layouts and Dataverse queries
- Entity forms and lists provide no-code CRUD operations
- Portal Management app configures all site settings and permissions
Next Steps
- Configure Azure Front Door for global CDN and WAF protection
- Implement multi-factor authentication for sensitive operations
- Add custom APIs with Azure Functions for complex business logic
- Enable Application Insights for monitoring and diagnostics
Additional Resources
Extend Dataverse beyond the organization.