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:
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 login4. 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:
- RDP into the connector VM
- 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 - Open https://entra.microsoft.com on the VM
- Navigate to: Identity → Applications → Enterprise applications → Application proxy
- Click Enable application proxy if not already enabled
- Click Download connector service and run the installer
- Sign in with Global Admin or Application Admin credentials
- Verify connector shows Active in the portal
- 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 jsonPhase 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:
| Hostname | IP | Purpose |
|---|---|---|
bom-explorer-web.azurewebsites.net | 10.160.0.17 | PSI Explorer App Service |
bom-explorer-web.scm.azurewebsites.net | 10.160.0.17 | PSI 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 URLTypeScript 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
| App | Internal URL | External URL | Auth Mode |
|---|---|---|---|
| PSI Explorer | https://bom-explorer-web.azurewebsites.net | https://psibomexplorer-progressivesurfaceinc.msappproxy.net/ | aadPreAuthentication |
| UniData API | https://api.progressivesurface.com | https://psiunidataapi-progressivesurfaceinc.msappproxy.net/ | passthru |
App Registration IDs
| App | Client ID | SP Object ID |
|---|---|---|
| PSI PSI Explorer | 13d6930a-97d9-4a8d-b355-a31074fbd53d | 6ce22887-f3b3-4cc9-83e9-d9a73062455b |
| PSI.UniData.API | b3db69d9-5d15-457d-b660-88b336fc00fa | 147dede0-c39f-4c80-ac4e-fb6ad62cb178 |
Connector Infrastructure
| Property | Value |
|---|---|
| Connector VM | PS-AZ-LS3 |
| VM IP | 10.160.0.14 |
| Subnet | PS-SERVERS (10.160.0.0/23) |
| VM Size | D2as_v5 |
| Connector Group | PSI-AppProxy-Connectors |
| Connector Group ID | 3bab88b8-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
| Problem | Cause | Solution |
|---|---|---|
| 403 Forbidden via App Proxy | DNS resolves App Service to public IP instead of private endpoint IP | Verify DC zone has A records for the app (see DNS Config) |
| CORS errors on API calls | API App Proxy using pre-authentication | Set API auth to passthru (see ) |
| CORS errors (origin not allowed) | App Proxy URL missing from API CORS config | Add msappproxy.net URL to Cors:AllowedOrigins in API appsettings.json |
| ExternalUrl_InvalidAsciiRules | Wrong URL format in Graph API call | Use *-progressivesurfaceinc.msappproxy.net (tenant name, not ID) |
| Graph API 403 / NotAdminRole | Missing Application Administrator role | Activate role via PIM, then az account clear && az login |
| onPremisesPublishing not found | Using v1.0 Graph API | Use https://graph.microsoft.com/beta/ endpoint |
| Double login prompt | App Proxy and MSAL both prompting | Ensure app registration is shared; verify redirectUri: window.location.origin |
| App loads but API fails | API not reachable from connector | Check if API is on-prem (no hosts entry needed) vs Azure PE (needs hosts entry) |
Related Pages
- Azure App Proxy Evaluation — Architecture overview, cost analysis, app assessment
- Deploy to Azure — Web app infrastructure, private endpoints, DNS
- PSI Explorer — POC pilot application
- UniData API — Shared API backend
Created: February 2026