Skip to content
The Lazy Administrator
  • Home
  • Disclaimer
  • Contact
  • About Me
  • Search Icon

The Lazy Administrator

Finding ways to do the most work with the least effort possible

Automated Alerts on Azure (Entra ID) Application Secret Expirations

Automated Alerts on Azure (Entra ID) Application Secret Expirations

December 16, 2023 Brad Wyatt Comments 43 comments

Table of Contents

  • Create a New Azure (Entra ID) Application
  • Assign Permissions
  • Create an App Secret
  • Build Our Monitor Logic
    • Connect to the Microsoft Graph API
    • Get All Applications
      • Pagination
    • Get Application Secret Expiration
    • Finding Expiring or Expired Secrets
    • Converting Time to Local Time Zone
      • Get the Current DateTime from a Specific Time Zone
      • Convert UTC to our Time Zone
    • Dealing with Multiple Secrets per Application
    • Bringing All The Application Expiration Logic Together
  • Sending the Alert to Email
    • Adding Send.Mail Permissions
    • Adding Send Email Logic
  • Sending the Alert to Microsoft Teams
    • Create a Channel Webhook
    • Send Alert to Teams
  • Automatic Serverless Automation
    • Create Automation Account and Runbook
    • Create Schedule
  • Obtain the Source Code

Monitoring Azure AD (Entra ID now) application secret expirations in an enterprise is a critical aspect of maintaining robust security and ensuring uninterrupted service. When application secrets expire without timely renewal, it can disrupt business operations by causing application failures. Proactive management of application secret expirations helps enterprises avoid last-minute issues, enabling a more secure and efficient operational environment.

During my brief research in finding an automated approach to monitoring application secret expirations, I found multiple write-ups and articles but many only showed the code on how to get the expiration property without walking through setting up the automation itself. Another issue was not converting the default UTC time to local time to get more accurate expiration datetimes, and also dealing with applications with multiple secrets that expire at different times.

This article will walk one through the code’s logic, including converting time and dealing with multiple values, and creating multiple different automated alerting systems.

The following was performed using PowerShell Core

Create a New Azure (Entra ID) Application

First, we need to connect to Azure using Connect-AzAccount

Once connected, we need to create our own Azure or Entra Application that we will use to connect to the Microsoft Graph REST API in order to report on the other applications.

In my example below, I am creating a new application called EntraAppMonitor. I am also getting the default tenant domain in order to create a generic Identifier URI.

$Domain = (Get-AzDomain).Domains | Where-Object { ($_ -like "*.onmicrosoft.com*") -and ($_ -notlike "*mail.*")} | Select-Object -First 1
$AppRegistrationSplat = @{
    DisplayName    = "EntraAppMonitor"
    IdentifierURIs = "http://Entraappmonitor.$Domain"
    ReplyUrls      = "https://www.localhost/"
}
$AzureADApp = New-AzADApplication @AppRegistrationSplat 

Going back to the Azure Portal, I can see my newly created Application.

Assign Permissions

Next, we need to give the application the permission, Application.Read.All which is the following ID: 9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30. Luckily, during the process of creating the application we stored the application information in the variable, $AzureADApp so we can call the objectID property of the application by using “$AzureADApp.ID“.

Note: The AppID of 00000003-0000-0000-c000-000000000000 is the application ID for the Microsoft Graph.

Add-AzADAppPermission -ObjectId $AzureADApp.ID -ApiId '00000003-0000-0000-c000-000000000000' -PermissionId "9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30" -Type Role

Jumping back into the Azure Portal, we now need to grant admin consent, allowing the application to be granted to permission we assigned. I can see in the status pane that admin consent is required but has not been granted.

To grant admin consent, first click “Grant admin consent…” and then click “Yes“

Create an App Secret

Next, we need to create an application secret to we can connect to the Microsoft Graph API. The secret is similar to a password so it must be protected and secured. In the example below I will create a secret that will expire in one (1) year. Using the variable, “$AzureADApp” that we created earlier, I can call the “AppID” property to fill in our Application ID value.

[System.DateTime]$startDate = Get-Date
[System.DateTime]$endDate = $startDate.AddYears(1)
$AppSecret = Get-AzADApplication -ApplicationId $AzureADApp.AppID | New-AzADAppCredential -StartDate $startDate -EndDate $endDate

By calling “AppSecret” we can retrieve different values such as the Secret itself. Take note of the SecretText value for later.

Note: You may have to call $AppSecret.SecretText to view the full secret text.

Build Our Monitor Logic

Connect to the Microsoft Graph API

The first thing we will want to do is build a PowerShell function to connect to the Microsoft Graph REST API using the application and secret that we created above.

