Update-EntraAppProxyCertificate.ps1

<#PSScriptInfo

.VERSION 1.6.2

.GUID 2bd6a2ed-2b0f-47f1-b7c1-9deb614c1554

.AUTHOR Richard Hicks

.COMPANYNAME Richard M. Hicks Consulting, Inc.

.COPYRIGHT Copyright (C) 2026 Richard M. Hicks Consulting, Inc. All Rights Reserved.

.LICENSE Licensed under the MIT License. See LICENSE file in the project root for full license information.

.LICENSEURI https://github.com/richardhicks/entraappproxy/blob/main/LICENSE

.PROJECTURI https://github.com/richardhicks/entraappproxy

.TAGS Entra, EntraID, AppProxy, ApplicationProxy, Certificate, TLS, SSL, MicrosoftGraph, Graph, Azure, AzureAD

#>


<#

.SYNOPSIS
    Updates the TLS certificate assigned to one or all Microsoft Entra application proxy applications published on a custom domain.

.DESCRIPTION
    Microsoft Entra application proxy applications published using a custom domain require a TLS certificate to be uploaded to the service. This script replaces that certificate using the Microsoft Graph API.

    The certificate must be supplied as a PFX file including the private key. When updating a single application by name, the subject or a subject alternative name must match the host name in the application's external URL.

    When the -UpdateAll switch is used, the script enumerates all application proxy applications in the tenant and updates every application whose external URL host matches a name in the supplied certificate. A wildcard certificate, a multi-SAN certificate, or a combination of both is supported. Applications are discovered using the Microsoft Entra on-premises application template. Applications created programmatically without this template are not discovered and must be updated individually using the -Name parameter.

    The signed in account requires the Cloud Application Administrator role (or higher) and the Directory.ReadWrite.All and Application.ReadWrite.All delegated Microsoft Graph permissions.

    Application proxy configuration is exposed only in the Microsoft Graph beta endpoint.

    Each run is recorded in a transcript written to $env:ProgramData\RMHCI\PowerShell with a date and time stamped file name.

    The script emits one result object per application to the pipeline and sets an exit code for callers that cannot consume pipeline output, such as Task Scheduler or CI/CD pipelines. Exit code 0 indicates all updates completed and were confirmed, 1 indicates the script terminated with an error before processing completed (returned when the script is run with powershell.exe or pwsh; when the script is called from within PowerShell, a terminating error is thrown instead), 2 indicates one or more updates failed, and 3 indicates one or more updates completed but could not be confirmed.

.PARAMETER Name
    The display name of the Microsoft Entra application proxy application. Required when updating a single application.

.PARAMETER UpdateAll
    Updates all application proxy applications in the tenant whose external URL matches a name in the supplied certificate. Wildcard and multi-SAN certificates are both supported. Applications published using the default msappproxy.net domain, applications published using Global Secure Access (Entra Private Access), and applications whose external URL does not match a name in the certificate are skipped.

.PARAMETER PfxFile
    The path to the PFX file containing the new TLS certificate and its private key.

.PARAMETER Password
    The password protecting the PFX file.

.PARAMETER TenantId
    The Microsoft Entra tenant ID or domain name. Optional. Required only when the signed in account has access to more than one tenant.

.PARAMETER SkipNameValidation
    Suppresses validation of the certificate subject and subject alternative names against the application's external URL. Valid only when updating a single application by name.

.INPUTS
    None. This script does not accept pipeline input.

.OUTPUTS
    PSCustomObject. One object is emitted to the pipeline for each application processed, with the following properties:

    Name - The display name of the application.
    Status - 'Updated', 'Unconfirmed', 'Skipped', or 'Failed'.
    Thumbprint - The thumbprint of the certificate reported by the service after the update. Null for skipped and failed applications.
    Expires - The expiration date of the certificate reported by the service after the update. Null for skipped and failed applications.
    Reason - The reason the application was skipped, failed, or could not be confirmed. Null for successful updates.

    No objects are emitted when -WhatIf is specified or when the script terminates with an error before processing begins.

.EXAMPLE
    $Password = Read-Host -Prompt 'Enter PFX password' -AsSecureString
    .\Update-EntraAppProxyCertificate.ps1 -Name 'hrwebapp' -PfxFile 'C:\Certs\hrwebapp.pfx' -Password $Password

    Updates the TLS certificate for the application proxy application named 'hrwebapp'.

