Skip to main content
Azure

Workload Identity Federation for Teams app deployments: no more secrets in your CI/CD pipeline

Workload Identity Federation for Teams app deployments: no more secrets in your CI/CD pipeline

Back in 2020 I wrote about CI/CD for Teams apps with Azure DevOps. In that post I stored a username, password, and appId as pipeline variables. That was perfectly fine at the time. Everybody did it that way, and it worked. But if I’m being honest, every time I set up a new pipeline like that I got a little uncomfortable. Secrets sitting in a variables tab, protected by a lock icon and a prayer. We can do better now.

I also wrote about stopping the use of client secrets for your Office 365 services where I covered Managed Identities. That post was about runtime identity: your app running in Azure authenticating without secrets. This post is the other half of the story: build-time identity. How does your CI/CD pipeline authenticate without secrets?

The problem with pipeline secrets

You know the drill. You create an Azure AD app registration, generate a client secret, copy it into your pipeline variables, and mark it as secret. Then six months later the secret expires and your pipeline breaks on a Friday afternoon. Or worse, someone screenshots the variables tab during a demo. Or a developer leaves and you’re not sure which pipelines they had access to.

Secrets expire. Secrets get leaked. Secrets need rotation. And the variables section in your pipeline is basically a vault with a sticky note on it saying “please don’t look.” It’s not great.

What is Workload Identity Federation

Workload Identity Federation uses OIDC (OpenID Connect) to eliminate secrets entirely. The concept is surprisingly simple once you wrap your head around it.

Here’s what happens when your GitHub Actions workflow runs. GitHub generates a short-lived OIDC token that says “I am a workflow running in repo RickVanRousselt/my-teams-bot on the main branch.” Your workflow sends that token to Azure AD. Azure AD looks at the token and checks: “Do I trust tokens from GitHub? Do I trust this specific repo and branch?” If the answer is yes, Azure AD hands back an Azure access token. Your workflow uses that token to deploy things.

The beautiful part? No secrets are stored anywhere. Not in GitHub, not in Azure DevOps, not in a key vault. The trust relationship is based on identity, not on a shared secret. This way, there’s nothing to expire, nothing to rotate, and nothing to leak.

Setting it up

First off, let’s configure the Azure side. You need to tell Azure AD to trust tokens coming from GitHub for your specific repository. This is a one-time setup per repo.

az ad app federated-credential create \
  --id "your-app-object-id" \
  --parameters '{
    "name": "github-teams-deploy",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:RickVanRousselt/my-teams-bot:ref:refs/heads/main",
    "description": "GitHub Actions deploy for Teams bot",
    "audiences": ["api://AzureADTokenExchange"]
  }'

The subject field is where the magic happens. It locks down the trust to a specific repo and branch. You can’t just grab any GitHub token and use it because Azure AD will only accept tokens that match this exact subject claim. Of course, you’ll want to replace the app object ID and repo name with your own values.

You also need to make sure the app registration has the right permissions. For deploying to App Service you’ll need Contributor on the resource group or the App Service itself. For updating Teams apps in the catalog, the app needs the appropriate Microsoft Graph permissions.

The pipeline

Now let’s get started with the actual workflow. Here’s a complete GitHub Actions workflow that builds a .NET Teams bot, deploys it to Azure App Service, and updates the Teams app in the catalog, all without a single secret.

name: Deploy Teams App

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  AZURE_TENANT_ID: "your-tenant-id"
  AZURE_CLIENT_ID: "your-federated-app-client-id"
  AZURE_SUBSCRIPTION_ID: "your-subscription-id"
  APP_SERVICE_NAME: "my-teams-bot-app"
  TEAMS_APP_ID: "your-teams-app-id"

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "8.0.x"

      - name: Build and publish
        run: |
          dotnet restore
          dotnet publish -c Release -o ./publish

      - name: Package Teams app
        run: |
          cd teams-manifest
          zip -r ../teams-app-package.zip .

      - name: Azure Login with OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ env.AZURE_CLIENT_ID }}
          tenant-id: ${{ env.AZURE_TENANT_ID }}
          subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.APP_SERVICE_NAME }}
          package: ./publish

      - name: Install CLI for Microsoft 365
        run: npm install -g @pnp/cli-microsoft365

      - name: Login to M365 with federated identity
        run: |
          m365 login --authType identity \
                      --appId ${{ env.AZURE_CLIENT_ID }} \
                      --tenant ${{ env.AZURE_TENANT_ID }}

      - name: Update Teams app in catalog
        run: |
          m365 teams app update \
            --id ${{ env.TEAMS_APP_ID }} \
            --filePath ./teams-app-package.zip

The key bit is the permissions block at the top. id-token: write tells GitHub “this workflow needs to request OIDC tokens.” Without that, the azure/login action won’t be able to get a token and you’ll be scratching your head wondering why authentication fails. I may or may not have spent 30 minutes figuring that out myself.

Notice there’s no AZURE_CLIENT_SECRET anywhere. The azure/login@v2 action knows how to do the OIDC dance automatically when you give it a client-id, tenant-id, and subscription-id without a secret. It requests a token from GitHub, sends it to Azure AD, and gets back an access token. All behind the scenes.

The CLI for Microsoft 365 step is where we update the Teams app in the organizational catalog. The CLI for Microsoft 365 is a fantastic community project, and if you’re not using it yet, you should be.

The full picture

Now take a step back and look at what we’ve achieved across these posts. In post 053 we used Managed Identity to eliminate secrets at runtime, so your bot running in Azure App Service authenticates to Microsoft Graph, SharePoint, and other services without a client secret or certificate. In this post we’ve used Workload Identity Federation to eliminate secrets at build-time, so your CI/CD pipeline deploys code and updates the Teams app without any stored credentials.

Managed Identity for runtime. Workload Identity Federation for build-time. Zero secrets in your entire Teams app lifecycle. No more rotating certificates every six months. No more “who has access to the pipeline variables?” conversations. No more Friday afternoon outages because a secret expired.

I sleep a little better at night knowing there’s genuinely nothing to leak.