How One Forgotten Pipeline Secret Deleted Production

Oct 30, 2026 min read

Tuesday, 2:22 PM. The patient portal returns errors on every page, and the security channel has one message in it: “Seeing resource deletions in production. Investigating.” You open the Activity Log for rg-portal-prod and watch the list scroll. The App Service. The Function App. The SQL database. The Key Vault. Forty-one delete operations between 2:05 and 2:19. Fourteen minutes.

The caller column doesn’t show an employee, and it doesn’t show an account anyone would call an attacker’s. It shows sp-portal-deploy, a service principal your team created in February for a GitHub Actions workflow that was deleted in April. The pipeline is gone. The identity, its Contributor role on the subscription, and its two-year client secret are not.

You revoke the secret at 3:05 and start restoring. The infrastructure comes back over the next two days. Then legal asks the question that matters in healthcare: was patient data read? You scroll back to Sunday night and find one quiet entry at 11:52 PM: List Storage Account Keys, on the account that holds every intake form and insurance card patients ever uploaded. After that, nothing. Nothing that anyone was logging.

This company is a composite: the healthcare provider, the people, the names, the dates and the counts are invented. Every Azure, GitHub and Entra ID behavior in this post, every log, and the HIPAA rule are real and linked to their primary sources. It’s the written version of episode 2 of Ping Larry, and the full arithmetic lives with the episode’s script.

The Architecture Everyone Signed Off On

Patients upload intake forms and insurance cards to a portal: an App Service backed by Azure SQL, with a Function App that processes uploads. The documents land in a storage account, stclinicaldocs, in a blob container called intake.

Deployments run from GitHub Actions. To reach Azure, the workflow signs in as sp-portal-deploy with a client secret stored as a GitHub secret. Two shortcuts went in the week it was built:

  • Contributor on the entire subscription. The pipeline kept failing on permissions, and subscription-wide Contributor made the errors stop.
  • A 24-month secret. That’s the longest lifetime the Entra admin center allows.

Neither shortcut was hidden. Microsoft’s app credentials guidance caps client secrets at 24 months, recommends less than 12, and says client secrets shouldn’t be used in production at all: use a certificate or a federated credential. The design review approved both anyway, because the pipeline needed to ship and the risk felt theoretical.

One Commit, Never Forgotten

The same week, a developer debugging a failed deploy pasted the secret into deploy/.env and committed it. Ten minutes later they noticed and committed the fix: remove creds. The working tree was clean.

Git history wasn’t. The secret sat one commit back, in every clone of the repository. GitHub’s own guide to removing sensitive data is blunt about the order of operations: once a credential is committed, rotate it first, then think about rewriting history, because you can’t know who already copied it.

Nobody rotated it. In April the portal moved to a new pipeline, the old workflow file was deleted, and the service principal stayed exactly where it was, with nobody listed as its owner. In July a contractor pushed a copy of the repository, full history included, to a personal public GitHub account to show their work.

You now have an orphaned deploy identity: a credential with production rights, attached to no running system, owned by no one, and valid for 16 more months (24 months minus the 8 it had already lived).

This isn’t rare. GitGuardian’s State of Secrets Sprawl 2025 counted 23.8 million secrets leaked on public GitHub in 2024, and found that 70% of the secrets leaked in 2022 were still valid. Attackers don’t need to guess which ones work. They try all of them.

Thirty-Eight Dark Hours

Sunday, 11:41 PM. Someone signs in as sp-portal-deploy from a server nobody recognizes. Entra ID records the sign-in. Nothing alerts on it.

Eleven minutes later, the Activity Log records one operation: Microsoft.Storage/storageAccounts/listKeys/action on stclinicaldocs. That entry is the most important line in this whole incident, and it’s easy to misread. Listing the keys is a control-plane call to Azure Resource Manager, so the Activity Log sees it. What the attacker does with the keys afterward is a different story.

The account keys are the master password for the data. Microsoft’s resource lock documentation says it directly: the account keys provide complete access to data in the storage account. Requests made with them go to stclinicaldocs.blob.core.windows.net, the data plane, and the Activity Log doesn’t record data-plane operations. Here’s what each log saw that week:

LogWhat it recordsWhat it saw Sunday to Tuesday
Entra ID sign-in logsEvery token the service principal requestedThe 11:41 PM sign-in, unread
Activity LogControl-plane writes and actions, kept 90 dayslistKeys, then 41 deletes
Blob resource logs (StorageRead, StorageWrite, StorageDelete)Every data-plane requestNothing. They were never turned on

The Activity Log documentation spells out both halves: it records management operations, typically not reads, and keeps them for 90 days. Resource logs, the data-plane record, aren’t collected until you create a diagnostic setting. Monitor Azure Blob Storage says the same for blobs. stclinicaldocs had no diagnostic setting.

So for 38 hours, from 11:52 PM Sunday to 2:05 PM Tuesday, someone held full read access to every document in the account, and no system you own wrote down a single request.

At 2:05 on Tuesday the deletions started: the App Service, the Function App, the SQL database, the Key Vault, and, using the account key on the data plane, the intake container itself.

The firewall didn’t matter. The VPN didn’t matter. Every request was authenticated and every request was authorized. As far as Azure could tell, this was the pipeline doing its job.

If this sounds like a small-shop problem, read Microsoft’s January 2024 Midnight Blizzard disclosure. The attackers password-sprayed a legacy, non-production test tenant account that had no MFA, then used a legacy test OAuth application with elevated access to Microsoft’s corporate environment to reach corporate email. Different mechanism, same shape: a forgotten identity that still had rights nobody remembered granting.

The Bill Wasn’t the Downtime

Detection came at 2:22 PM, 17 minutes after the first delete, and only because the portal went down. By 3:05 the Activity Log had pointed at sp-portal-deploy, the secret was revoked and the service principal disabled.

Recovery went better than anyone expected:

  • Key Vault came back from soft delete, which is on for every new vault and can’t be turned off once enabled.
  • The SQL database came back as a restore of a deleted database from its automated backups. That worked because the attacker deleted the database, not the logical server. Delete the server and its databases’ point-in-time backups go with it; only long-term retention backups survive. If your recovery plan assumes PITR, check whether a Contributor can delete the server.
  • The documents came back from backups.

Two days of disruption. Painful, survivable. Then legal asked whether patient data had been read, and nobody could answer.

Under the HIPAA Breach Notification Rule, an impermissible use or disclosure of protected health information is presumed to be a breach unless you can demonstrate a low probability that the data was compromised, using a risk assessment of at least four factors. One of those factors is whether the PHI was actually acquired or viewed. That’s the exact question StorageRead logs answer, and the exact log that didn’t exist. The burden of proof is yours, and you had nothing to prove it with.

Someone in the room will ask about encryption at rest. Azure Storage encrypts every blob, but it decrypts transparently for any request the account authorizes, and an account key authorizes everything. Encryption at rest protects you from a stolen disk, not from a stolen key.

So it’s a breach. 38,412 patients get notification letters. Most of them live in one state, and more than 500 residents of a state means notice to prominent media outlets there, plus notice to the HHS Secretary, all within 60 days of discovery.

The deletions cost two days. The missing log turned a bad Tuesday into a reportable breach. Call it the evidence gap: the distance between what happened in your account and what you can prove happened.

Three Guardrails, None of Them Exotic

Each guardrail breaks a different link in the chain. You don’t need all three to stop this exact incident, but each one covers the day another one fails.

