Azure app registration secret expiry: they will go at 3am on a Saturday
Nothing about the failure will make sense at first. The nightly job did not run. The logs say the token request was rejected. Nobody deployed anything, nobody changed a firewall rule, and the thing has worked every night for two years.
Azure app registration secret expiry is the answer roughly a third of the time, and it has a particular cruelty to it: the expiry date was chosen two years ago by somebody picking from a dropdown, and it lands whenever it lands. Which is statistically likely to be outside working hours, because most of the week is.
Azure app registration secret expiry comes in two kinds
App registrations authenticate with either a client secret or a certificate, and both expire.
Client secrets are strings. Easy to create, easy to paste somewhere, and the value is only visible once at creation. Maximum lifetime is capped, and the portal nudges you toward shorter ones now, which is an improvement.
Certificates are better and more work. The thumbprint is what the app registration holds, and the private key lives wherever the client runs. They expire the same way and are less likely to be pasted into a Teams message.
Both are held on the application object, and both are invisible until they fail unless somebody is looking. There is no default alert. Nothing emails you. The app registration credential documentation covers creation but the monitoring is your problem.
Finding what expires soon
The Graph query is straightforward. The trap is in the property names.
Connect-MgGraph -Scopes 'Application.Read.All'
$horizon = (Get-Date).AddDays(30)
Get-MgApplication -All | ForEach-Object {
$app = $_
foreach ($c in @($app.PasswordCredentials) + @($app.KeyCredentials)) {
# EndDateTime, not EndDate. A null here becomes a date in year 1,
# which reports every credential as expired 739,000 days ago.
if (-not $c.EndDateTime) { continue }
if ($c.EndDateTime -le $horizon) {
[pscustomobject]@{
App = $app.DisplayName
AppId = $app.AppId
Kind = if ($c.Hint) { 'Secret' } else { 'Certificate' }
Name = $c.DisplayName
Expires = $c.EndDateTime
DaysLeft = [int]($c.EndDateTime - (Get-Date)).TotalDays
}
}
}
} | Sort-Object DaysLeft
Two things worth stealing. The property is EndDateTime and not EndDate; I
wrote EndDate once, got null for every credential, subtracted it from today,
and produced a report claiming everything had expired roughly two thousand years
ago. It was a memorable way to learn to guard for null before doing date
arithmetic.
And service principals hold their own credentials separately from applications.
An app registration created in your tenant has both objects, and a credential
can sit on either. Get-MgApplication alone misses some, so check
Get-MgServicePrincipal too, particularly for anything set up by a vendor.
The ten-year secret is a different problem
Finding a secret expiring in 30 days is an operational task. Finding one that expires in 2034 is a security finding, and the two want different responses.
A long-lived secret is a password that never rotates, held by however many systems and people have touched it since creation. Its expiry date is not protecting anything; it is deferring a conversation. And because it will not fail, nobody will ever be forced to look at it, so it accumulates copies: in a pipeline variable, in a config file, in the ticket where somebody shared it, in a former employee's password manager.
So the audit should sort both ways. Expiring soonest, which is the ticket queue. And longest-lived, which is the risk register. The second list is more interesting and gets looked at less.
Setting a tenant-wide maximum credential lifetime through app management policy stops new ones being created with absurd expiry dates, which is preventive rather than corrective and therefore worth more than any amount of auditing.
Managed identity, where it fits
The correct answer to most secret expiry problems is to stop having a secret.
Managed identity gives an Azure resource an identity in your directory with no credential you hold. The platform handles it. Nothing expires, nothing gets pasted anywhere, and there is nothing for a leaver to take with them. If the thing authenticating runs in Azure, which for a VM, Function, App Service, Automation account or container app it usually does, this is available and is almost always right.
Where it does not apply: something running outside Azure, such as a script on a laptop, a build agent elsewhere, or a third-party SaaS product calling your tenant. Those need a real credential, and for those, certificates beat secrets and short lifetimes beat long ones.
The practical migration order is to work through the list produced above and ask one question per app: does this run in Azure. Everything that does becomes a managed identity and drops off the list permanently. What remains is a smaller set worth actually monitoring, which is the point.
The inventory question underneath this
Working through an expiry list surfaces a question people find harder than the credentials themselves: what is this app registration actually for.
A tenant that has been running for a few years accumulates them. One per
integration, several from vendor onboarding, a handful somebody created while
testing and never removed, and at least one called something like test-app-2
holding Directory.ReadWrite.All. The credential expiry is the prompt to ask,
because it is the only moment anyone is forced to look.
Three questions per app, and the answers take minutes:
What permissions does it hold, and are they application permissions or delegated. Application permissions apply tenant-wide with no user context, which is what makes an over-permissioned app registration worse than an over-permissioned person.
When did it last sign in. Service principal sign-in activity answers this. An app registration with a live secret and no sign-ins in six months is a credential with no purpose, and deleting it is the cheapest security improvement available.
Who owns it. Not who created it, who would notice if it stopped. If nobody can answer, that is the finding rather than the expiry date.
The ones that fail all three are safe to remove, and removing an app removes its credentials, its permissions and its place on next quarter's list at the same time.
Alerting on it properly
Two mechanisms, and you want both.
A scheduled check that reports credentials expiring within 30 days, running weekly, emailing somebody. Simple, and it works as long as the job itself is monitored, which means it must exit non-zero on failure rather than reporting nothing.
And a detection on credentials being added, which is the security half. A
new secret appearing on an existing app registration is a recognised persistence
technique: an attacker with sufficient privilege adds their own credential to an
app that already has the access they want, and nothing else about the tenant
appears to change. That event is in AuditLogs, it is rare, and it belongs in
the same category as the configuration drift rules in
the Sentinel validation note.
Where this sits
Expiring credentials is one of the ten checks in the AzClean Toolkit, reported both ways: soonest expiry for the queue, longest lifetime for the risk conversation.
It pairs with the RBAC review, because the two questions combine into the one that matters: what can authenticate against this tenant, and does anybody still need it to.