A penetration test finds a client secret in a Git history and uses it successfully. The commit is fourteen months old. The secret was rotated on schedule eleven months ago, the ticket was closed, and the change record is complete and accurate.
Both secrets were valid the whole time.
This is not a story about a bad process. The rotation ran correctly by every measure the team had: a new secret generated, the application updated, the change verified, the ticket closed. What nobody did was delete the credential the rotation replaced, because an Entra ID application registration accepts multiple secrets by design and nothing about the working state tells you one of them should not be there.
An app registration holds a list, not a value
The passwordCredentials collection on an application registration is exactly that: a collection. This is deliberate and it is the right design. A changeover with no downtime needs both credentials valid at once: you add the new one, update whatever consumes it, confirm it works, and only then remove the old.
The rotation everyone runs stops at "confirm it works."
That is where the incentive stops too. The application is working. The ticket has a verifiable success condition and it has been met. Removing the old credential changes nothing observable, carries a small risk of breaking something if the update did not fully land, and no view in the Azure portal marks the previous secret as superseded. It sits in the same list as the new one, with a different display name and a longer expiry, looking exactly as legitimate as it did the day it was created.
The two-credential state is invisible in every view except one
Nothing surfaces this. The application authenticates normally. Sign-in logs show successful authentications and do not distinguish which credential was used. Conditional Access has no workload identity policy in most tenants, and where one exists it evaluates the sign-in rather than the credential behind it. The registration's own page shows a list, and a list of two is not visually different from a list of one.
The only place it appears is a deliberate query:
Connect-MgGraph -Scopes "Application.Read.All"
Get-MgApplication -All |
Where-Object { $_.PasswordCredentials.Count -gt 1 } |
ForEach-Object {
$app = $_
$app.PasswordCredentials | ForEach-Object {
[pscustomobject]@{
Application = $app.DisplayName
Credential = $_.DisplayName
Created = $_.StartDateTime
Expires = $_.EndDateTime
AgeDays = [int]((Get-Date) - $_.StartDateTime).TotalDays
}
}
} | Sort-Object Application, CreatedRun it before reading further. Two properties matter in the output: how many applications hold more than one secret, and how far apart the creation dates are. Credentials created minutes apart are a rotation in progress. Credentials created a year apart, both valid, are a rotation that finished halfway.
There is a third pattern worth recognizing in that output, and it is the one that should stop you. A registration holding a certificate and a client secret is a migration that got most of the way. Somebody did the work to move the application onto stronger credential material and left the secret in place, so the registration is now exactly as strong as the weaker of the two. An attacker holding that secret has no reason to care about the certificate. Adding stronger material without removing the weaker raises nothing.
Benchmark
In a tenant of roughly 200 app registrations, expect 15 to 30 holding more than one client secret. Fewer than five means either a disciplined rotation practice or, more often, that almost nothing has ever been rotated. More than 40 per cent of registrations carrying multiples means rotation is running and completion is not tracked at all.
Age is the property that matters, not expiry
The instinct is to look at expiry dates and treat anything current as fine. That reads the wrong end of the credential.
A secret's expiry tells you when it stops working. Its age tells you how many people have had the opportunity to copy it and how few of them still work here. A credential created in 2021 with a 2027 expiry has been through five years of laptop rebuilds, repository migrations, offboarding processes and CI pipeline rewrites, and the person who created it is likely to have left. The long expiry is not reassurance. It is the reason nobody has been prompted to look at it.
That is also the argument against long lifetimes generally. A credential renewing annually gets touched every year by somebody who still remembers what it is for. One renewing in four years outlives the engineer who set it up, and the renewal lands on somebody who finds a secret with an unfamiliar display name and no documentation and rotates it rather than questioning whether it should exist.
That last behavior is worth naming, because it is how the two-credential state compounds. An engineer who inherits an application, finds a credential they cannot account for, and rotates it defensively has now added a third. The registration accumulates one credential per person who was unsure, and every one of them still authenticates.
Detecting the addition, which is harder than it sounds
The audit event for a credential addition is Add service principal credentials in AuditLogs. Alerting on it directly produces every legitimate rotation in the tenant, which in a healthy estate is a steady stream of noise.
The event does not distinguish a rotation from an attacker with a compromised administrator session adding their own credential to an existing application. Both are the same operation, by an administrator account, with a success result and a display name the actor chose. There is nothing in the event to separate them.
What separates them is what surrounds the event:
// Hypothesis: a credential addition with no matching removal is either an
// incomplete rotation or an addition nobody intended. Both are worth a question.
let additions = AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName has "Add service principal credentials"
| extend App = tostring(TargetResources[0].displayName),
Actor = tostring(InitiatedBy.user.userPrincipalName)
| project AddedAt = TimeGenerated, App, Actor;
let removals = AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName has "Remove service principal credentials"
| extend App = tostring(TargetResources[0].displayName)
| project RemovedAt = TimeGenerated, App;
additions
| join kind=leftouter removals on App
| extend RemovalWithin30d = iff(isnotempty(RemovedAt)
and RemovedAt between (AddedAt .. AddedAt + 30d), true, false)
| where RemovalWithin30d == false
| project AddedAt, App, Actor
| order by AddedAt desc
// Rows here are additions that were never completed. Investigate the recent ones
// as possible unauthorized additions; treat the older ones as rotation debt.This is a detection with a prerequisite most teams do not have: a change record to join against. A credential addition with a ticket behind it and a removal within days is a rotation. One with neither is a finding. If your tenant has no change record for credential work, this query gives you the second half and you cannot build the first.
There is a weaker version worth running in the meantime. Any credential addition with no matching removal inside thirty days is either an incomplete rotation or an addition nobody intended, and both deserve a question. It produces false positives against sloppy rotation practice, which is itself the finding, so the noise here is informative rather than wasted.
The Sigma equivalent for teams working outside Sentinel:
title: Entra ID Service Principal Credential Added Without Removal
id: 8f2c41d7-3b19-4e5a-9c22-6d18a4f7b309
status: experimental
logsource:
product: azure
service: auditlogs
detection:
selection:
Category: ApplicationManagement
OperationName|contains: 'Add service principal credentials'
Result: success
condition: selection
falsepositives:
- Scheduled credential rotation. Correlate against change records and against
a matching removal event for the same application within 30 days.
level: lowThe level: low is deliberate. On its own this fires on every rotation, and a rule that produces mostly legitimate activity should say so in its severity rather than in a comment nobody reads.
The fix is one line in a procedure
Nothing here is technically difficult. The remediation is a step added to the rotation runbook that somebody signs off:
Delete the superseded credential, and record that you did.
A rotation ending when the application works has done half the job and closed the ticket on the half that was easy to verify. The half that matters has no observable success condition, which is exactly why it needs to be an explicit step rather than an assumed one.
For applications you are building rather than inheriting, the class of problem disappears entirely with workload identity federation. There is no secret: the platform running your code asserts what it is, Entra ID checks that assertion against a trust you configured, and there is nothing to store, rotate, leak, or forget to remove. That is a build decision rather than a configuration change, which is why it stays rare, but it is the only answer that ends this rather than managing it.
Worth knowing
A deleted app registration is recoverable for 30 days with its full state. An edited one is not recoverable at all. The destructive-looking operation is the reversible one, and the ordinary edit nobody thinks twice about is the one that destroys the previous state permanently.
What to do this week
Run the PowerShell above and count. Applications holding more than one client secret, and the gap between creation dates. Anything over thirty days apart is a rotation that never finished.
Sort what you find by age, not by expiry. The oldest credential on the list has been through the most hands and belongs to the fewest people still employed.
Check whether you have a change record for credential work. If you do not, the detection in this post cannot be built and that is the finding rather than the query.
Pick the applications with no owner first. They are where this accumulates, because there is nobody to ask whether the old credential is still needed and nobody who notices when it is not.
For anything you are building now, use federation. Not a vault, not a certificate. Nothing to rotate is a different category from something rotated well.
Further reading
- Microsoft Graph application resource type, the
passwordCredentialsandkeyCredentialscollections, and why both are lists - Workload identity federation in Entra ID, the configuration that removes the credential
- MITRE ATT&CK T1098.001, Account Manipulation: Additional Cloud Credentials, the technique this detection covers
- Sigma rules for Azure activity, community detections for the same log source