.EXAMPLE
    .\Update-EntraAppProxyCertificate.ps1 -Name 'hrwebapp' -PfxFile 'C:\Certs\hrwebapp.pfx' -Password $Password -TenantId 'contoso.com' -Verbose

    Updates the TLS certificate for the application proxy application named 'hrwebapp' in the contoso.com tenant with verbose output.

.EXAMPLE
    .\Update-EntraAppProxyCertificate.ps1 -Name 'hrwebapp' -PfxFile 'C:\Certs\hrwebapp.pfx' -Password $Password -WhatIf

    Performs all validation and reports the change that would be made without updating the application.

.EXAMPLE
    .\Update-EntraAppProxyCertificate.ps1 -UpdateAll -PfxFile 'C:\Certs\wildcard.pfx' -Password $Password

    Updates the TLS certificate for all application proxy applications published on a custom domain whose external URL matches a name in the certificate.

.EXAMPLE
    .\Update-EntraAppProxyCertificate.ps1 -UpdateAll -PfxFile 'C:\Certs\wildcard.pfx' -Password $Password -WhatIf

    Lists the application proxy applications that would be updated without making any changes.

.LINK
    https://github.com/richardhicks/entraappproxy

.LINK
    https://learn.microsoft.com/en-us/graph/api/resources/onpremisespublishing?view=graph-rest-beta

.LINK
    https://learn.microsoft.com/en-us/entra/identity/app-proxy/how-to-configure-custom-domain

.NOTES
    Version: 1.6.2
    Creation Date: August 6, 2026
    Last Updated: November 9, 2026
    Author: Richard Hicks
    Organization: Richard M. Hicks Consulting, Inc.
    Contact: rich@richardhicks.com
    Website: https://www.richardhicks.com/

#>


#Requires -Version 5.1
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.0.0' }

