Rotating An Azure Service Principal Secret

A Github Actions workflow started failing to login to Azure. The Service Principal (SP) I use to deploy, github-resolv-uat, had its client secret expire, this is expected as az ad sp create-for-rbac secrets only last 12 months by default. See also Deploying .NET Applications To Azure for how the SP was originally created.

The SP was originally created with a command like this, which both creates the SP and assigns it Contributor at a resource group scope

1
az ad sp create-for-rbac --name github-auth --role contributor --scopes /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/demo-rg-bb8c7a39-dev --json-auth --output json

Re-running create-for-rbac again would create a brand new SP (or reset far more than I wanted), so the goal here was to rotate just the secret on the existing SP, leaving its role assignment and scope untouched.

Steps

Find the App and confirm the expired secret

I already knew the clientId (this is the appId on the App Registration) from the Github secret, f225f027-d611-43d1-89d8-764d8c0640fb. If I hadn’t, App Registrations sharing a naming convention (mine are prefixed github- or similar) can be listed with

1
az ad app list --query "[?starts_with(displayName, 'github-')].{name:displayName, appId:appId, objectId:id}" -o table

Confirm the app and check its credential expiry

1
az ad app show --id f225f027-d611-43d1-89d8-764d8c0640fb -o json

The important part of the output was passwordCredentials

1
2
3
4
5
6
7
8
9
"passwordCredentials": [
{
"displayName": "rbac",
"endDateTime": "2026-09-14T00:59:36Z",
"hint": "CPp",
"keyId": "52e59555-2007-45b5-b095-f79abc22aaeb",
"startDateTime": "2025-09-14T00:59:36Z"
}
]

endDateTime confirmed the secret expired 12 months to the day after it was created, matching the Github Actions login failures.

Confirm the SP object id and its existing role assignment

The App Registration (az ad app show) and the Service Principal (az ad sp show) are different objects in Entra that share the same appId, so its worth confirming both resolve to the SP you expect

1
az ad sp show --id f225f027-d611-43d1-89d8-764d8c0640fb --query "{spObjectId:id, appId:appId, displayName:displayName}" -o json
1
2
3
4
5
{
"appId": "f225f027-d611-43d1-89d8-764d8c0640fb",
"displayName": "github-resolv-uat",
"spObjectId": "efe1dd68-690b-45c6-b647-5d315d1659ef"
}

Before rotating anything, I wanted proof of the role/scope to compare against after, so it doesn’t change as a side effect

1
az role assignment list --assignee efe1dd68-690b-45c6-b647-5d315d1659ef --all -o json

Using the spObjectId with --all mattered here, --assignee with the appId alone returned an empty array for me.

1
2
3
4
5
6
7
8
9
[
{
"roleDefinitionName": "Contributor",
"scope": "/subscriptions/9f057e40-0103-4ed1-99c2-4315ae48e904/resourceGroups/resolv-uat-rg",
"principalId": "efe1dd68-690b-45c6-b647-5d315d1659ef",
"principalName": "f225f027-d611-43d1-89d8-764d8c0640fb",
"principalType": "ServicePrincipal"
}
]

This confirmed exactly the role (Contributor) and scope (resolv-uat-rg) from the original create-for-rbac, this is what should stay unchanged after rotating.

Rotate the secret

az ad app credential reset only touches passwordCredentials on the App Registration, it does not create a new SP and does not touch role assignments, so it was the right command here

1
az ad app credential reset --id f225f027-d611-43d1-89d8-764d8c0640fb --display-name rbac --years 1 -o json
1
2
3
4
5
{
"appId": "f225f027-d611-43d1-89d8-764d8c0640fb",
"password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tenant": "7100b71f-64ad-41fa-8bf6-3dd631e76929"
}

Note this replaces the credential rather than appending to it, using the same --display-name as before (rbac) kept things tidy.

This output alone isn’t the same shape as the original --json-auth output though, its missing subscriptionId and the endpoint urls that the Github Action expects in the AZ_CREDENTIALS secret, so I rebuilt it using values already gathered

1
az account show --query "{subscriptionId:id, tenantId:tenantId}" -o json
1
2
3
4
5
6
7
8
9
10
11
12
{
"clientId": "f225f027-d611-43d1-89d8-764d8c0640fb",
"clientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"subscriptionId": "9f057e40-0103-4ed1-99c2-4315ae48e904",
"tenantId": "7100b71f-64ad-41fa-8bf6-3dd631e76929",
"activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
"resourceManagerEndpointUrl": "https://management.azure.com/",
"activeDirectoryGraphResourceId": "https://graph.windows.net/",
"sqlManagementEndpointUrl": "https://management.core.windows.net:8443/",
"galleryEndpointUrl": "https://gallery.azure.com/",
"managementEndpointUrl": "https://management.core.windows.net/"
}

This whole block gets pasted into the Github repo secret

1
${{ secrets.AZ_CREDENTIALS }}

Finally I re-ran the role assignment check from the previous step to confirm nothing moved, and re-ran the failing Github Action to confirm it logs in and deploys successfully.

References