Canonical source of truth for any human or AI agent implementing, changing, or deploying a PSI web app. Start here before making Azure, auth, deployment, secrets, networking, or production configuration changes.
Agents MUST review the DNS standard before publishing
Before you publish or deploy a web app to Azure, you MUST also read dns-standards and follow its new-app checklist. DNS is the #1 cause of failed PSI deploys — a private endpoint without records in both zones ships broken.
What this page governs
This standard applies to:
Azure App Service applications
Azure Static Web Apps
GitHub Actions deployment workflows
auth, networking, secrets, and production configuration changes
Use this page for the baseline. Use the linked pages at the bottom for implementation details, current exceptions, resource mapping, and historical evidence.
Mandatory baseline
Area
Requirement
Deployment path
Production deployments must run through GitHub Actions.
Deployment auth
Production App Service deploys must be identity-based (az login --identity on the PSI runner, or Entra federated credentials).
Publishing credentials
basicPublishingCredentialsPolicies/scm=false and basicPublishingCredentialsPolicies/ftp=false unless a documented exception exists.
Legacy deploy methods
Local-git and publish-profile deployment are not compliant for production App Services.
Secrets
Sensitive settings must live in Key Vault and be referenced from app settings; never commit secrets or leave production secrets as plain-text app settings when Key Vault references are possible.
Key Vault
Standard production vault is ps-certificates-kv; app identity must have only the access it needs.
Networking
Internal web apps must use private endpoints and have public access disabled.
DNS
Follow dns-standards. Every internal app needs records in two privatelink zones (Azure + the DCs), both app and app.scm; on-prem references from App Service use the FQDN.
Public exposure
Public/anonymous apps require an explicit documented reason and must be treated as exceptions, not defaults.
Authentication
Each app must use an approved auth profile and have that profile documented.
Validation
Use existing tests/builds, then run a real deployment validation and verify the custom-domain behavior.
Health gates
Health/reachability gates must treat 200, 401, and 403 according to the intended auth posture of the endpoint.
Controlled rollout
Production changes should be applied one app at a time with validation between changes.
Documentation
Any auth, deployment, secrets, networking, or exception change must be reflected in the relevant wiki pages.
Approved auth profiles
Profile
When to use it
Required guardrails
Current examples
Client/app-layer auth
SPA + API apps where auth is enforced in the app and anonymous root/health behavior may be needed
Route expectations documented, app-layer validation present, no accidental EasyAuth enforcement left behind
Apps that intentionally require platform auth in front of the app
Entra-only provider, unused providers disabled, custom-domain behavior validated, deploy health gates allow 401/403 when expected
ps-progressive-view, ps-redbook-dashboard
Public / anonymous exception
Asset or utility apps that must be broadly reachable
Business reason documented, no unnecessary secrets exposed, public posture explicitly tracked as an exception
psredbookphotos, ps-shipphotos
AuthsettingsV2 guardrail: disabling only platform.enabled is not enough if globalValidation.requireAuthentication=true remains set. Anonymous/app-layer apps must also set requireAuthentication=false and AllowAnonymous.
Client/app-layer auth best practice
For internal PSI apps that use app-layer Entra/MSAL auth and are accessed from each user’s own Windows/browser profile:
Prefer redirect-based auth flows for normal browser use. Do not add popup token fallback unless a specific app has a documented need for it.
Prefer localStorage MSAL cache when users are not sharing browser profiles and smoother pass-through sign-in is the goal.
Use sessionStorage only when per-session sign-in loss is intentional.
On app startup:
handle the redirect promise first,
set the active account from the redirect result or auth events,
verify that the cached account can still silently acquire the app token,
if that silent acquisition fails, clear the stale MSAL cache and force a fresh redirect login.
Do not surface transient redirect/token-refresh states as user-facing feature errors while auth recovery is in progress.
For kiosk or shared-browser scenarios, document the exception and reconsider localStorage.
App registration permissions and admin consent (NON-NEGOTIABLE)
PSI has user consent DISABLED tenant-wide.policies/authorizationPolicy →
defaultUserRolePermissions.permissionGrantPoliciesAssigned carries only the two Teams
ManagePermissionGrantsForOwnedResource.* entries — there is no ManagePermissionGrantsForSelf.*.
Consequence: every delegated scope an app requests needs an admin grant, including the OIDC
scopes (openid, profile, email, offline_access). A user can consent to nothing.
So a new app registration with an empty requiredResourceAccess does not “just work” — it makes
every sign-in a consent request that dead-ends in the admin-approval flow. Nothing is declared, so
nothing can be consented.
For every new app registration:
Declare the delegated permissions the client actually requests. Match requiredResourceAccess
to the app’s MSAL loginRequest/scopes — including offline_access, which MSAL.js appends to
the authorize request automatically (check the live authorize URL if unsure).
If one app registration is both the SPA client and the API resource (the common PSI pattern —
it exposes access_as_user on api://<appId>and is the clientId the SPA signs in with),
the self-scope must also be declared. It is not implicitly consented, and it is usually the
piece that actually blocks token issuance.
Grant tenant-wide admin consent: az ad app permission admin-consent --id <appId> (or Entra →
App registrations → app → API permissions → Grant admin consent for ProgressiveSurface).
Verify two AllPrincipals grants exist — one on Graph, one on the app itself:
SP=$(az ad sp show --id <appId> --query id -o tsv)az rest --method GET \ --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?\$filter=clientId eq '$SP'" \ --query "value[].{consent:consentType,scope:scope}" -o tsv
The reference shape (PSI-Redbook-Web, PSI Docgen) is
User.Read openid profile email offline_access on Graph +access_as_user on itself.
Two traps worth knowing:
Grants read back stale for a minute or two after consent. Re-query before concluding a scope
is missing.
Do not chase an apparent gap with az ad app permission grant or a PATCH on the
oauth2PermissionGrant object. Creating a grant works with ordinary admin rights, but updating
an existing one needs DelegatedPermissionGrant.ReadWrite.All (Cloud Application Administrator /
Global Admin) and fails Authorization_RequestDenied otherwise. Re-running admin-consent after
declaring the full set is the correct move.
Adding a scope to an already-live app is the same drill: declare, then consent. Shipping a new
scope without an admin grant breaks sign-in for everyone, not just the new feature.
Identify whether the app is internal/private, EasyAuth-enforced, or a documented public exception.
Confirm the deployment path is GitHub Actions and does not depend on local-git, publish profiles, or basic publishing credentials.
Move new sensitive settings to Key Vault references instead of plain-text app settings.
Preserve or explicitly update the app’s approved auth profile.
For a new app registration, or any change to requested scopes: declare requiredResourceAccess
and grant tenant-wide admin consent, then verify the AllPrincipals grants. User consent is
disabled tenant-wide — skipping this breaks sign-in for everyone. See the section above.
Run the repo’s existing test/build steps, then trigger a real deploy validation (workflow_dispatch when available).
Validate the custom domain and relevant endpoints (/, /health, /api/health, or app-specific health endpoints).
Interpret 200, 401, and 403 against the intended auth posture; do not treat every 401 as a failure without checking the expected profile.
If production posture changed, update the supporting wiki pages and record any exception, incident, or follow-up action.
When an exception is required
If a change cannot meet the baseline, document:
the rule being excepted
the app and environment affected
the business reason
the owner
the expiry or next review point
Do not leave compliance deviations implicit.
MCP servers
MCP servers that expose an HTTP transport are subject to this standard. They tend to carry broad,
read-heavy access to internal data, so the baseline is private + authenticated by default:
Requirement
Rule
Network
Private only — no public exposure. Bind/firewall to private ranges; do not open the listener port to Any. A public FQDN requires a tracked exception.
Authentication
Enforce Entra ID bearer tokens on every route except /health and the OAuth discovery doc. The server is an OAuth 2.1 Resource Server: validate signature (tenant JWKS / RS256), iss, aud, and a required scope/role. No secret is needed server-side — resource-server validation uses public JWKS.
Discovery
Serve RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource and a 401 WWW-Authenticate: Bearer resource_metadata="…" so clients (Claude Code, Copilot) can discover the authorization server.
Fail closed
If auth config is missing, reject all protected requests rather than running open.
CORS
No wildcard Access-Control-Allow-Origin: *. MCP clients are not browsers; use an explicit allowlist or omit CORS.
App registration
One Entra app per MCP server (App ID URI api://<id>, exposed scope, e.g. Mcp.Invoke).
Client connection (Claude Code / Copilot). Either pre-configured OAuth (--client-id + --callback-port,
with authServerMetadataUrl pointing at the Entra tenant — Entra has no Dynamic Client Registration, so
clients must be pre-registered), or a headersHelper that mints a token per connection
(az account get-access-token --resource api://<id> --query "{Authorization:join(' ',['Bearer',accessToken])}" -o json).
The headersHelper path is preferred for internal LAN tooling: non-interactive, auto-refreshing, no stored secret.
stdio MCP servers are exempt from the network/auth rules — they run as a local child process over
stdio and never expose a network listener; they inherit the launching user’s identity.
Reference implementation: psi-machine (psi-machine-mcp, runs on PS-PROXY) — Entra bearer auth via
lib/auth.js, app registration PSI Machine MCP (api://0dfb7d0a-b815-4611-93ba-ccdf3213187b, scope Mcp.Invoke),
firewall scoped to private ranges. A consolidated mcp.progressivesurface.com gateway (path-routed, central
TLS/OAuth/PRM) is the future landing zone for any off-LAN access.
Current explicit exceptions and important decisions
erp-migration-api / dmt.progressivesurface.com is intentionally app-layer auth with platform auth disabled so root, health, and MCP endpoints can remain anonymously reachable as required.
ps-redbook-dashboard / quality.progressivesurface.com currently returns 401 anonymously by design under EasyAuth.
psredbookphotos and ps-shipphotos are intentionally public.
Supporting reference pages
shared-libraries — @psi/* SDK packages; @psi/auth ships this standard’s auth defaults by default
deploy-to-azure — Detailed deployment implementation standard