Azure App Proxy Implementation

Step-by-step implementation guide for publishing PSI web applications via Microsoft Entra Application Proxy. Based on the PSI Explorer POC completed February 2026.

Prerequisite reading: Azure App Proxy Evaluation for architecture overview, requirements, and cost analysis.


POC Results Summary

The PSI Explorer POC validated App Proxy for PSI. Key outcomes:

ItemResult
Pilot appPSI Explorer (explorer.progressivesurface.com)
APIUniData API (api.progressivesurface.com) — also published via App Proxy
Connector VMPS-AZ-LS3 (10.160.0.14, Windows, D2as_v5, PS-SERVERS subnet)
Connector groupPSI-AppProxy-Connectors (3bab88b8-6c62-44d9-8a92-da3f8d1790d8)
PSI Explorer external URLhttps://psibomexplorer-progressivesurfaceinc.msappproxy.net/
UniData API external URLhttps://psiunidataapi-progressivesurfaceinc.msappproxy.net/
PSI Explorer auth modeEntra ID pre-authentication (aadPreAuthentication)
API auth modePassthrough (required for SPA API calls — see )

Critical Findings

These are the non-obvious issues discovered during implementation that will apply to every future App Proxy deployment.

1. External URL Format

The auto-generated msappproxy.net subdomain uses the tenant display name, not the tenant ID:

CORRECT:   https://psibomexplorer-progressivesurfaceinc.msappproxy.net
WRONG:     https://psibomexplorer-a83ae943-0a50-49cc-83c3-479b7a44b7fb.msappproxy.net

If you provide the wrong format, the Graph API returns ExternalUrl_InvalidAsciiRules. The error message reveals the correct suffix.

2. Graph API Requires Beta Endpoint

The onPremisesPublishing property on application objects is only available on the beta Graph API, not v1.0:

CORRECT:   https://graph.microsoft.com/beta/applications/{id}
WRONG:     https://graph.microsoft.com/v1.0/applications/{id}

3. PIM Role Activation Required

Configuring App Proxy via Graph API requires Application Administrator role. If the role is assigned via PIM (Privileged Identity Management), it must be activated — being “eligible” is not enough. After activation, clear the Azure CLI token cache:

az account clear && az login

4. Private Endpoint DNS Resolution on Connector VM

Problem: The connector VM (PS-AZ-LS3) resolves bom-explorer-web.azurewebsites.net to the public IP instead of the private endpoint IP (10.160.0.17). This causes a 403 Forbidden because publicNetworkAccess=Disabled on the App Service.

Root cause: Windows DNS returns the complete CNAME chain from public DNS in a single response, so a conditional forwarder for privatelink.azurewebsites.net is never consulted. The DC needs an authoritative local zone to intercept the CNAME.

Fix (implemented 2026-02-17): AD-integrated primary zones on PS-AZ-DC01 for all privatelink.* domains with A records matching the Azure Private DNS zones. This resolves the issue for ALL VNet machines — connector VMs, CI/CD runners, and any future services. See Deploy to Azure § DNS for full details.

Legacy POC workaround (no longer needed): Hosts file entries were previously added to the connector VM (PS-AZ-LS3). These can be removed now that the DC zones are in place.

Note: On-premises services like the UniData API (api.progressivesurface.com → PS-PROXY at 192.9.201.217) do NOT need hosts entries. They resolve via normal DNS through the site-to-site VPN.

5. SPA + API Architecture — Passthrough Required for APIs

This is the most important architectural finding.

When a React SPA calls a separate API via fetch(), and both are published through App Proxy:

  • The SPA (PSI Explorer) should use Entra ID pre-authentication — users authenticate at the proxy before reaching the app
  • The API (UniData API) must use Passthrough authentication — NOT pre-authentication

Why: With pre-auth on the API, the browser’s CORS preflight (OPTIONS request) gets intercepted by App Proxy and returned as a 302 redirect to login.microsoftonline.com. Browsers cannot follow redirects during preflight, so the fetch() call fails with a CORS error. The API never sees the request.

SPA pre-auth + API pre-auth:
  Browser → OPTIONS https://api-proxy.msappproxy.net/api/endpoint
         ← 302 Redirect to login.microsoftonline.com (NO CORS headers)
         ✗ Browser blocks: CORS preflight failed

SPA pre-auth + API passthrough:
  Browser → OPTIONS https://api-proxy.msappproxy.net/api/endpoint
         → (passes through to actual API)
         ← 200 OK with CORS headers
         ✓ Browser proceeds with actual request

