← Back to Blog

Your Secret Rotation Ran Correctly. Both Credentials Are Still Valid.

25 August 2026 Identity & Access 8 min read
WHAT A ROTATION DOES, AND WHAT PEOPLE THINK IT DOES WHAT THE TICKET ASSUMES old secret new secret one credential, replaced WHAT THE REGISTRATION HOLDS old secret, valid to 2027 new secret, valid to 2029 two credentials, both working Nothing in the portal marks the old one as superseded
A rotation adds a credential. Removing the previous one is a separate act nobody is prompted to perform.

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, Created

Run 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: low

The 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

Ridgeline Cyber Defence Written by security professionals. Published weekly on Tuesdays.

Related Articles

12 May 2026

Service Principal Ownership Is the Attack Path Nobody Governs

Owning a service principal means owning its permissions. Most tenants don't monitor SP ownership changes. Here's the det

15 June 2026

The EC2 Credential-Theft Detection Most Teams Ship Wrong

Alerting on AssumedRole from outside AWS buries you in SSO noise. Here is the marker that isolates a stolen EC2 instance

4 June 2026

KQL SigninLogs, The 10 Queries Every SOC Analyst Runs First

Ten KQL queries against SigninLogs that answer what SOC analysts actually ask during an identity investigation, copy-pas