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.

7. SPA + Pre-auth on a SHARED App Registration → PKCE Redirect-URI Conflict (CRITICAL)

This is the single most confusing failure when a same-origin SPA is published with App Proxy pre-auth on the same app registration (as PSI Explorer is, 13d6930a). Two consumers redeem auth codes against the same origin with mutually exclusive platform requirements:

ConsumerFlowReply-URL platform it needsIf registered as the other platform
MSAL.js SPAPKCE + cross-origin token redemption (browser fetch with Origin)SPAAADSTS9002326cross-origin token redemption is only supported for SPA reply URLs
App Proxy pre-authconfidential-client code flow, no PKCE, server-sideWebAADSTS9002325PKCE is required for cross-origin authorization code redemption

You cannot satisfy both by moving the URL between buckets — fixing one error produces the other. Register the external URL in BOTH spa.redirectUris AND web.redirectUris. AAD disambiguates per request (MSAL sends Origin+PKCE → SPA rules; App Proxy sends neither → Web rules).

The cleanest way to avoid any ambiguity is to let the two flows match on different strings — App Proxy redirects to the URL with a trailing slash, MSAL uses window.location.origin without one:

spa.redirectUris:  [ "https://explorer.progressivesurface.com" ]    # no slash — MSAL / window.location.origin
web.redirectUris:  [ "https://explorer.progressivesurface.com/" ]   # trailing slash — App Proxy's redirect_uri
# Add the App Proxy (Web, PKCE-exempt) reply URL WITHOUT disturbing the SPA one:
az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{ "web": {
      "homePageUrl": "https://<app>.progressivesurface.com/",
      "logoutUrl": "https://<app>.progressivesurface.com/?appproxy=logout",
      "implicitGrantSettings": { "enableIdTokenIssuance": true },
      "redirectUris": ["https://<app>.progressivesurface.com/"]
  }}'

Symptom sequence we hit on PSI Explorer (2026-07-23): with the Web URL removed (to fix 9002326 for the SPA), enabling pre-auth produced a Forbidden / AADSTS9002325 page after the login redirect. Adding the slash-form Web reply URL resolved it — but that only clears the first error. See §8.

8. Pre-auth is INCOMPATIBLE with token-acquiring SPAs (CRITICAL — read before publishing any SPA)

Do not publish a modern MSAL SPA (MSAL.js 2.x+) with App Proxy pre-authentication. The §7 dual-redirect-URI fix gets past the initial PKCE error, but the deeper problem is fatal and cannot be fixed from the SPA side:

When App Proxy pre-auth decides a request needs (re)authentication — the interactive sign-in return, a silent-token-renewal iframe, or session expiry — it 302-redirects to login.microsoftonline.com with no CORS headers. A browser can’t follow that redirect inside a fetch/iframe/popup, so the MSAL auth-code URL fragment is dropped on the proxied top-level return; handleRedirectPromise finds no code, no account is cached, and the app loops (AuthGuard re-fires loginRedirect → “We couldn’t sign you in”). It works on-network only because a Windows PRT lets MSAL authenticate silently with no return trip — which is why it looks fine in testing and fails for every external/mobile user. Microsoft documents this as expected behavior, not a bug (App Proxy CORS issues, Option 5; Q&A).

Things that do not fix it (all verified on Explorer): the §7 PKCE dual redirect-URI, navigateToLoginRequestUrl:false, MSAL popup, no-store/cache changes. The auth code never arrives.

Correct patterns for a private SPA that needs remote access:

  1. Entra Private Access (Global Secure Access) — network-level ZTNA; the app is never internet-exposed and the SPA runs untouched (no HTTP auth-proxy in the token path). PSI’s chosen direction. Needs the GSA client + a Private Access license.
  2. Passthrough App Proxy + app-layer auth — works, but the app’s server is exposed to unauthenticated internet traffic (only acceptable if you accept that surface).
  3. Re-architect the SPA to server-side / BFF auth (App Service EasyAuth or API Management) so the browser never holds tokens — then an HTTP edge-auth (incl. App Proxy pre-auth) works.

PSI Explorer was removed from App Proxy on 2026-07-28 (maintenance mode + public DNS flipped CNAME→A to the private endpoint) and reverted to VPN-only; EPA is the planned replacement. See PSI Explorer.


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.net/https://explorer.progressivesurface.com/ (custom domain)aadPreAuthentication (live 2026-07-23)

The UniData API is no longer published via App Proxy. PSI Explorer moved to a same-origin reverse-proxy model: the browser calls only explorer.progressivesurface.com/api/*, and the Explorer Node server relays to the internal UniData API over the VNet. The internal API is never internet-published, so the old “API via App Proxy passthru + CORS” section below is obsolete — there is no second publication and no cross-origin fetch, which also removes the CORS-preflight problem that forced passthrough on the API (Finding #5). Explorer’s external URL is now the custom domain (explorer.progressivesurface.com), not the msappproxy.net host (which now returns 404).

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