PowerApps Portals and Power Pages: Building External-Facing Applications

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

  1. Authentication: Use Azure AD B2C for scalability, not local accounts
  2. Permissions: Always configure table permissions for authenticated users
  3. Performance: Enable output caching, optimize Dataverse queries
  4. Security: Enforce HTTPS, implement rate limiting, use CAPTCHA
  5. SEO: Configure meta tags, sitemap, friendly URLs
  6. 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.