[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByName')]
[OutputType([PSCustomObject])]

Param (

    [Parameter(Mandatory, ParameterSetName = 'ByName', HelpMessage = 'Enter the display name of the Entra application proxy application.')]
    [ValidateNotNullOrEmpty()]
    [string]$Name,

    [Parameter(Mandatory, ParameterSetName = 'UpdateAll', HelpMessage = 'Update all Entra application proxy applications matching the certificate.')]
    [switch]$UpdateAll,

    [Parameter(Mandatory, HelpMessage = 'Enter the path to the PFX file containing the new TLS certificate.')]
    [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
    [string]$PfxFile,

    [Parameter(Mandatory, HelpMessage = 'Enter the password for the PFX file.')]
    [ValidateNotNull()]
    [securestring]$Password,

    [Parameter(HelpMessage = 'Enter the Entra tenant ID or domain name.')]
    [string]$TenantId,

    [Parameter(ParameterSetName = 'ByName', HelpMessage = 'Skip validation of the certificate name against the application external URL.')]
    [switch]$SkipNameValidation

)

# Ensure verbose output is always displayed and captured in the transcript
$VerbosePreference = 'Continue'

# Create log directory if it doesn't exist
Write-Verbose 'Starting transcript...'
$LogPath = "$env:ProgramData\RMHCI\PowerShell"

If (-not (Test-Path -Path $LogPath)) {

    # Logging is exempt from -WhatIf so the transcript can always be created
    [void](New-Item -Path $LogPath -ItemType Directory -Force -WhatIf:$False)

}

# Start transcript. Exempt from -WhatIf so -WhatIf runs are always logged and Stop-Transcript in the Finally block always succeeds
Start-Transcript -Path "$LogPath\Update-EntraAppProxyCertificate_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -WhatIf:$False

# Record the invocation and bound parameters in the transcript for troubleshooting
Write-Verbose "Invocation: $($MyInvocation.Line.Trim())"
Write-Verbose 'Bound parameters:'
ForEach ($Parameter in $PSBoundParameters.GetEnumerator()) {

    Write-Verbose " -$($Parameter.Key): $($Parameter.Value)"

}

$ErrorActionPreference = 'Stop'
$GraphUri = 'https://graph.microsoft.com/beta/applications'
$AppProxyTemplateId = '8adf8e6e-67b2-4cf2-a259-e3dc5476c621'
$PlainTextPassword = $null

# Result collections populated during processing and reported in the summary displayed after the transcript stops
$Updated = [System.Collections.Generic.List[object]]::New()
$Skipped = [System.Collections.Generic.List[object]]::New()
$Failed = [System.Collections.Generic.List[object]]::New()

Function Invoke-GraphRequestWithRetry {

    [CmdletBinding()]

    Param (

        [hashtable]$RequestParams

    )

    $MaxAttempts = 4
    $Attempt = 0

    While ($True) {

        $Attempt++

        Try {

            Return Invoke-MgGraphRequest @RequestParams

        }

        Catch {

            $StatusCode = 0
            $Response = $_.Exception.Response

            If ($null -ne $Response) {

                $StatusCode = [int]$Response.StatusCode

            }

            If ($StatusCode -eq 429 -and $Attempt -lt $MaxAttempts) {

                $Delay = 10
                $RetryAfter = $Response.Headers.RetryAfter

                If ($null -ne $RetryAfter -and $null -ne $RetryAfter.Delta) {

                    $Delay = [int]$RetryAfter.Delta.TotalSeconds

                }

                Write-Warning "Request throttled by Microsoft Graph. Waiting $Delay seconds before retrying..."
                Start-Sleep -Seconds $Delay

            }

            Else {

                Throw

            }

        }

    }

}

Function Get-CertificateDnsName {

    [CmdletBinding()]
    [OutputType([string[]])]

    Param (

        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate

    )

    $DnsNames = [System.Collections.Generic.List[string]]::New()
    $SanExtension = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }

    If ($null -ne $SanExtension) {

        ForEach ($Line in ($SanExtension.Format($True) -split "`r`n|`n")) {

            ForEach ($Entry in ($Line -split ',')) {

                If ($Entry.Trim() -match '^DNS Name=(.+)$') {

                    $DnsNames.Add($Matches[1].Trim())

                }

            }

        }

    }

    If ($Certificate.Subject -match 'CN=([^,]+)') {

        $DnsNames.Add($Matches[1].Trim())

    }

    Return $DnsNames.ToArray()

}

Function Test-CertificateNameMatch {

    [CmdletBinding()]
    [OutputType([bool])]

    Param (

        [string]$HostName,
        [string[]]$DnsName

    )

    ForEach ($CandidateName in $DnsName) {

        If ($HostName -eq $CandidateName) {

            Return $True

        }

        # Wildcard names match a single label only, consistent with TLS certificate validation rules
        If ($CandidateName.StartsWith('*.')) {

            $Separator = $HostName.IndexOf('.')

            If ($Separator -gt 0 -and $HostName.Substring($Separator + 1) -eq $CandidateName.Substring(2)) {

                Return $True

            }

        }

    }

    Return $False

}

Function Set-AppProxyCertificate {

    # ShouldProcess is handled by the calling script before this function is invoked
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]

    Param (

        [string]$ApplicationId

    )

    $RequestParams = @{

        Method      = 'PATCH'
        Uri         = '{0}/{1}' -f $GraphUri, $ApplicationId
        Body        = $RequestBody
        ContentType = 'application/json'
        ErrorAction = 'Stop'

    }

    [void](Invoke-GraphRequestWithRetry -RequestParams $RequestParams)

    # Allow the service time to process the change before confirming
    Start-Sleep -Seconds 5

    $ConfirmParams = @{

        Method      = 'GET'
        Uri         = '{0}/{1}?$select=onPremisesPublishing' -f $GraphUri, $ApplicationId
        OutputType  = 'PSObject'
        ErrorAction = 'Stop'

    }

    Return (Invoke-GraphRequestWithRetry -RequestParams $ConfirmParams).onPremisesPublishing.verifiedCustomDomainCertificatesMetadata

}