flowchart LR A["Secret in Git history"] --> B["Sign-in as sp-portal-deploy
Sun 23:41"] B --> C["listKeys on stclinicaldocs
Sun 23:52"] C --> D["38 hours of unlogged blob reads"] B --> E["41 deletes in rg-portal-prod
Tue 14:05-14:19"] G1(["1. No secret to leak"]) -. removes .-> A G2(["2. RG scope, lock, Shared Key off"]) -. blocks .-> C G2 -. blocks .-> E G3(["3. Read logs and alerts"]) -. detects .-> B G3 -. records .-> D

The snippets are Pulumi TypeScript (@pulumi/azure-native v3 and @pulumi/azuread v6) and GitHub Actions YAML. They type-check as written; swap in your own names. The identity, lock, policy and monitoring resources belong in your platform stack, deployed by a separate identity with rights to Microsoft.Authorization/*. The storage account belongs in the app stack that the deploy identity runs. That split is the point: the identity that deploys the app can’t change the rules that contain it.

1. Have No Secret to Leak

GitHub Actions can sign in to Azure with OpenID Connect. The workflow asks GitHub for a short-lived OIDC token, Entra ID checks it against a federated credential on the app registration, and hands back an access token. No client secret exists, so none can end up in a .env file, a Git history, or a contractor’s public fork.

import * as pulumi from "@pulumi/pulumi";
import * as azuread from "@pulumi/azuread";

const cfg = new pulumi.Config();
// gh api repos/acme-health/patient-portal --jq '"\(.owner.id) \(.id)"'
const owner = cfg.require("githubOwner");     // acme-health
const ownerId = cfg.require("githubOwnerId"); // numeric, never reused
const repo = cfg.require("githubRepo");       // patient-portal
const repoId = cfg.require("githubRepoId");   // numeric, never reused

const app = new azuread.Application("portal-deploy-prod", {
    displayName: "gh-patient-portal-deploy-prod",
    owners: [cfg.require("platformTeamOwnerObjectId")],
});

const sp = new azuread.ServicePrincipal("portal-deploy-prod", {
    clientId: app.clientId,
    owners: [cfg.require("platformTeamOwnerObjectId")],
});

// The only credential this app will ever have. No azuread.ApplicationPassword anywhere.
new azuread.ApplicationFederatedIdentityCredential("gh-production", {
    applicationId: app.id,
    displayName: "github-patient-portal-production",
    issuer: "https://token.actions.githubusercontent.com",
    // Immutable subject: survives a repo rename, and a recreated repo with the same name can't match it.
    subject: pulumi.interpolate`repo:${owner}@${ownerId}/${repo}@${repoId}:environment:production`,
    audiences: ["api://AzureADTokenExchange"],
});

export const AZURE_CLIENT_ID = app.clientId;

Three details in that resource do the real work:

  • The subject binds one repository and one environment. A workflow in any other repository, or in this repository without environment: production, gets a token Entra ID refuses. Put required reviewers on the production environment in GitHub and every deployment has a named approver, which is the separation-of-duties evidence your auditors ask for.
  • The subject uses GitHub’s immutable format. GitHub’s original subject was built from names only (repo:acme-health/patient-portal:environment:production), and names can be renamed, transferred or reused. Since July 15, 2026, repositories created, renamed or transferred on GitHub.com send repo:owner@ownerId/repo@repoId:... instead; older repositories keep the name-only format until you opt in. Microsoft’s migration guide walks through moving an existing credential without downtime.
  • The app has owners. The orphan in this story happened because nobody’s name was on sp-portal-deploy. An owner is someone your next access review can ask about it. Nobody can’t answer.

The workflow side is short. id-token: write lets the job request the OIDC token, and nothing in the workflow is a password:

name: deploy-portal

on:
  push:
    branches: [main]

permissions:
  id-token: write   # request a GitHub OIDC token for Entra ID
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # the federated credential trusts this environment and nothing else
    env:
      ARM_USE_OIDC: "true"    # Pulumi's azure-native provider picks up GitHub's token itself
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 22

      - run: npm ci
        working-directory: infra/app

      # Only needed for az CLI steps. No client-secret input, no creds JSON.
      - uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - run: az account show --query "{subscription:name, signedInAs:user.name}" --output table

      # Pulumi Cloud trusts the same GitHub OIDC token, so there's no PULUMI_ACCESS_TOKEN secret either.
      - uses: pulumi/auth-actions@v2
        with:
          organization: acme-health
          requested-token-type: urn:pulumi:token-type:access_token:organization

      - uses: pulumi/actions@v7
        with:
          command: up
          stack-name: acme-health/patient-portal/prod
          work-dir: infra/app

The client, tenant and subscription IDs aren’t credentials. They sit in GitHub secrets only because the azure/login README asks you to pass those inputs from secrets rather than from anything an event can influence.

The judgment call: OIDC, certificate, or managed identity? You have three ways to get a pipeline into Azure without a client secret. A certificate credential is still a long-lived private key: you’ve moved the rotation problem into Key Vault, not removed it. A self-hosted runner on a VM with a managed identity removes the credential, but now the runner is the credential, and anyone who can queue a job on it inherits its rights. For GitHub-hosted runners in a regulated environment, pick federated credentials: there’s nothing stored to steal, the trust is scoped to a repository and environment you can show an auditor, and every token it issues is short-lived. Use managed identities on self-hosted runners only when network isolation forces you onto them, and then scope each runner group to one identity.

Then go find the secrets you already have:

# Every app registration that still has a client secret, with the expiry of its first one
az ad app list --all \
  --query "[?length(passwordCredentials) > \`0\`].{app:displayName, appId:appId, secretExpires:passwordCredentials[0].endDateTime}" \
  --output table

Anything on that list that belongs to a pipeline is a migration ticket. Anything nobody claims within a week is an orphan: disable the service principal and see who complains.

2. Shrink the Blast Radius

OIDC removes this leak. It doesn’t remove every way a deploy identity can be abused: a compromised workflow, a malicious dependency, or a bad merge to main still runs with the identity’s rights. So make those rights small, and make the worst operations impossible.

import * as pulumi from "@pulumi/pulumi";
import * as azure_native from "@pulumi/azure-native";

const client = azure_native.authorization.getClientConfigOutput();
const subscriptionScope = pulumi.interpolate`/subscriptions/${client.subscriptionId}`;
const contributor = pulumi.interpolate`${subscriptionScope}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c`;

const rg = new azure_native.resources.ResourceGroup("rg-portal-prod", {
    resourceGroupName: "rg-portal-prod",
});

// Contributor on one resource group, not the subscription.
new azure_native.authorization.RoleAssignment("portal-deploy-rg-contributor", {
    scope: rg.id,
    principalId: sp.objectId, // the service principal from guardrail 1
    principalType: "ServicePrincipal",
    roleDefinitionId: contributor,
});

// Contributor's NotActions include Microsoft.Authorization/*/Delete, so the deploy identity can't remove this.
new azure_native.authorization.ManagementLockAtResourceGroupLevel("rg-portal-prod-no-delete", {
    resourceGroupName: rg.name,
    lockName: "no-delete",
    level: "CanNotDelete",
    notes: "Production. Removing this lock is a change request plus a PIM activation for Owner.",
});

// Deny any storage account that allows Shared Key. Contributor can't edit or delete policy assignments either.
new azure_native.authorization.PolicyAssignment("deny-storage-shared-key", {
    policyAssignmentName: "deny-storage-shared-key",
    displayName: "Storage accounts should prevent shared key access (Deny)",
    scope: subscriptionScope,
    policyDefinitionId: "/providers/Microsoft.Authorization/policyDefinitions/8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54",
    parameters: { effect: { value: "Deny" } },
});

And in the app stack, the storage account that holds PHI turns Shared Key off:

const docs = new azure_native.storage.StorageAccount("stclinicaldocs", {
    accountName: "stclinicaldocs",
    resourceGroupName: "rg-portal-prod",
    kind: azure_native.storage.Kind.StorageV2,
    sku: { name: azure_native.storage.SkuName.Standard_ZRS },
    allowSharedKeyAccess: false,        // listKeys still works; the keys it returns are refused with a 403
    defaultToOAuthAuthentication: true, // the portal uses Entra ID for data access too
    allowBlobPublicAccess: false,
    minimumTlsVersion: azure_native.storage.MinimumTlsVersion.TLS1_2,
});

Replay Sunday night against this configuration and watch each step fail:

  1. The keys are worthless. With allowSharedKeyAccess: false, Azure Storage rejects every request authorized with the account keys with a 403. Contributor can still call listKeys; it just gets two strings that open nothing.
  2. Contributor can’t read blobs another way. The Contributor role has no DataActions, and its NotActions exclude Microsoft.Authorization/*/Write, so it can’t grant itself Storage Blob Data Reader either.
  3. Contributor can’t turn Shared Key back on. It has Microsoft.Storage/storageAccounts/write, so it could try. The Deny policy refuses the update, and Contributor can’t touch the policy assignment.
  4. The 41 deletes fail. A CanNotDelete lock applies to every user and role, and creating or removing locks needs Microsoft.Authorization/locks/*, which Owner and User Access Administrator have and Contributor doesn’t.
  5. The blast radius is one resource group. Even before the lock, a resource-group-scoped identity can’t reach the subscription’s other workloads.

Notice what the lock doesn’t do. Locks apply only to the control plane: a CanNotDelete lock on a storage account doesn’t protect the blobs inside it from a data-plane delete. In the original incident, the intake container died through the account key, which the lock would never have stopped. Shared Key off is what closes that path. The guardrails overlap on purpose.

The judgment call: where the lock goes. You have three options. A ReadOnly lock looks stricter, but it blocks every POST to Resource Manager, which breaks App Service scaling, slot swaps and plenty of routine operations; you’ll remove it within a week. Resource-level CanNotDelete locks on only the stateful resources (SQL server, storage account, Key Vault) let Pulumi replace stateless resources freely, but every new stateful resource needs someone to remember its lock. For a regulated production resource group, put CanNotDelete on the whole group. It costs you real friction: any Pulumi change that replaces a resource fails with ScopeLocked, and that replacement becomes a change request where an Owner activates through PIM, removes the lock, and restores it after. That friction is the control. A production delete should need two people.

One trap in this exact architecture: the Function App. Its host storage connection (AzureWebJobsStorage) uses an account key by default, and on some hosting plans the content file share still needs Shared Key. Don’t let that drag Shared Key back onto the PHI account. Give the Function App its own host storage account, use identity-based connections where your plan supports them, and keep stclinicaldocs holding documents and nothing else.

3. Keep the Evidence

Guardrails 1 and 2 make the incident less likely. Guardrail 3 decides what you can tell legal when something else goes wrong. All of it is diagnostic settings and alert rules, and all of it belongs in the platform stack:

const workspaceId = cfg.require("logAnalyticsWorkspaceId"); // the platform's central workspace
const actionGroupId = cfg.require("securityActionGroupId");
const opsRg = cfg.require("opsResourceGroupName");

// Data plane: every read, write and delete against the documents.
new azure_native.monitor.DiagnosticSetting("stclinicaldocs-blob-audit", {
    name: "blob-audit",
    resourceUri: pulumi.interpolate`${docs.id}/blobServices/default`,
    workspaceId,
    logs: ["StorageRead", "StorageWrite", "StorageDelete"].map((category) => ({ category, enabled: true })),
});

// Control plane: the Activity Log, kept as long as the workspace table's retention, not 90 days.
new azure_native.monitor.SubscriptionDiagnosticSetting("activity-log-to-law", {
    name: "activity-log-to-law",
    workspaceId,
    logs: ["Administrative", "Security", "Policy", "Alert"].map((category) => ({ category, enabled: true })),
});

// Identity: who signed in, with which credential, from where. Needs Security Administrator in Entra ID.
new azure_native.aadiam.DiagnosticSetting("entra-signins-to-law", {
    name: "entra-signins-to-law",
    workspaceId,
    logs: ["SignInLogs", "ServicePrincipalSignInLogs", "ManagedIdentitySignInLogs", "AuditLogs"]
        .map((category) => ({ category, enabled: true })),
});

Blob logs land in the StorageBlobLogs table, the Activity Log in AzureActivity, and service principal sign-ins in AADServicePrincipalSignInLogs. Activity Log ingestion into Log Analytics is free, and retention is only charged for the period past 90 days, so there’s no cost argument for leaving it at the default. Set the table retention to whatever your records schedule requires; Log Analytics goes up to 12 years.

Logs you never read only help the lawyers. Two alerts would have caught Sunday night while it was happening. The first fires on any listKeys. With Shared Key off everywhere, nobody has a legitimate reason to list keys, so the alert stays quiet until it matters:

new azure_native.monitor.ActivityLogAlert("storage-listkeys", {
    activityLogAlertName: "storage-listkeys",
    resourceGroupName: opsRg,
    location: "Global",
    scopes: [subscriptionScope],
    enabled: true,
    description: "Someone listed storage account keys. With Shared Key off, nobody should need to.",
    condition: {
        allOf: [
            { field: "category", equals: "Administrative" },
            { field: "operationName", equals: "Microsoft.Storage/storageAccounts/listKeys/action" },
        ],
    },
    actions: { actionGroups: [{ actionGroupId }] },
});

The second watches your deploy identities’ sign-ins. After guardrail 1, every legitimate sign-in for them uses a federated credential, so a sign-in without one is the attacker’s signature. It also flags sign-ins from a network (autonomous system) the identity hasn’t used recently:

const deploySpIds = [sp.objectId];

new azure_native.monitor.ScheduledQueryRule("deploy-sp-unexpected-signin", {
    ruleName: "deploy-sp-unexpected-signin",
    resourceGroupName: opsRg,
    location: "eastus2",
    kind: azure_native.monitor.Kind.LogAlert,
    description: "A deploy identity signed in without its federated credential, or from a network it hasn't used in 2 days.",
    severity: 1,
    enabled: true,
    scopes: [workspaceId],
    evaluationFrequency: "PT15M",
    windowSize: "PT15M",
    overrideQueryTimeRange: "P2D", // log search alerts can look back 2 days at most
    criteria: {
        allOf: [{
            query: pulumi.all(deploySpIds).apply((ids) => `
let deploySps = dynamic(${JSON.stringify(ids)});
let recent = AADServicePrincipalSignInLogs
    | where TimeGenerated > ago(15m) and ResultType == "0" and ServicePrincipalId in (deploySps);
let knownNetworks = AADServicePrincipalSignInLogs
    | where TimeGenerated between (ago(2d) .. ago(15m)) and ResultType == "0" and ServicePrincipalId in (deploySps)
    | distinct ServicePrincipalId, AutonomousSystemNumber;
union
    (recent | where isempty(FederatedCredentialId) | extend Reason = "no federated credential"),
    (recent | join kind=leftanti knownNetworks on ServicePrincipalId, AutonomousSystemNumber | extend Reason = "new network")
| project TimeGenerated, Reason, ServicePrincipalName, ServicePrincipalId, ClientCredentialType, IPAddress, AutonomousSystemNumber, LocationDetails`),
            timeAggregation: azure_native.monitor.TimeAggregation.Count,
            operator: azure_native.monitor.ConditionOperator.GreaterThan,
            threshold: 0,
            failingPeriods: { numberOfEvaluationPeriods: 1, minFailingPeriodsToAlert: 1 },
        }],
    },
    actions: { actionGroups: [actionGroupId] },
});

The query baselines on autonomous system number rather than IP address because GitHub-hosted runners don’t keep their IPs between runs; a per-IP rule would page you on every deployment. The two-day baseline is the ceiling for a log search alert, so a pipeline that runs weekly will look “new” every time. If that’s noisy, move the rule to a Microsoft Sentinel analytics rule with a 14-day baseline; the monitoring post in this series covers where those rules live.

The judgment call: what read logging costs, and where to pay it. StorageRead on a busy account is the most expensive log category you’ll turn on, and the temptation is to skip it everywhere. Don’t spread it everywhere either. Turn on read, write and delete logs for every account that holds regulated data, keep write and delete only for the rest, and enforce the PHI accounts’ setting with a DeployIfNotExists policy so a new account can’t ship without it. The bill for logging the reads on one PHI account is a rounding error next to notifying 38,412 patients.

Troubleshooting

AADSTS700213: No matching federated identity record found for presented assertion subject. The subject GitHub sent doesn’t exactly match any federated credential. The error prints the subject it received; compare it character by character with yours. The usual causes are a missing environment: on the job, a pull request run (its subject ends in :pull_request), or the immutable format: a repository created after July 15, 2026 sends owner@id/repo@id, so a name-only credential will never match it.

ScopeLocked during pulumi up. The change replaces a resource inside the locked resource group, and replacement means delete. Look at the preview’s replace lines before you approve. If the replacement is intended, it’s the change request from guardrail 2; if it isn’t, you just found a property change that would have deleted production.

KeyBasedAuthenticationNotPermitted (403) from an app or tool. Something still authenticates with an account key or an account SAS. Before you flip the setting, run Microsoft’s query against StorageBlobLogs for AuthenticationType in ("AccountKey", "SAS") to find the callers, and move them to Entra ID or a user delegation SAS. That’s the reason to turn on the logs in guardrail 3 first.

The listKeys alert fires and it’s a colleague. Opening the Access keys blade in the Azure portal lists the keys. Treat it as a conversation, not noise: with Shared Key off, they don’t need them, and the next person who opens that blade might not be a colleague.

The Deny policy blocks a deployment of an existing storage account. Deny evaluates on create and update, so an existing account with Shared Key still allowed shows up as non-compliant and fails its next update. Run the policy in Audit first, fix the accounts it reports, then switch the assignment to Deny.

Check Your Own Tenant Before Friday

Four commands tell you how much of this story is already sitting in your subscriptions:

# 1. Pipelines still signing in with a client secret
az ad app list --all \
  --query "[?length(passwordCredentials) > \`0\`].{app:displayName, secretExpires:passwordCredentials[0].endDateTime}" \
  --output table

# 2. Service principals with Contributor or Owner on a whole subscription
az role assignment list --all \
  --query "[?principalType=='ServicePrincipal' && (roleDefinitionName=='Contributor' || roleDefinitionName=='Owner') && starts_with(scope, '/subscriptions/') && !contains(scope, '/resourceGroups/')].{principal:principalName, role:roleDefinitionName, scope:scope}" \
  --output table

# 3. Storage accounts that still accept the account keys
az graph query -q "resources | where type =~ 'microsoft.storage/storageaccounts' | extend sharedKey = tostring(properties.allowSharedKeyAccess) | where sharedKey != 'false' | project subscriptionId, resourceGroup, name, sharedKey"

# 4. Storage accounts with no diagnostic setting on their blob service
for id in $(az storage account list --query "[].id" --output tsv); do
  count=$(az monitor diagnostic-settings list --resource "$id/blobServices/default" --query "length(value || @)" --output tsv 2>/dev/null)
  [ "${count:-0}" = "0" ] && echo "no blob logs: $id"
done

The third query needs the resource-graph CLI extension. An empty allowSharedKeyAccess counts as allowed: the property isn’t set by default, and null means Shared Key works.

Key Takeaways

  • A retired pipeline doesn’t retire its identity. Give every app registration an owner, and treat an identity with no running system as an incident waiting for a date.
  • Federated credentials remove the secret entirely. Bind them to one repository and one environment, and use GitHub’s immutable subject format.
  • Scope deploy identities to the resource group they deploy, lock production against deletes, and turn Shared Key off with a Deny policy. Contributor can’t undo any of the three.
  • The Activity Log sees listKeys but never the reads that follow. Without StorageRead logs you can’t show a low probability of compromise, and HIPAA presumes the breach.
  • Alert on listKeys and on deploy identities that sign in without their federated credential. Both should be silent on a normal day.

The identity design behind guardrails 1 and 2, including PIM for the humans who remove that lock, is in Identity and Access Architecture for Azure Landing Zones. For the policy-as-code side of the Deny assignment, read Governance at Scale: Writing and Deploying Azure Policies with Terraform and Pulumi, and for a full OIDC deployment pipeline with approvals, read CI/CD Pipeline for Your Landing Zone.


Sources