How many of your app registrations have a client secret expiring next week - and does anyone actually know? Not "does someone know it will happen eventually." Does anyone, right now, have a list with a date on it? For most tenants the honest answer is no. There is no default email, no built-in nag, no dashboard tile that says "this integration dies in six days." The secret just quietly stops working, at whatever hour it happens to cross its expiry timestamp, and the first anyone hears about it is a broken integration and a confusing error in a log file.
This post explains what an app registration and a client secret actually are in plain English, why expiry tracking falls through the cracks even in well-run tenants, how to manually check one app's credentials before you trust any script's output, and then gives you a complete PowerShell script that audits every app registration in the tenant and tells you exactly which credentials are dead, dying, or fine.
Get-AppRegistrationExpiryReport.ps1 is free and open source. Download it from the Imran76Awan/Daily-Tasks repository on GitHub - no sign-in required. This script is read-only - it audits and reports, it never rotates, deletes, or modifies a credential.
The problem: a secret expires and nothing announces it
Before anything else, two definitions, because the rest of this post is meaningless without them. An app registration is Entra ID's record of an application that needs to talk to Microsoft Graph, sign users in, or call an API - think of a payroll integration, a monitoring tool, or an internal script that pulls device data. When you register that application, Entra ID gives it an application (client) ID, a GUID - a globally unique identifier, a 128-bit value written as 32 hex characters in five dashed groups - that identifies the app the way a username identifies a person.
A client secret is one way that app proves it really is that app when it authenticates - functionally, a password that Entra ID generated for the application rather than a human. A certificate credential does the same job with a public/private key pair instead of a shared secret. Either way, Entra ID stamps every credential with a start date and an end date when it is created. Nothing forces that end date to be more than two years away. Nothing forces anyone to write it down anywhere else. Nothing sends a warning email by default.
So here is the symptom, verbatim, that a reader sees the moment the last valid credential on an app crosses its expiry date and something tries to authenticate with it:
AADSTS7000215: Invalid client secret provided. Ensure the secret being sent in the request is the client secret value, not the client secret ID, for a secret added to app 'a1b2c3d4-e5f6-4789-a0b1-c2d3e4f56789'. Trace ID : 8f2e1a90-6c3d-4b5a-9e7f-1a2b3c4d5e6f Correlation : 4d5c6b7a-8e9f-0a1b-2c3d-4e5f60718293 Timestamp : 2026-07-22 06:14:02Z
Notice the wording. Entra ID's error text nudges you toward the wrong diagnosis first - "check you're sending the value, not the ID" - because that is the more common mistake when a secret is freshly created and simply pasted in wrong. But when the integration has been running fine for eighteen months and suddenly starts throwing AADSTS7000215 overnight with no code change, the far more likely cause is that the secret simply ran out the clock. Nobody typed anything wrong. Time just passed.
application object, tenant-wide definition) and their credentials. A related but distinct object, the service principal, is the per-tenant instance of that application used at sign-in time - relevant if the app is multi-tenant - but the PasswordCredentials and KeyCredentials that actually expire live on the application object, which is exactly what this script targets.Why it happens: multiple credentials pile up and nobody owns the calendar
The structural root cause is that Entra ID lets - and in practice, encourages - an app registration to hold more than one credential at once. Under Certificates & secrets in the portal, "Add a client secret" and "Upload a certificate" are both repeatable actions with no limit that a normal admin will ever hit. Teams rotate secrets by adding a new one alongside the old one, intending to delete the old one once the new one is confirmed working - and then the old one just sits there, expired, forever, because deleting it felt risky and nobody circled back.
Multiply that pattern across a tenant with a few hundred app registrations, built up over several years by different teams, and you get exactly what the Microsoft Graph data model actually returns: a PasswordCredentials array and a KeyCredentials array on every application object, each one potentially holding several entries, each entry with its own independent StartDateTime and EndDateTime. Checking "does this app have a secret" tells you nothing useful. You have to check every single entry in both arrays, on every single app, individually.
Compounding that, there is no built-in, tenant-wide expiry-warning mechanism that fires reliably before something breaks. Microsoft does surface some in-portal nudges and, in some configurations, notification emails to app owners as a credential nears expiry - but those depend on an app owner being correctly set, on someone actually reading that inbox, and on the notification not landing in a shared mailbox nobody checks. There is no guaranteed, centralized, admin-visible worklist of "every credential expiring across the whole tenant in the next 30 days" anywhere in the Entra admin center. That gap is exactly what this post's script fills.
How to verify - check one app by hand before you trust any script
Before running anything tenant-wide, it is worth knowing how to manually confirm the finding on a single app, because that is exactly what you or a colleague will do to sanity-check the script's output the first time it flags something important.
- Sign in to the Microsoft Entra admin center with at least Application Administrator or Cloud Application Administrator rights
- Go to Identity › Applications › App registrations
- Select the app you want to check - for example, a fictional integration called Contoso Expense App
- Select Certificates & secrets in the left-hand menu - this shows two tabs: Certificates and Client secrets
- On the Client secrets tab, read the Expires column for every single row, not just the first one
Here is what that tab looks like on a real, un-remediated app - three secrets, only one of them actually usable:
Three rows on one app, and the picture is not reassuring. One secret expired eighteen months ago and was clearly never cleaned up. A second one, added as a supposed backup, also expired four months before this screenshot was taken and nobody noticed. The only secret keeping this integration alive expires in four days. Checking this by hand, for this one app, took under two minutes once you knew where to look. Doing that same check across every app registration in a tenant that might have several hundred of them is not realistic without a script - which is exactly why the next section exists.
The fix - Get-AppRegistrationExpiryReport.ps1
The script uses the Microsoft.Graph.Applications PowerShell module to do programmatically, across the entire tenant, exactly what you just did by hand for one app. It calls Get-MgApplication -All to retrieve every app registration, then walks the PasswordCredentials and KeyCredentials arrays on each one, comparing every single credential's EndDateTime against the current date to classify it as Expired, Expiring, or Healthy.
# Connect with the read-only Graph scope needed to read app credentials Connect-MgGraph -Scopes 'Application.Read.All' -UseDeviceCode -NoWelcome # Every app registration in the tenant, with its credential arrays $apps = Get-MgApplication -All -Property "Id,AppId,DisplayName,PasswordCredentials,KeyCredentials" $now = Get-Date foreach ($app in $apps) { # Check EVERY secret, not just whether the array is non-empty foreach ($secret in $app.PasswordCredentials) { $daysToExpiry = [math]::Round(($secret.EndDateTime - $now).TotalDays) if ($daysToExpiry -lt 0) { $status = 'Expired' } elseif ($daysToExpiry -le $WarningDays) { $status = 'Expiring' } else { $status = 'Healthy' } # ...added to $results with AppDisplayName, EndDateTime, Status, etc. } # Certificates go through the identical check against KeyCredentials foreach ($cert in $app.KeyCredentials) { $daysToExpiry = [math]::Round(($cert.EndDateTime - $now).TotalDays) # ...same classification, same output row } }
The script requires only the Application.Read.All Graph permission - a read-only application permission. It never writes anything back to Entra ID. It supports two connection modes: pass -TenantId, -ClientId, and -CertificateThumbprint for unattended app-only certificate authentication - the right choice for a scheduled task or an Azure Automation runbook - or omit them entirely and it falls back to an interactive device-code sign-in with the same read-only scope. It also always disconnects and reconnects fresh rather than trusting a stale cached session, and it fails loudly with Write-Error and a non-zero exit code on any connection or query failure, rather than silently reporting zero findings, which would look identical to a genuinely clean tenant. Here is a realistic run against a test tenant, sorted worst-first by days to expiry:
[INFO] Connecting to Microsoft Graph... [INFO] Connected. Tenant: ββββββββ-ββββ-ββββ-ββββ-ββββββββββββ Account: i.awan@contoso.com === Retrieving app registrations === [INFO] Found 214 app registration(s) in the tenant. === Findings === [EXPIRED ] Secret Contoso Expense App Prod secret 2023 ends 2025-01-04 (-564 day(s)) [EXPIRED ] Secret Contoso Expense App Backup secret (unused) ends 2026-03-11 (-133 day(s)) [EXPIRED ] Certificate Contoso Data Sync Connector sync-cert-2024 ends 2026-06-30 (-22 day(s)) [EXPIRING] Secret Contoso Expense App Prod secret 2025 rotation ends 2026-07-26 (4 day(s)) [EXPIRING] Secret Contoso Helpdesk Bot Client secret ends 2026-08-09 (18 day(s)) [HEALTHY ] Secret Contoso Payroll Export Client secret ends 2027-02-15 (208 day(s)) === Summary === Total app registrations scanned : 214 Total credentials evaluated : 389 Expired credentials : 11 Expiring within 30 day(s) : 6 Healthy credentials : 372 [INFO] Audit complete.
Eleven expired credentials sitting in the tenant right now, unnoticed, and six more due to fail within the month - including that Contoso Expense App secret with four days left, the exact scenario this post opened with. Two of the expired ones on that same app are the dead secrets from the manual check earlier - now confirmed programmatically instead of by clicking through the portal one app at a time.
Once a credential is confirmed genuinely dead or genuinely at risk, remediation is the same short sequence every time:
Proof it worked - rotate a flagged secret and re-run the audit
Take the Contoso Expense App secret with four days left, rotate it following the sequence above, and delete the two dead secrets alongside it while you're in there. Re-run the exact same script with no other changes:
[INFO] Connecting to Microsoft Graph... [INFO] Connected. Tenant: ββββββββ-ββββ-ββββ-ββββ-ββββββββββββ Account: i.awan@contoso.com === Retrieving app registrations === [INFO] Found 214 app registration(s) in the tenant. === Findings === [EXPIRED ] Certificate Contoso Data Sync Connector sync-cert-2024 ends 2026-06-30 (-22 day(s)) [EXPIRING] Secret Contoso Helpdesk Bot Client secret ends 2026-08-09 (18 day(s)) [HEALTHY ] Secret Contoso Expense App Prod secret 2026 rotation ends 2028-07-22 (730 day(s)) [HEALTHY ] Secret Contoso Payroll Export Client secret ends 2027-02-15 (208 day(s)) === Summary === Total app registrations scanned : 214 Total credentials evaluated : 387 Expired credentials : 9 Expiring within 30 day(s) : 5 Healthy credentials : 373 [INFO] Audit complete.
Contoso Expense App has dropped off both the Expired and Expiring lists entirely - the two dead secrets were deleted, and the four-day secret was replaced with a fresh one now healthy for two years. The credential count on that one app went from three to one, and the total evaluated count in the tenant dropped from 389 to 387 to match. The remaining findings - the certificate that expired 22 days ago on a different app, and the helpdesk bot secret with 18 days left - are exactly what should still be there, since this remediation pass only touched the one app.
Get-AppRegistrationExpiryReport.ps1 to run weekly via an Azure Automation runbook using app-only certificate authentication, with -ExportCsv enabled and the CSV dropped somewhere your team actually looks - a SharePoint list, a Teams channel via webhook, or a ticketing system import. New app registrations get created constantly, each one starts a fresh expiry clock, and a one-time clean-up only buys you until the next rotation deadline nobody wrote down.I'll audit every app registration in your tenant, flag every expired and expiring credential, and help you build a rotation process your team will actually keep up with.
References
- Create an Entra ID application and service principal in the portal — Microsoft Learn
- Get started with the Microsoft Graph PowerShell SDK — Microsoft Learn
- Microsoft Entra authentication and authorization error codes — Microsoft Learn
- passwordCredential resource type — Microsoft Graph
So - how many of your app registrations have a client secret expiring next week? Run the script above and find out before an on-call page does it for you.