Try {

    # Validate the file extension
    If ([System.IO.Path]::GetExtension($PfxFile) -notin '.pfx', '.p12') {

        Throw "The file '$PfxFile' does not appear to be a PFX file. A PFX file including the private key is required."

    }

    $PfxFile = (Resolve-Path -Path $PfxFile).ProviderPath

    # Convert the PFX password to plain text for certificate validation and the Graph request body
    $Bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)

    Try {

        $PlainTextPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($Bstr)

    }

    Finally {

        [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Bstr)

    }

    # Load the certificate and confirm the password and private key
    Write-Verbose "Reading certificate from $PfxFile..."

    Try {

        $Certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $PfxFile, $Password, ([System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet)

    }

    Catch {

        Write-Verbose 'Ephemeral key import failed. Falling back to the default key storage flags...'
        $Certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $PfxFile, $Password

    }

    If (-Not $Certificate.HasPrivateKey) {

        Throw 'The supplied PFX file does not contain a private key. Export the certificate again and include the private key.'

    }

    If ($Certificate.NotAfter -lt (Get-Date)) {

        Throw "The certificate expired on $($Certificate.NotAfter). Supply a valid certificate."

    }

    If ($Certificate.NotBefore -gt (Get-Date)) {

        Throw "The certificate is not valid until $($Certificate.NotBefore). Supply a currently valid certificate."

    }

    Write-Verbose "Certificate subject: $($Certificate.Subject)"
    Write-Verbose "Certificate thumbprint: $($Certificate.Thumbprint)"
    Write-Verbose "Certificate expires: $($Certificate.NotAfter)"

    $CertificateDnsNames = @(Get-CertificateDnsName -Certificate $Certificate)
    Write-Verbose "Certificate DNS names: $($CertificateDnsNames -join ', ')"

    # Connect to Microsoft Graph
    $Context = Get-MgContext

    If ($null -eq $Context) {

        Write-Verbose 'Connecting to Microsoft Graph...'

        $ConnectParams = @{

            Scopes      = 'Application.ReadWrite.All', 'Directory.ReadWrite.All'
            NoWelcome   = $True
            ErrorAction = 'Stop'

        }

        If ($PSBoundParameters.ContainsKey('TenantId')) {

            $ConnectParams.Add('TenantId', $TenantId)

        }

        Connect-MgGraph @ConnectParams
        $Context = Get-MgContext

    }

    Else {

        Write-Verbose "Using the existing Microsoft Graph connection for $($Context.Account)."

    }

    If ('Directory.ReadWrite.All' -notin $Context.Scopes) {

        Write-Warning 'The current Microsoft Graph session does not include the Directory.ReadWrite.All scope. The update may fail. Run Disconnect-MgGraph and try again if an authorization error occurs.'

    }

    # Build the request body once. The same certificate payload is used for every application.
    $Base64Certificate = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($PfxFile))

    $Body = @{

        onPremisesPublishing = @{

            verifiedCustomDomainKeyCredential      = @{

                type  = 'X509CertAndPassword'
                value = $Base64Certificate

            }

            verifiedCustomDomainPasswordCredential = @{

                value = $PlainTextPassword

            }

        }

    }

    $RequestBody = $Body | ConvertTo-Json -Depth 5 -Compress

    If ($UpdateAll) {

        # Enumerate application proxy applications using the on-premises application template
        Write-Verbose 'Enumerating application proxy applications...'

        $Filter = "applicationTemplateId eq '{0}'" -f $AppProxyTemplateId
        $Uri = '{0}?$filter={1}&$select=id,displayName&$count=true&$top=999' -f $GraphUri, [System.Uri]::EscapeDataString($Filter)
        $Applications = [System.Collections.Generic.List[object]]::New()

        While (-Not [string]::IsNullOrEmpty($Uri)) {

            $ListParams = @{

                Method      = 'GET'
                Uri         = $Uri
                Headers     = @{ ConsistencyLevel = 'eventual' }
                OutputType  = 'PSObject'
                ErrorAction = 'Stop'

            }

            $Result = Invoke-GraphRequestWithRetry -RequestParams $ListParams

            ForEach ($Application in $Result.value) {

                $Applications.Add($Application)

            }

            $Uri = $Result.'@odata.nextLink'

        }

        If ($Applications.Count -eq 0) {

            Throw "No application proxy applications were found in tenant $($Context.TenantId). Applications created without the Entra on-premises application template are not discovered and must be updated individually by name."

        }

        Write-Verbose "Found $($Applications.Count) application proxy application(s)."

        # Select applications with an external URL matching a name in the certificate
        $Targets = [System.Collections.Generic.List[object]]::New()

        ForEach ($Application in $Applications) {

            $GetParams = @{

                Method      = 'GET'
                Uri         = '{0}/{1}?$select=onPremisesPublishing' -f $GraphUri, $Application.id
                OutputType  = 'PSObject'
                ErrorAction = 'Stop'

            }

            $Publishing = (Invoke-GraphRequestWithRetry -RequestParams $GetParams).onPremisesPublishing

            If ($null -eq $Publishing -or [string]::IsNullOrEmpty($Publishing.externalUrl)) {

                Write-Verbose "Skipping '$($Application.displayName)'. The application is not configured for application proxy."
                $Skipped.Add([PSCustomObject]@{ Name = $Application.displayName; Status = 'Skipped'; Thumbprint = $null; Expires = $null; Reason = 'The application is not configured for application proxy' })
                Continue

            }

            # Global Secure Access (Entra Private Access) applications share the application proxy template but do not use TLS certificates
            If ($Publishing.isAccessibleViaZTNAClient -eq $True) {

                Write-Verbose "Skipping '$($Application.displayName)'. The application is published using Global Secure Access, not application proxy."
                $Skipped.Add([PSCustomObject]@{ Name = $Application.displayName; Status = 'Skipped'; Thumbprint = $null; Expires = $null; Reason = 'The application is published using Global Secure Access, not application proxy' })
                Continue

            }

            $ExternalHost = ([System.Uri]$Publishing.externalUrl).Host

            If ($ExternalHost -like '*.msappproxy.net') {

                Write-Verbose "Skipping '$($Application.displayName)'. The application is published using the default msappproxy.net domain."
                $Skipped.Add([PSCustomObject]@{ Name = $Application.displayName; Status = 'Skipped'; Thumbprint = $null; Expires = $null; Reason = 'The application is published using the default msappproxy.net domain' })
                Continue

            }

            If (-Not (Test-CertificateNameMatch -HostName $ExternalHost -DnsName $CertificateDnsNames)) {

                Write-Verbose "Skipping '$($Application.displayName)'. The certificate does not include a name matching '$ExternalHost'."
                $Skipped.Add([PSCustomObject]@{ Name = $Application.displayName; Status = 'Skipped'; Thumbprint = $null; Expires = $null; Reason = "The certificate does not include a name matching '$ExternalHost'" })
                Continue

            }

            $Targets.Add([PSCustomObject]@{ Id = $Application.id; Name = $Application.displayName; ExternalHost = $ExternalHost })

        }

        If ($Targets.Count -eq 0) {

            Throw "The certificate does not include a name matching the external URL of any application proxy application. Names found: $($CertificateDnsNames -join ', ')."

        }

        Write-Verbose "$($Targets.Count) application(s) selected for update."

        # Update each matching application. A failure on one application does not stop the batch.
        ForEach ($Target in $Targets) {

            If ($PSCmdlet.ShouldProcess($Target.Name, "Assign TLS certificate $($Certificate.Thumbprint) expiring $($Certificate.NotAfter)")) {

                Try {

                    Write-Verbose "Uploading certificate to application '$($Target.Name)'..."
                    $Metadata = Set-AppProxyCertificate -ApplicationId $Target.Id
                    $Confirmed = ($null -ne $Metadata -and $Metadata.thumbprint -eq $Certificate.Thumbprint)

                    If ($Confirmed) {

                        Write-Verbose "The TLS certificate for '$($Target.Name)' was updated successfully."
                        $Updated.Add([PSCustomObject]@{ Name = $Target.Name; Status = 'Updated'; Thumbprint = $Metadata.thumbprint; Expires = $Metadata.expiryDate; Reason = $null })

                    }

                    Else {

                        Write-Warning "The update request for '$($Target.Name)' completed but the assigned certificate could not be confirmed. Verify the assignment in the Microsoft Entra admin center."
                        $Updated.Add([PSCustomObject]@{ Name = $Target.Name; Status = 'Unconfirmed'; Thumbprint = $Metadata.thumbprint; Expires = $Metadata.expiryDate; Reason = 'The update request completed but the assigned certificate could not be confirmed' })

                    }

                }

                Catch {

                    Write-Warning "Failed to update the certificate for '$($Target.Name)'. $($_.Exception.Message)"
                    $Failed.Add([PSCustomObject]@{ Name = $Target.Name; Status = 'Failed'; Thumbprint = $null; Expires = $null; Reason = $_.Exception.Message })

                }

            }

        }

    }

    Else {

        # Locate the application object
        Write-Verbose "Searching for application '$Name'..."

        $Filter = "displayName eq '{0}'" -f $Name.Replace("'", "''")
        $Uri = '{0}?$filter={1}&$select=id,displayName' -f $GraphUri, [System.Uri]::EscapeDataString($Filter)
        $Result = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject

        If ($Result.value.Count -eq 0) {

            Throw "No application with the display name '$Name' was found in tenant $($Context.TenantId)."

        }

        If ($Result.value.Count -gt 1) {

            Throw "More than one application with the display name '$Name' was found. Resolve the ambiguity before continuing."

        }

        $ApplicationId = $Result.value[0].id
        Write-Verbose "Found application '$Name' with object ID $ApplicationId."

        # Retrieve the current application proxy configuration
        $Uri = '{0}/{1}?$select=onPremisesPublishing' -f $GraphUri, $ApplicationId
        $Publishing = (Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject).onPremisesPublishing

        If ($null -eq $Publishing -or [string]::IsNullOrEmpty($Publishing.externalUrl)) {

            Throw "The application '$Name' is not configured for Microsoft Entra application proxy."

        }

        # Global Secure Access (Entra Private Access) applications share the application proxy template but do not use TLS certificates
        If ($Publishing.isAccessibleViaZTNAClient -eq $True) {

            Throw "The application '$Name' is published using Global Secure Access, not application proxy. A TLS certificate cannot be assigned to this application."

        }

        $ExternalUrl = $Publishing.externalUrl
        $ExternalHost = ([System.Uri]$ExternalUrl).Host
        Write-Verbose "Application external URL: $ExternalUrl"

        If ($ExternalHost -like '*.msappproxy.net') {

            Throw "The application '$Name' is published using the default msappproxy.net domain. A TLS certificate can only be assigned to an application published using a custom domain."

        }

        If ($null -ne $Publishing.verifiedCustomDomainCertificatesMetadata) {

            Write-Verbose "Current certificate thumbprint: $($Publishing.verifiedCustomDomainCertificatesMetadata.thumbprint)"
            Write-Verbose "Current certificate expires: $($Publishing.verifiedCustomDomainCertificatesMetadata.expiryDate)"

        }

        # Validate the certificate name against the external URL
        If (-Not $SkipNameValidation) {

            If (-Not (Test-CertificateNameMatch -HostName $ExternalHost -DnsName $CertificateDnsNames)) {

                Throw "The certificate does not include a name matching the external URL host '$ExternalHost'. Names found: $($CertificateDnsNames -join ', '). Use -SkipNameValidation to override this check."

            }

            Write-Verbose "Certificate name validated against '$ExternalHost'."

        }

        # Update the certificate
        If ($PSCmdlet.ShouldProcess($Name, "Assign TLS certificate $($Certificate.Thumbprint) expiring $($Certificate.NotAfter)")) {

            Write-Verbose "Uploading certificate to application '$Name'..."
            $Metadata = Set-AppProxyCertificate -ApplicationId $ApplicationId
            $Confirmed = ($null -ne $Metadata -and $Metadata.thumbprint -eq $Certificate.Thumbprint)

            If ($Confirmed) {

                Write-Verbose "The TLS certificate for '$Name' was updated successfully."
                $Updated.Add([PSCustomObject]@{ Name = $Name; Status = 'Updated'; Thumbprint = $Metadata.thumbprint; Expires = $Metadata.expiryDate; Reason = $null })

            }

            Else {

                Write-Warning "The update request completed but the assigned certificate could not be confirmed. Expected thumbprint $($Certificate.Thumbprint) but the service reported '$($Metadata.thumbprint)'. Verify the assignment in the Microsoft Entra admin center."
                $Updated.Add([PSCustomObject]@{ Name = $Name; Status = 'Unconfirmed'; Thumbprint = $Metadata.thumbprint; Expires = $Metadata.expiryDate; Reason = 'The update request completed but the assigned certificate could not be confirmed' })

            }

        }

    }

}