Security note: Passthrough on the API means the connector forwards traffic without Entra pre-auth. The API is still protected by its own authentication (Azure AD JWT validation) and CORS policy. The API is not accessible without valid credentials — App Proxy just doesn’t add a second auth layer.

6. MSAL.js redirectUri Works Automatically

If the SPA uses window.location.origin for the MSAL redirect URI (not a hardcoded domain), authentication works seamlessly for both direct access and App Proxy access:

// src/auth/msalConfig.ts
redirectUri: window.location.origin,      // Works for both URLs
postLogoutRedirectUri: window.location.origin,

Just ensure the App Proxy external URL is added to the app registration’s redirect URIs.


Implementation Steps

Phase 1: Connector Installation (Manual — RDP Required)

The connector must be installed interactively on a Windows Server VM.

VM requirements:

  • Windows Server 2016+
  • .NET Framework 4.7.2+
  • On PS-SERVERS subnet (same as private endpoints)
  • Outbound HTTPS to *.msappproxy.net, *.servicebus.windows.net, login.microsoftonline.com

Steps:

  1. RDP into the connector VM
  2. Run prerequisite checks:
    # Check OS version — need Server 2016+
    [System.Environment]::OSVersion
     
    # Check .NET — need 4.7.2+ (release 461808+)
    (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full").Release
     
    # Verify outbound connectivity
    Test-NetConnection -ComputerName msappproxy.net -Port 443
    Test-NetConnection -ComputerName servicebus.windows.net -Port 443
    Test-NetConnection -ComputerName login.microsoftonline.com -Port 443
  3. Open https://entra.microsoft.com on the VM
  4. Navigate to: Identity → Applications → Enterprise applications → Application proxy
  5. Click Enable application proxy if not already enabled
  6. Click Download connector service and run the installer
  7. Sign in with Global Admin or Application Admin credentials
  8. Verify connector shows Active in the portal
  9. Create a connector group (e.g., PSI-AppProxy-Connectors) and add the VM

Phase 2: Configure App Proxy for the Web App (Graph API)

Required role: Application Administrator (activate via PIM if needed)

# Clear cached tokens to pick up activated PIM role
az account clear && az login
 
# Get the application's Object ID (not the App ID)
APP_OBJECT_ID=$(az ad app show --id <APP_CLIENT_ID> --query "id" -o tsv)
 
# Configure App Proxy on the application
az rest --method PATCH \
  --url "https://graph.microsoft.com/beta/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{
    "onPremisesPublishing": {
      "externalAuthenticationType": "aadPreAuthentication",
      "internalUrl": "https://<app-name>.azurewebsites.net",
      "externalUrl": "https://<appname>-progressivesurfaceinc.msappproxy.net/",
      "isTranslateHostHeaderEnabled": true,
      "isTranslateLinksInBodyEnabled": false,
      "isHttpOnlyCookieEnabled": true,
      "isSecureCookieEnabled": true,
      "isPersistentCookieEnabled": false,
      "isOnPremPublishingEnabled": true
    }
  }'
 
# Verify the configuration
az rest --method GET \
  --url "https://graph.microsoft.com/beta/applications/$APP_OBJECT_ID" \
  --query "onPremisesPublishing" -o json

Phase 3: Configure App Proxy for the API (Passthrough)

For APIs called from browser SPAs, use passthru authentication:

API_OBJECT_ID=$(az ad app show --id <API_CLIENT_ID> --query "id" -o tsv)
 
az rest --method PATCH \
  --url "https://graph.microsoft.com/beta/applications/$API_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{
    "onPremisesPublishing": {
      "externalAuthenticationType": "passthru",
      "internalUrl": "https://api.progressivesurface.com",
      "externalUrl": "https://psiunidataapi-progressivesurfaceinc.msappproxy.net/",
      "isTranslateHostHeaderEnabled": true,
      "isTranslateLinksInBodyEnabled": false,
      "isHttpOnlyCookieEnabled": true,
      "isSecureCookieEnabled": true,
      "isPersistentCookieEnabled": false,
      "isOnPremPublishingEnabled": true
    }
  }'

Phase 4: Update Redirect URIs

Add the App Proxy external URL to the web app’s redirect URIs:

az ad app update \
  --id <APP_CLIENT_ID> \
  --web-redirect-uris \
    "https://<app>.progressivesurface.com" \
    "https://<appname>-progressivesurfaceinc.msappproxy.net/"

Phase 5: Assign Connector Group