Put in the Application ID of the application we made above, the Tenant ID of your Azure/Entra ID Tenant, and the Application Secret we created in the previous step.

Note: If you need to retrieve the application ID again, go to portal.azure.com > Microsoft Entra ID > App Registrations > [click you application]

$AppID = ''
$TenantID = ''
$AppSecret = ""

Function Connect-MSGraphAPI {
    param (
        [system.string]$AppID,
        [system.string]$TenantID,
        [system.string]$AppSecret
    )
    begin {
        $URI = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
        $ReqTokenBody = @{
            Grant_Type    = "client_credentials"
            Scope         = "https://graph.microsoft.com/.default"
            client_Id     = $AppID
            Client_Secret = $AppSecret
        } 
    }
    Process {
        Write-Host "Connecting to the Graph API"
        $Response = Invoke-RestMethod -Uri $URI -Method POST -Body $ReqTokenBody
    }
    End{
        $Response
    }
}

Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecre

TIP: If you are following along step by step, when running Connect-MSGraphAPI, store the results in a variable for our other API calls.

$tokenResponse = Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecret

Get All Applications

Next, we need to recursively retrieve all applications. We can do this because we granted our application the Application.Read.All permission so we just need to perform a GET at the /applications/ endpoint.

For this, I created a single Function that performs a GET request on an endpoint, this makes it so I can re-use this function for different endpoints.

Function Get-MSGraphRequest {
    param (
        [system.string]$Uri,
        [system.string]$AccessToken
    )
    begin {
        $ReqTokenBody = @{
            Headers = @{
                "Content-Type"  = "application/json"
                "Authorization" = "Bearer $($AccessToken)"
            }
            Method  = "Get"
            Uri     = $URI
        }
    }
    process {
        $Data = Invoke-RestMethod @ReqTokenBody
    }
    end {
        $Data
    }
}

Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"

Lets run ‘Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"‘ and store the results in the variable $Applications so we can view the properties easier.

$Applications = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"

Now I can run $Applications.value and see my different applications and their associated properties.

Pagination

API pagination is a method implemented in API design and development for handling the retrieval of extensive data sets in an organized and efficient way. This technique is particularly useful when an API endpoint needs to deliver a substantial volume of data. By using pagination, the data is segmented into smaller, easier-to-manage portions, often referred to as pages. Each of these pages holds a specific, limited quantity of records or entries.

Some larger enterprises may have a large quantity of applications, so we need to ensure that when we get all applications, there are no other pages to parse. The way we do that, is if the results contain ‘@odata.nextLink‘, grab the next pages URI and perform another GET method against that URI until there are no more pages.

In order to account for pagination, we will change our function to the code below:

Function Get-MSGraphRequest {
    param (
        [system.string]$Uri,
        [system.string]$AccessToken
    )
    begin {
        [System.Array]$allPages = @()
        $ReqTokenBody = @{
            Headers = @{
                "Content-Type"  = "application/json"
                "Authorization" = "Bearer $($AccessToken)"
            }
            Method  = "Get"
            Uri     = $Uri
        }
    }
    process {
        write-verbose "GET request at endpoint: $Uri"
        $data = Invoke-RestMethod @ReqTokenBody
        while ($data.'@odata.nextLink') {
            $allPages += $data.value
            $ReqTokenBody.Uri = $data.'@odata.nextLink'
            $Data = Invoke-RestMethod @ReqTokenBody
            # to avoid throttling, the loop will sleep for 3 seconds
            Start-Sleep -Seconds 3
        }
        $allPages += $data.value
    }
    end {
        Write-Verbose "Returning all results"
        $allPages
    }
}

$applications = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"

Get Application Secret Expiration

To view the application secret expiration, we can view the passwordcredentials property.

If we wanted to view the application name along with the passwordexpiration details, we can run the following:

$Applications.value | select displayname, passwordcredentials | format-list

The passwordCredentials results aren’t very human-friendly. This is because the property contains name-value pairs. passwordCredentials is of the type NoteProperty.

if we want to view the passwordCredentials and the application displayName we can run the following PowerShell code:

$Applications.value | select-object -ExpandProperty passwordcredentials -property @{name="ApplicationName"; expr={$_.displayName}}

Finding Expiring or Expired Secrets

By using New-Timespan we can determine if a secret is expiring or has expired already. For the purposes of this article, a secret will be close to expiring if it’s going to expire in 30 days or under.

(New-TimeSpan -Start (Get-Date)  -End ($_.passwordCredentials.endDateTime)).Days