Finally {

    If ($null -ne $PlainTextPassword) {

        $PlainTextPassword = $null

    }

    If ($null -ne $Certificate) {

        $Certificate.Dispose()

    }

    Remove-Variable -Name Body, RequestBody, Base64Certificate, PlainTextPassword -ErrorAction SilentlyContinue
    [System.GC]::Collect()

    # Stop transcript
    Write-Verbose 'Stopping transcript...'
    Stop-Transcript

}

# Display the results summary and emit result objects (skipped if the script exited early or -WhatIf was specified)
If (-Not $WhatIfPreference -and ($Updated.Count -gt 0 -or $Skipped.Count -gt 0 -or $Failed.Count -gt 0)) {

    # Human-readable summary written to the information stream so it does not mix with the object output. Per-application details are available in the result objects.
    Write-Information '' -InformationAction Continue
    Write-Information "Certificate update complete. Updated: $($Updated.Count). Skipped: $($Skipped.Count). Failed: $($Failed.Count)." -InformationAction Continue

    # Emit one result object per application for consuming automation
    Write-Output $Updated
    Write-Output $Skipped
    Write-Output $Failed

    # Set an exit code for schedulers and other callers that cannot consume the object output. Success falls through with exit code 0.
    If ($Failed.Count -gt 0) {

        Exit 2

    }

    If (@($Updated | Where-Object { $_.Status -eq 'Unconfirmed' }).Count -gt 0) {

        Exit 3

    }

}