# Get connector group ID
CONNECTOR_GROUP_ID="3bab88b8-6c62-44d9-8a92-da3f8d1790d8"
 
# Assign to web app
az rest --method PUT \
  --url 'https://graph.microsoft.com/beta/applications/'"$APP_OBJECT_ID"'/connectorGroup/$ref' \
  --headers "Content-Type=application/json" \
  --body '{"@odata.id": "https://graph.microsoft.com/beta/onPremisesPublishingProfiles/applicationProxy/connectorGroups/'"$CONNECTOR_GROUP_ID"'"}'
 
# Assign to API app
az rest --method PUT \
  --url 'https://graph.microsoft.com/beta/applications/'"$API_OBJECT_ID"'/connectorGroup/$ref' \
  --headers "Content-Type=application/json" \
  --body '{"@odata.id": "https://graph.microsoft.com/beta/onPremisesPublishingProfiles/applicationProxy/connectorGroups/'"$CONNECTOR_GROUP_ID"'"}'

Phase 6: DNS — Connector VM Hosts File

For each App Service with publicNetworkAccess=Disabled, add hosts entries on the connector VM:

az vm run-command invoke \
  --resource-group PS-RG-01 \
  --name PS-AZ-LS3 \
  --command-id RunPowerShellScript \
  --scripts "Add-Content -Path 'C:\Windows\System32\drivers\etc\hosts' -Value \"`n# App Proxy: <app-name>`n<PRIVATE_ENDPOINT_IP>    <app-name>.azurewebsites.net`n<PRIVATE_ENDPOINT_IP>    <app-name>.scm.azurewebsites.net\""

Current hosts entries on PS-AZ-LS3:

HostnameIPPurpose
bom-explorer-web.azurewebsites.net10.160.0.17PSI Explorer App Service
bom-explorer-web.scm.azurewebsites.net10.160.0.17PSI Explorer SCM

When adding more apps, add their private endpoint IPs here. See current PE assignments for IPs.

Phase 7: Code Changes

7a. Runtime API URL Detection (SPA)

Add logic to detect external access and route API calls through the API’s App Proxy URL:

// src/services/api.ts
const API_PROXY_URL = import.meta.env.VITE_API_PROXY_URL || '';
const isExternalAccess = API_PROXY_URL && window.location.hostname.includes('msappproxy.net');
const API_BASE_URL = isExternalAccess ? API_PROXY_URL : (import.meta.env.VITE_API_URL || '/api');

7b. Environment Variables

.env.production:

VITE_API_URL=https://api.progressivesurface.com/api
VITE_API_PROXY_URL=https://psiunidataapi-progressivesurfaceinc.msappproxy.net/api

.env.example:

VITE_API_PROXY_URL=
# App Proxy API URL for external (non-VPN) access via Azure App Proxy
# Only used when the app is accessed through an msappproxy.net URL

TypeScript declarations (src/vite-env.d.ts):

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_API_PROXY_URL: string;
  // ... other vars
}

7c. MSAL Redirect URI (Verify)

Ensure the MSAL config uses window.location.origin, not a hardcoded domain:

// src/auth/msalConfig.ts
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,

7d. API CORS Configuration

Add the web app’s App Proxy URL to the API’s CORS allowed origins:

// PSI.UniData.API/src/PSI.UniData.API/appsettings.json
"Cors": {
  "AllowedOrigins": [
    "https://explorer.progressivesurface.com",
    "https://psibomexplorer-progressivesurfaceinc.msappproxy.net"
  ]
}

Checklist: Publishing a New App via App Proxy

Use this checklist when adding another PSI web app to App Proxy:

## App Proxy Publish Checklist
 
### Prerequisites
- [ ] Connector VM (PS-AZ-LS3) is running and connector is Active
- [ ] Application Administrator role activated via PIM
- [ ] App registration exists in Entra ID with correct redirect URIs
- [ ] Fresh Azure CLI token (`az account clear && az login`)
 
### Azure Configuration
- [ ] Configure onPremisesPublishing on app (Graph beta API)
- [ ] Set auth type: `aadPreAuthentication` for web apps, `passthru` for APIs
- [ ] External URL follows format: `<appname>-progressivesurfaceinc.msappproxy.net`
- [ ] Add App Proxy external URL to app registration redirect URIs
- [ ] Assign connector group (`PSI-AppProxy-Connectors`)
- [ ] Verify DC DNS zone has A records for the app's private endpoint IP (see [[deploy-to-azure#dns-configuration-for-private-endpoints|Deploy to Azure § DNS]])
 