There are two issues with the code below that I will dive into below:

  1. endDateTime is always UTC time
  2. Get-Date may not be your time zone. If you end up running this in a serverless runbook that is hosted in the Eastern Time Zone but you are located in the Central Time Zone, Get-Date will display the eastern time.

Now, since we are dealing with days and not hours, this most likely wont be a problem. But depending on what you are monitoring or alerting on, this could be an issue, so for our article lets convert all DateTime objects to our time zone.

Converting Time to Local Time Zone

As mentioned above, there are two DateTime objects that we need to convert to our time zone. First, we need to run Get-Date and ensure that it is getting the date for our time zone (central standard time for my case). Second, we need to convert endDateTime from UTC to CST.

Get the Current DateTime from a Specific Time Zone

To get the current date and time for a specific time zone (in my case Central Standard Time) we can run the following:

([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")

Convert UTC to our Time Zone

Next, we need to convert endDateTime from UTC to our Time Zone. To do this we can feed it the endDateTime value.

$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.passwordCredentials.endDateTime, 'Central Standard Time')

Dealing with Multiple Secrets per Application

When looking up similar articles, many just parse each application, get the secret expiration, and then see how many days until it expires. But some applications may contain multiple secrets, it’s not as common (think of a user/service account having multiple passwords) but I have seen it with different companies.

So, we must see if passwordCredentials.endDateTime contains more than 1 value. Below is a snippet (will not work on its own) of that logic including the timezone conversions.

if ($_.passwordCredentials.endDateTime.count -gt 1) {
        $endDates = $_.passwordCredentials.endDateTime
        [int[]]$daysUntilExpiration = @()
        foreach ($Date in $endDates) {
            $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($Date, 'Central Standard Time')
            $daysUntilExpiration += (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
        }
    }

Bringing All The Application Expiration Logic Together

Remember earlier when we got all the application, we stored those results in a variable called Applications. Now we can iterate through that array and output our application ID, Name and how many days until each secret is set to expire, while also converting everything to our local time zone.

$Applications.value | Sort-Object displayName | Foreach-Object {
    #If there are more than one password credentials, we need to get the expiration of each one
    if ($_.passwordCredentials.endDateTime.count -gt 1) {
        $endDates = $_.passwordCredentials.endDateTime
        [int[]]$daysUntilExpiration = @()
        foreach ($Date in $endDates) {
            $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($Date, 'Central Standard Time')
            $daysUntilExpiration += (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
        }
    }
    Elseif ($_.passwordCredentials.endDateTime.count -eq 1) {
        $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.passwordCredentials.endDateTime, 'Central Standard Time')
        $daysUntilExpiration = (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days 
    }
    $_ | Select-Object id, displayName, @{
        name = "daysUntil"; 
        expr = { $daysUntilExpiration } 
    } 
}

Sending the Alert to Email

One of the available options of sending the alert is sending it via e-mail, which is the more traditional way. Since we are already interfacing with the Microsoft Graph REST API, we will be sending the e-mail through the API and then using Azure Serverless Automation (runbooks) to run on a set schedule.

Adding Send.Mail Permissions

First, we must grant our application the Send.Mail permission. For this I will be going through the Azure Portal instead of through PowerShell (only because we have already seen how to do it via PS).

One thing to note: to send email using the Microsoft Graph, you must send as a licensed user. In my case I will send it as myself ([email protected])

Go to portal.azure.com and then Microsoft Entra ID > App Registrations > and then click on your application that you created earlier and finally click “API Permissions” on the left pane.

Click + Add a Permission

Click Microsoft Graph > Application Permission and add Mail.Send

Once you have added the permissions, click Grant admin consent for... to apply the permissions.

Adding Send Email Logic

Next, we need to create a new PowerShell function that will send our email alert out. The below function will send an HTML email that contains a table of our application. When calling this function you will need to give it the URI (endpoint), AccessToken, To (who the email, or where the email goes to), and the Body (which the script will auto feed and format)

Note: The code below will not work on its own as we are not passing the accessToken, Body or URI yet. It relies on data from other functions. The code is shown to show you the logic behind sending the email.

Function Send-MSGraphEmail {
    param (
        [system.string]$Uri,
        [system.string]$AccessToken,
        [system.string]$To,
        [system.string]$Subject = "App Secret Expiration Notice",
        [system.string]$Body
    )
    begin {
        $headers = @{
            "Authorization" = "Bearer $($AccessToken)"
            "Content-type"  = "application/json"
        }

        $BodyJsonsend = @"
{
   "message": {
   "subject": "$Subject",
   "body": {
      "contentType": "HTML",
      "content": "$($Body)"
   },
   "toRecipients": [
      {
      "emailAddress": {
      "address": "$to"
          }
      }
   ]
   },
   "saveToSentItems": "true"
}
"@
    }
    process {
        $data = Invoke-RestMethod -Method POST -Uri $Uri -Headers $headers -Body $BodyJsonsend
    }
    end {
        $data
    }
}

On the Outlook side here is what the email looks like:

Sending the Alert to Microsoft Teams

Next, I want to automate this to run daily and alert me in Microsoft Teams if any secrets are set to expire in thirty (30) days or less.

Luckily, I already have a Microsoft Team and channel that I send all of my enterprise alerting to (seen below)

Create a Channel Webhook

In order to send my alert to a Microsoft Teams Channel, I need to get the channels webhook information.

Go to the channel that you chose and navigate to Manage Channel > Connectors and then click Configure.

Give you Webhook a name and icon (optional) and then click Create

Notate the URI information and save it for later, we will need it in a later step.

Back in the channel chat you should see a message regarding your newly created webhook.

Send Alert to Teams

A new If statement declares that if the daysUntil value is less than, or equal to ’30’ to add the daysUntil value, id, and displayName of the application in an array called array.

Next, we need to convert the array object into HTML by using ConvertTo-HTML. Lastly, I pasted in my webhook url to the $uri variable.

$array = @()
$applications = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"
$Applications.value | Sort-Object displayName | Foreach-Object {
    #If there are more than one password credentials, we need to get the expiration of each one
    if ($_.passwordCredentials.endDateTime.count -gt 1) {
        $endDates = $_.passwordCredentials.endDateTime
        [int[]]$daysUntilExpiration = @()
        foreach ($Date in $endDates) {
            $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($Date, 'Central Standard Time')
            $daysUntilExpiration += (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
        }
    }
    Elseif ($_.passwordCredentials.endDateTime.count -eq 1) {
        $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.passwordCredentials.endDateTime, 'Central Standard Time')
        $daysUntilExpiration = (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days 
    }

    if ($daysUntilExpiration -le 30) {
        $array += $_ | Select-Object id, displayName, @{
            name = "daysUntil"; 
            expr = { $daysUntilExpiration } 
        }
    }
}

$textTable = $array | Sort-Object daysUntil | select-object displayName, daysUntil | ConvertTo-Html
$JSONBody = [PSCustomObject][Ordered]@{
    "@type"      = "MessageCard"
    "@context"   = "<http://schema.org/extensions>"
    "themeColor" = '0078D7'
    "title"      = "$($Array.count) App Secrets areExpiring Soon"
    "text"       = "$textTable"
}

$TeamMessageBody = ConvertTo-Json $JSONBody

$parameters = @{
    "URI"         = 'https://bwya77.webhook.office.com/webhookb2/eee030b9-93ef-4fae-add9-17bf369d1101@6438b2c9-54e9-4fce-9851-f00c24b5dc1f/IncomingWebhook/e322889da8ed47c4b3211f256f8ac57d/5bcffade-2afd-48a2-8096-390a9090555c'
    "Method"      = 'POST'
    "Body"        = $TeamMessageBody
    "ContentType" = 'application/json'
}

Invoke-RestMethod @parameters

Jumping back to Teams I can see my newly created alert in my Teams Channel.

Automatic Serverless Automation

Create Automation Account and Runbook

Next step is to add this to a PowerShell runbook, this way I am not hardcoding secrets, and its running on a schedule.

Go to the Azure Portal > Automation Accounts and create a new one or use an existing one.

Next, go to your automation account and click Variables. I will be adding three variables:

  1. tenantID
  2. appSecret
  3. appID

Make sure you click Yes for encryption.

Change the PowerShell Script to get the newly created automation variables.

$AppID = Get-AutomationVariable -Name 'appID' 
$TenantID = Get-AutomationVariable -Name 'tenantID'
$AppSecret = Get-AutomationVariable -Name 'appSecret' 

Next, I will create a new runbook

Next, I will paste the code from below to my newly created runbook. Make sure you have your webhook url to the variable teamswebhookURI. (this is only applicable if you are doing the Microsoft Teams alerting)

  • Teams Notification Script
  • Email Notification Script

When finished, click Publish.

Create Schedule

We now need to create a schedule so this runbook can run on a regular cadence. In the runbook click Schedules and then create a schedule that best fits your needs. In my example I have it running daily at 8:00 AM.

Obtain the Source Code

The scripts are hosted on my GitHub . Feel free to send in issues and contribute to the project as well.

  • Teams Notification Script
  • Email Notification Script
Brad Wyatt
Brad Wyatt

My name is Bradley Wyatt; I am a 5x Microsoft Most Valuable Professional (MVP) in Microsoft Azure and Microsoft 365. I have given talks at many different conferences, user groups, and companies throughout the United States, ranging from PowerShell to DevOps Security best practices, and I am the 2022 North American Outstanding Contribution to the Microsoft Community winner.


Graph, PowerShell
API, Automation, Azure, Graph, JSON, Office 365, PowerShell

Post navigation

PREVIOUS
The Microsoft Graph Command-Line Interface (CLI)
NEXT
Automatically Schedule Microsoft Teams Do Not Disturb Presence Based on Outlook Calendar Events

43 thoughts on “Automated Alerts on Azure (Entra ID) Application Secret Expirations”

  1. Dinesh says:
    December 17, 2023 at 5:14 pm

    Your article is truly inspiring and informative. It’s commendable of you to share your knowledge with others and contribute to their growth. I’m grateful for the valuable insights you’ve provided and look forward to implementing them in my own endeavours. Thank you for being such a valuable resource.

    Reply
  2. Paul says:
    December 17, 2023 at 6:17 pm

    Great detailed solution, I’m planning something similar in my environment and this will help me get started. I prefer automation runbooks over logic apps which is what I’ve seen similar efforts use. What are your thoughts on using the automation account identity for access instead of creating the app registration?

    Reply
    1. Brad Wyatt says:
      December 17, 2023 at 8:19 pm

      I dont know of any downsides to using a Managed Identity – I am just used to going straight for the enterprise app

      Reply
  3. Robert Hulse says:
    December 17, 2023 at 10:56 pm

    I couldnt get the Get-MSGraphRequest method to work, the Runbook kept failing. I had to change it to:

    # Function to make HTTP request to Microsoft Graph API
    function Invoke-GraphApiRequest {
    param (
    [string]$Uri,
    [string]$AccessToken
    )

    $headers = @{
    ‘Authorization’ = “Bearer $AccessToken”
    ‘Content-Type’ = ‘application/json’
    }

    return Invoke-RestMethod -Uri $Uri -Method GET -Headers $headers
    }

    # Get applications using Microsoft Graph API
    $applicationsUri = “https://graph.microsoft.com/v1.0/applications/”
    $applications = Invoke-GraphApiRequest -Uri $applicationsUri -AccessToken $tokenResponse.access_token

    # Process each application
    $applications.value | Sort-Object displayName | Foreach-Object {
    $expirationDates = $_.passwordCredentials.endDateTime

    }

    Reply
    1. Brad Wyatt says:
      December 18, 2023 at 5:52 pm

      I had updated the entire runbook last night which mightve fixed whatever you were running into. (I was calling the incorrect function to get the encrypted vars)

      Reply
  4. TomiS says:
    December 20, 2023 at 3:20 pm

    Hi @Brad
    There is still a problem with function ,,Get-MSGraphRequest”. I tried using your script using local versions of PowerShell (version PS 5.1 & 7.1) in my VM. TThen I tried to use the automatic account, unfortunately to no avail. Locally, the problem is that the function does not work properly and does not collect application data. However, in Automate Account there is a problem with “Exception of type ‘System.OutOfMemoryException’ was thrown”.
    The script will be very helpful, so I care about its correct operation.

    Reply
    1. Brad Wyatt says:
      December 27, 2023 at 9:04 pm

      I was handling pagination incorrectly, the new updated scripts on my github address this

      Reply
  5. Daniel says:
    December 21, 2023 at 6:41 am

    Great post, really clear guide. Can you advise of the upside of doing this (and other similar processes) using an Azure Runbook rather than a Logic App? I already have something like this setup using a Logic App and Managed Identity. Curious to see if I’m better switching this to a runbook and using this method in the future.
    Thanks

    Reply
    1. Brad Wyatt says:
      December 27, 2023 at 9:12 pm

      If you aren’t having issues with the Logic App I’d probably suggest staying with it.
      Depending how often you are running you Logic App(s), I could see the logic app incurring more cost than this automation as it may take slightly longer to run to completion and Logic Apps charges on Memory, vCPU, and connectors

      Reply
  6. Mateusz says:
    December 22, 2023 at 12:16 pm

    Script is hangs on $applications. When i don’t add -Scopes “Application.Read.All” in $tokenResponse then is hanging on $tokenResponse. Any idea?

    Reply
    1. Brad Wyatt says:
      December 27, 2023 at 9:04 pm

      I was handling pagination incorrectly, the new updated scripts on my github address this

      Reply
      1. Mateusz says:
        December 31, 2023 at 1:08 pm

        Thanks for script update. Now i have a problem with time convert. I using “Central European Standard Time” and powershell showing me two types of errors:

        Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”. $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.pass …
        + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        + CategoryInfo : NotSpecified: (:) [], MethodException
        + FullyQualifiedErrorId : MethodCountCouldNotFindBest

        Second:
        New-TimeSpan : Cannot bind parameter ‘End’ to the target. Exception setting “End”: “Cannot convert null to type “System.DateTime”.” … ([DateTime]::Now, “Central European Standard Time”)) -End $Date).Days
        + ~~~~~
        + CategoryInfo : WriteError: (:) [New-TimeSpan], ParameterBindingException
        + FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.PowerShell.Commands.NewTimeSpanCommand

        Can you help me also in this one?

        Reply
        1. Mateusz says:
          January 7, 2024 at 4:18 am

          @Brad any help?

          Reply
          1. Brad Wyatt says:
            January 23, 2024 at 9:50 pm

            Worked with Mateusz on this and its been resolved – updated code is on GitHub

          2. Jeremy Metcalf says:
            December 16, 2024 at 12:26 pm

            Could you share the fix for the first error? Still getting the same issue, even using the freshest code from github. Thanks!

  7. Sriray says:
    January 5, 2024 at 11:40 am

    Can this be modified to send the alert email to the owner of the SP instead of sending it to a specific email ID?

    Reply
  8. abwi says:
    January 12, 2024 at 7:50 am

    Hi,
    It seems that when running the script using the registered app and secret, only returns a part of the applications.
    When i run the same code authenticating with my own user, i get far more applications.
    Any idea why this is?
    The app has the Application.Read.All permission.

    Reply
    1. Brad Wyatt says:
      January 23, 2024 at 9:48 pm

      I had an error with pagination, its been resolved and the code is on GitHub

      Reply
  9. Erik Wold says:
    February 1, 2024 at 1:20 am

    Great script. Is it possible to only return in mail secrets that are below the days set? Now it also returns (some randomly?) secrets that are already expired.

    Reply
  10. Dylan Morley says:
    February 1, 2024 at 4:22 am

    Nice article, good work.

    You know what would be really useful – a property exposed on an app registration for “token last issued” or “last activity date”. You can have some app registrations that aren’t actively being used, and that’s often a question you have to track down – “is this still being used, does it need updating”. Some way of easily understanding your active app registrations, and therefore able to easily understand the impact of an outage would help prioritise this stuff. Feature request to MS I think

    Reply
  11. Valentino says:
    February 7, 2024 at 6:42 pm

    Hi Brad! Thank you so much for this tutorials! very helpful for my new project! I did the comparison between the code from your github and the code on your website, are those two supposed to be the same or different? Thanks!

    Reply
    1. Brad Wyatt says:
      February 7, 2024 at 9:53 pm

      Github has the most recent code. It contains bug fixes and improvements since I wrote the blog post

      Reply
      1. Valentino says:
        February 9, 2024 at 9:51 am

        Hi Brad, how do we remove the one that says -300 or -123 from the result?

        Reply
      2. kranthiA says:
        July 24, 2024 at 8:39 pm

        Hi Brad can you please post the updated Repo for this

        Reply
  12. Valentino says:
    February 14, 2024 at 1:54 pm

    Hi Brad, we getting this error now for some reason when running the script…
    Connecting to the Graph API
    Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
    At line:116 char:13
    + $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_. …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : NotSpecified: (:) [], MethodException
    + FullyQualifiedErrorId : MethodCountCouldNotFindBest

    Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
    At line:116 char:13
    + $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_. …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : NotSpecified: (:) [], MethodException
    + FullyQualifiedErrorId : MethodCountCouldNotFindBest

    Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
    At line:116 char:13
    + $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_. …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : NotSpecified: (:) [], MethodException
    + FullyQualifiedErrorId : MethodCountCouldNotFindBest

    Reply
  13. Elijah Kurk says:
    February 21, 2024 at 3:28 pm

    Running into an issue with this:
    (New-TimeSpan -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days
    New-TimeSpan : Cannot bind parameter ‘End’ to the target. Exception setting “End”: “Cannot convert null to type
    “System.DateTime”.”
    At line:1 char:39
    + … an -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : WriteError: (:) [New-TimeSpan], ParameterBindingException
    + FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.PowerShell.Commands.NewTimeSpanCommand

    Reply
  14. Kevin says:
    February 23, 2024 at 7:17 am

    Thank you for this tutorial.

    Here is the “t” missing:
    Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecre

    Reply
  15. Kevin says:
    February 23, 2024 at 7:41 am

    When I set “(New-TimeSpan -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days” I get following error:
    New-TimeSpan: The End parameter cannot be bound to the target. Exception when setting End: “NULL cannot be in type System.DateTime”
    be converted.

    Reply
    1. M Sameer says:
      May 8, 2024 at 4:27 am

      Excellent Article, But when I tried facing the below error message:Invoke-RestMethod: Line | 52 | $data = Invoke-RestMethod @ReqTokenBody | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | {“error”:{“code”:”Authorization_RequestDenied”,”message”:”Insufficient privileges to complete the operation.”,”innerError”:{“date”:”2024-05-08T10:21:02″,”request-id”:”c–“,”client-request-id”:”—-“}}}
      Could you kindly guide me how to fix this

      Reply
  16. Vahur says:
    March 11, 2024 at 2:54 am

    Hi,

    had the same problem with the New-Timespan.
    Did some troubleshooting, and found that it’s actually to do with the FOREACH cycle’s logic.
    Two of them are actually not needed, so one of them provides empty “.endDateTime” value.
    Did some changes there and used only one foreach element – and it works fine now.
    Great solution BTW!

    Reply
  17. Jon says:
    April 2, 2024 at 5:07 pm

    For those having issues with error:
    Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.

    I found that if I cast the endDateTime value to [DateTime] it fixed it for me:
    $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]$_.endDateTime, ‘Central Standard Time’)

    Reply
  18. Ganesh says:
    June 1, 2024 at 7:48 am

    Hi Brad,
    Thank you so much for this tutorials! very helpful.
    Can we also monitor certificate expiration ?

    Thank you
    Ganesh

    Reply
  19. HT says:
    June 6, 2024 at 2:09 am

    This was awesome! I found the formatting got messed up in sending the email to the help desk. Sent as an attachment instead and added the app id.

    Function Send-MSGraphEmail {
    param (
    [system.string]$Uri,
    [system.string]$AccessToken,
    [system.string]$To,
    [system.string]$Subject = “App Secret Expiration Notice”,
    [system.string]$Body,
    [system.string]$AttachmentPath
    )
    begin {
    $headers = @{
    “Authorization” = “Bearer $($AccessToken)”
    “Content-type” = “application/json”
    }

    $BodyJsonsend = @”
    {
    “message”: {
    “subject”: “$Subject”,
    “body”: {
    “contentType”: “HTML”,
    “content”: “$($Body)”
    },
    “toRecipients”: [
    {
    “emailAddress”: {
    “address”: “$to”
    }
    }
    ],
    “attachments”: [
    {
    “@odata.type”: “#microsoft.graph.fileAttachment”,
    “name”: “$(Split-Path $AttachmentPath -Leaf)”,
    “contentBytes”: “$(Get-Content -Path $AttachmentPath -Raw | ForEach-Object { [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($_)) })”
    }
    ]
    },
    “saveToSentItems”: “true”
    }
    “@
    }
    process {
    $data = Invoke-RestMethod -Method POST -Uri $Uri -Headers $headers -Body $BodyJsonsend
    }
    end {
    $data
    }
    }

    $tokenResponse = Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecret

    $array = @()
    $apps = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri “https://graph.microsoft.com/v1.0/applications/”
    foreach ($app in $apps) {
    $app.passwordCredentials | foreach-object {
    #If there is a secret with an enddatetime, we need to get the expiration of each one
    if ($_.endDateTime -ne $null) {
    [system.string]$secretdisplayName = $_.displayName
    [system.string]$id = $app.id
    [system.string]$displayname = $app.displayName
    $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.endDateTime, ‘Central Standard Time’)
    [int32]$daysUntilExpiration = (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, “Central Standard Time”)) -End $Date).Days

    if (($daysUntilExpiration -ne $null) -and ($daysUntilExpiration -le $expirationDays)) {
    $array += $_ | Select-Object @{
    name = “id”;
    expr = { $id }
    },
    @{
    name = “displayName”;
    expr = { $displayName }
    },
    @{
    name = “secretName”;
    expr = { $secretdisplayName }
    },
    @{
    name = “daysUntil”;
    expr = { $daysUntilExpiration }
    }
    }
    $daysUntilExpiration = $null
    $secretdisplayName = $null
    }
    }
    }

    if ($array.Count -gt 0) {
    # Export expiring secrets to a CSV file
    $array | Export-Csv -Path “C:ExpiringSecrets.csv” -NoTypeInformation

    # Send email with CSV attachment
    $emailSubject = “App Secret Expiration Notice”
    $emailBody = “Please find the expiring secrets in the attached CSV file.”

    # Construct email parameters
    $emailParams = @{
    Uri = “https://graph.microsoft.com/v1.0/users/$emailSender/sendMail”
    AccessToken = $tokenResponse.access_token
    To = $emailTo
    Subject = $emailSubject
    Body = $emailBody
    AttachmentPath = “ExpiringSecrets.csv”
    }

    # Send email with attachment
    Send-MSGraphEmail @emailParams
    }
    else {
    Write-Output “No apps with expiring secrets”
    }

    Reply
  20. x says:
    June 27, 2024 at 7:12 am

    Just a minor typo, but there’s a “t” missing and the end of this line :

    “Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecre” should read “Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecret”

    😉

    Reply
  21. Toyin says:
    July 16, 2024 at 9:29 am

    With the new update from microsoft to retire O365 connectors, you will no longer be able to use the webhooks in teams above but instead you would need to modify the script above just a little so you can use the power platform workflows. The edited part is below for anyone who might be having this issue:

    if ($array.count -ne 0) {
    Write-output “Sending Teams Message”
    $textTable = $array | Sort-Object daysUntil | select-object displayName, secretName, daysUntil | ConvertTo-Html
    $JSONBody = @{
    type = “message”
    attachments = @(
    @{
    contentType = “$textTable”
    content = @{
    “$schema” = “http://adaptivecards.io/schemas/adaptive-card.json”
    type = “AdaptiveCard”
    version = “1.2”
    }
    }
    )
    }

    This allows you to send a POST to the webhook for the target teams channel.

    Reply
    1. Nimmy says:
      July 18, 2024 at 5:22 pm

      Hey Toyin,

      I’m getting an error on invoke-restmethod with your fix. Are you sure you haven’t done any other changes as well?

      I removed the $JSONBody from the original script, but kept the following:

      $TeamMessageBody = ConvertTo-Json $JSONBody

      $parameters = @{
      “URI” = $teamsWebhookURI
      “Method” = ‘POST’
      “Body” = $TeamMessageBody
      “ContentType” = ‘application/json’
      }

      Getting the following error:
      Cannot convert ‘System.Object[]’ to the type ‘System.String’ required by parameter ‘ContentType’. Specified method is not supported.

      Reply
    2. John says:
      July 31, 2024 at 2:37 pm

      Hey Toyin, By any chance do you have the complete setup with details for the new alternate?
      TIA

      Reply
  22. kranthiA says:
    July 24, 2024 at 6:06 pm

    Hi Toyin, It was very useful post to get the events from enterprise application . My question is can we set up the altering to SLACK Channel , if yes how we can do that .

    Reply
  23. John says:
    July 31, 2024 at 2:31 pm

    teams notification alerts is not going to work. Because the webhook connector is soon going to deprecate. Msft is suggesting using power-automate workflows to replace them.

    Reply
    1. K R SHASHIVARDHAN says:
      October 9, 2024 at 8:43 pm

      How can we do with power-automate workflows?

      Reply
  24. Mark says:
    October 4, 2024 at 5:01 am

    I want to follow this but there are so many things that doesn’t make sense to someone who is not well-versed in Powershell or comfortable with the Cloud shell. I am running into errors immediately that I don’t know how to resolve. But thanks anyway, inspiring content!

    Reply
  25. Tom says:
    December 26, 2024 at 4:23 pm

    Thank you

    Reply
  26. eriwol says:
    April 9, 2025 at 5:08 am

    Hi,
    Is it possible to add multiple recipients to the email? Can’t figure it out 🙂

    Reply

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Subscribe

Email


Categories

  • Active Directory (8)
  • AI (3)
  • API (1)
  • AutoPilot (2)
  • Azure (15)
  • Bicep (4)
  • Connectwise (1)
  • Defender for Cloud Apps (1)
  • Delegated Admin (1)
  • DevOps (6)
  • Graph (6)
  • Intune (15)
  • LabTech (1)
  • Microsoft Teams (6)
  • Office 365 (19)
  • Permissions (2)
  • PowerShell (50)
  • Security (1)
  • SharePoint (3)
  • Skype for Business (1)
  • Terraform (1)
  • Uncategorized (2)
  • Yammer (1)

Recent Comments

  • Mike D on Upload a file to Connectwise and Attach it to a Service Ticket with PowerShell
  • Side Eye on Homeland Security’s Trusted Travelers API and PowerShell – Getting a Better Global Entry Interview Using PowerShell
  • Lisa on Allow Non-Admin Users to Manage Their Desktop Icons Using Intune
  • A1 Lottery LOGIN on Get a New Computer’s Auto Pilot Hash Without Going Through the Out of Box Experience (OOBE)
  • Kristopher Gates on Getting Started with GitHub Copilot in the CLI

1,753,001 People Reached

© 2025   All Rights Reserved.