RO EN

Professional CI/CD with GitHub Actions (5): passwordless secrets with Managed Identity and OIDC

Professional CI/CD with GitHub Actions (5): passwordless secrets with Managed Identity and OIDC ✨ Imagine generată cu AI
Doru Bulubasa
09 September 2026
44 views

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.