### Code Changes
- [ ] Add API Proxy URL detection in API client (check `msappproxy.net` hostname)
- [ ] Add `VITE_API_PROXY_URL` to environment files
- [ ] Verify MSAL uses `window.location.origin` for redirectUri
- [ ] Add App Proxy origin to API CORS allowed origins
- [ ] Commit and push all changes
 
### Testing
- [ ] App loads via internal URL (no regression)
- [ ] App loads via App Proxy external URL
- [ ] Entra login prompt appears on external access
- [ ] API calls succeed (check browser console for CORS errors)
- [ ] SSO works (no double login prompt)

Current App Proxy Configuration

Published Applications

AppInternal URLExternal URLAuth Mode
PSI Explorerhttps://bom-explorer-web.azurewebsites.nethttps://psibomexplorer-progressivesurfaceinc.msappproxy.net/aadPreAuthentication
UniData APIhttps://api.progressivesurface.comhttps://psiunidataapi-progressivesurfaceinc.msappproxy.net/passthru

App Registration IDs

AppClient IDSP Object ID
PSI PSI Explorer13d6930a-97d9-4a8d-b355-a31074fbd53d6ce22887-f3b3-4cc9-83e9-d9a73062455b
PSI.UniData.APIb3db69d9-5d15-457d-b660-88b336fc00fa147dede0-c39f-4c80-ac4e-fb6ad62cb178

Connector Infrastructure

PropertyValue
Connector VMPS-AZ-LS3
VM IP10.160.0.14
SubnetPS-SERVERS (10.160.0.0/23)
VM SizeD2as_v5
Connector GroupPSI-AppProxy-Connectors
Connector Group ID3bab88b8-6c62-44d9-8a92-da3f8d1790d8

Next Steps

High Availability

Deploy a second connector on a separate VM and add it to the PSI-AppProxy-Connectors group. Connectors auto-update independently.

DNS — Enterprise Fix (Completed 2026-02-17)

AD-integrated primary zones for privatelink.* domains are now configured on PS-AZ-DC01. No hosts file entries are needed on any VNet machine. When adding new private endpoints, add A records to the DC zone. See Deploy to Azure § DNS.

Conditional Access Policy

Create a CA policy requiring MFA for App Proxy apps when accessed from outside the corporate network. Start in report-only mode:

az rest --method POST \
  --url "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
  --headers "Content-Type=application/json" \
  --body '{
    "displayName": "Require MFA for App Proxy Apps (External)",
    "state": "enabledForReportingButNotEnforced",
    "conditions": {
      "applications": {
        "includeApplications": ["13d6930a-97d9-4a8d-b355-a31074fbd53d"]
      },
      "users": { "includeUsers": ["All"] },
      "locations": {
        "includeLocations": ["All"],
        "excludeLocations": ["<CORPORATE_NAMED_LOCATION_ID>"]
      }
    },
    "grantControls": {
      "operator": "OR",
      "builtInControls": ["mfa"]
    }
  }'

Custom Domains

Replace msappproxy.net URLs with custom domains (e.g., ext-explorer.progressivesurface.com). Requires DNS CNAME records and SSL certificate configuration.

Roll Out Additional Apps

Candidates: PSI Portal, Project Explorer, Redbook Web, ERP Migration Tool. Follow the checklist above.


Troubleshooting

ProblemCauseSolution
403 Forbidden via App ProxyDNS resolves App Service to public IP instead of private endpoint IPVerify DC zone has A records for the app (see DNS Config)
CORS errors on API callsAPI App Proxy using pre-authenticationSet API auth to passthru (see )
CORS errors (origin not allowed)App Proxy URL missing from API CORS configAdd msappproxy.net URL to Cors:AllowedOrigins in API appsettings.json
ExternalUrl_InvalidAsciiRulesWrong URL format in Graph API callUse *-progressivesurfaceinc.msappproxy.net (tenant name, not ID)
Graph API 403 / NotAdminRoleMissing Application Administrator roleActivate role via PIM, then az account clear && az login
onPremisesPublishing not foundUsing v1.0 Graph APIUse https://graph.microsoft.com/beta/ endpoint
Double login promptApp Proxy and MSAL both promptingEnsure app registration is shared; verify redirectUri: window.location.origin
App loads but API failsAPI not reachable from connectorCheck if API is on-prem (no hosts entry needed) vs Azure PE (needs hosts entry)


Created: February 2026