We have reached the last piece of the series – and it’s one I have been referring to repeatedly. In all the examples so far, the azure/login step authenticated to Azure without any password, just with a few IDs. It’s time to see how and, especially, why this is the correct way.
There are actually two different questions about secrets:
-
How does GitHub Actions authenticate to Azure to deploy – without a client secret stored in the repo?
-
How does your application access Azure resources (Key Vault, SQL, Storage) – without connection strings containing passwords?
The answer to the first is OIDC. The answer to the second is Managed Identity. Together, they make visible passwords disappear.
🔓 The problem with classic passwords
The old approach looked like this: you generated a client secret for a service principal, put it in GitHub Secrets, and the connection strings with passwords stayed in app settings. It works, but:
-
Secrets expire – and one day the deployment fails because the password expired.
-
They must be rotated manually – a chore everyone postpones.
-
They can be leaked – a log, a screenshot, a misconfigured fork.
-
Anyone with access to the repo or configuration dangerously approaches them.
The safest secret is the one that does not exist. If you don’t store any long-term password, there’s nothing to leak or rotate.
🎫 Part 1: GitHub → Azure via OIDC
OIDC (OpenID Connect) replaces the password with trust. At each run, GitHub issues a short-lived token (a JWT) that verifiably says, “this is run X from repo Y, on the production environment.” Azure is pre-configured to trust such tokens – a relationship called federated credential.
The result: GitHub receives, only for the duration of the run, an access token to Azure. No client secret is stored anywhere.
Step 1 – create the identity and federated trust
In an Entra ID application, you bind a federated credential to your repo and environment:
az ad app create --display-name "github-myapp-deploy"
az ad app federated-credential create \
--id <APP_ID> \
--parameters '{
"name": "github-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:owner/myapp:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'
The subject is the key: it binds the trust exactly to the production environment in your repo. Another repo, another branch, or another environment does not match – so it does not receive a token. This ties directly into the approval gates from part 2.
Step 2 – azure/login without client secret
In the workflow, you give the job permission to request an OIDC token and log in only with IDs:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
permissions: id-token: write is mandatory – without it, the runner cannot request the OIDC token. And AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID are not passwords: they are simple identifiers. Even if visible, they unlock nothing without the federated trust configured in Azure.
🪪 Part 2: application → Azure via Managed Identity
We solved the deployment. But the application itself? It needs to read a secret from Key Vault, connect to SQL, write to Storage. The classic approach: connection strings with passwords in app settings. The correct approach: Managed Identity.
A Managed Identity is an identity managed by Azure, attached to your App Service. Azure fully manages its lifecycle – there is no password for you to see or manage.
Activate the identity in Bicep
On the web app, add an identity block (continuing the Bicep from part 3):
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
name: webAppName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: plan.id
httpsOnly: true
}
}
Then you grant it access with RBAC – for example, the Key Vault Secrets User role on the vault – also from Bicep. The identity receives exactly the permissions it needs, nothing more.
In code: DefaultAzureCredential
In the .NET application, you use the Azure.Identity package. DefaultAzureCredential automatically detects Managed Identity in Azure (and your local credentials when developing), without any secret in code:
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var client = new SecretClient(
new Uri("https://my-vault.vault.azure.net/"),
new DefaultAzureCredential());
KeyVaultSecret secret = await client.GetSecretAsync("DbPassword");
For SQL, you don’t even need a password in the connection string anymore – you connect with the identity:
Server=tcp:my-sql.database.windows.net,1433;
Database=mydb;
Authentication=Active Directory Default;
Authentication=Active Directory Default tells the SQL client to use the same Managed Identity. Zero passwords, neither in code nor in app settings.
🧹 Why visible secrets disappear
Put the two pieces together and the result is clean:
-
You don’t store any client secret in GitHub – only non-sensitive IDs, and authentication is done with short-lived tokens via OIDC.
-
You don’t have connection strings with passwords in app settings – the application authenticates via Managed Identity.
-
Nothing to rotate, nothing to expire, nothing to leak. Secrets that don’t exist cannot be stolen.
✅ Some healthy rules
-
Bind the federated credential to the environment (production), not just the repo – least privilege.
-
Use minimal RBAC for Managed Identity – only strictly necessary roles.
-
A user-assigned managed identity can be reused by multiple resources if needed.
-
Keep only identifiers in GitHub Secrets, never long-term passwords.
Conclusion: where we have arrived
With this, the series is complete. We started from “I have an automatic build” and arrived at a truly professional pipeline for .NET + Azure:
-
Part 1 – the foundation: build once, blocking tests, a promotable artifact.
-
Part 2 – environments with approval gates: the same artifact through dev → staging → production, with human approval.
-
Part 3 – Infrastructure as Code with Bicep: infrastructure defined in code, versioned and reproducible.
-
Part 4 – deployment slots: releases with zero downtime and rollback in seconds.
-
Part 5 – secrets without passwords: OIDC for deploy, Managed Identity for the application.
A professional pipeline does not mean more YAML, but more guarantees: you build once, promote in a controlled way, define everything in code, deliver without downtime, and keep no password visible. Now you have the complete map – step by step, from zero to production.