{"id":29371,"date":"2026-02-28T12:32:25","date_gmt":"2026-02-28T11:32:25","guid":{"rendered":"https:\/\/whiteduck.de\/terraform-scaffolding-for-azure-now-secretless-with-oidc\/"},"modified":"2026-09-29T14:46:09","modified_gmt":"2026-09-29T12:46:09","slug":"terraform-scaffolding-for-azure-now-secretless-with-oidc","status":"publish","type":"post","link":"https:\/\/whiteduck.de\/en\/terraform-scaffolding-for-azure-now-secretless-with-oidc\/","title":{"rendered":"Terraform scaffolding for Azure \u2013 now secretless with OIDC"},"content":{"rendered":"<h2>The problem with secrets in CI\/CD pipelines<\/h2>\n<p>Secrets in CI\/CD pipelines have an expiration date. And at some point, someone forgets to renew them in time. Anyone running Infrastructure as Code with Terraform on Azure knows the drill: a service principal is created, the client secret ends up in Key Vault or directly in the pipeline variables, and everything works. Until the secret expires. Or gets leaked. Or someone forgets to rotate it.     <\/p>\n<p>The reality looks like this:<\/p>\n<ul>\n<li>Secrets must be rotated\u2014and regularly. If you forget, you risk outages. <\/li>\n<li>Secrets can be leaked\u2014through logs, misconfiguration, or compromised systems. <\/li>\n<li>Secrets are attack vectors. A stolen client secret grants full access to Azure resources. <\/li>\n<\/ul>\n<p>The good news: There is a better way. With <strong>Workload Identity Federation<\/strong> (OIDC), GitHub Actions and Azure DevOps pipelines can authenticate directly with Azure\u2014without any stored secrets. <\/p>\n<p>In this post, we show how our updated <a id=\"https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure\" href=\"https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure\" target=\"_blank\" rel=\"noreferrer noopener\" type=\"link\">Terraform scaffolding for Azure<\/a> supports this approach out of the box and how you can switch to &#8220;secretless&#8221; in just a few minutes.<\/p>\n<h2>What is Terraform scaffolding for Azure?<\/h2>\n<p>Before you can get started with Terraform on Azure, you need a solid foundation: a service principal with the right permissions, a storage account for the Terraform state, and\u2014until now\u2014a secure place for secrets. That is exactly what we solved some time ago with our Terraform Scaffold for Azure. If you do not yet know the <a href=\"https:\/\/whiteduck.de\/production-ready-terraform-project-scaffolding-for-azure\/\" target=\"_blank\" rel=\"noreferrer noopener\">original announcement post<\/a>: the repository provides a simple setup script that provisions everything you need in just a few minutes.  <\/p>\n<h3>What do you get out of the box?<\/h3>\n<figure>\n<table class=\"has-fixed-layout\">\n<tbody>\n<tr>\n<td><strong>Component<\/strong><\/td>\n<td><strong>Description<\/strong><\/td>\n<\/tr>\n<tr>\n<td><strong>Service Principal<\/strong><\/td>\n<td>With the Contributor and Role Based Access Control Administrator roles (restricted by conditions)<\/td>\n<\/tr>\n<tr>\n<td><strong>Storage Account<\/strong><\/td>\n<td>For the Terraform state, with a soft-delete policy enabled<\/td>\n<\/tr>\n<tr>\n<td><strong>Entra ID permissions<\/strong><\/td>\n<td>The service principal receives Graph API permissions (Group.ReadWrite.All, GroupMember.ReadWrite.All, User.Read.All) to manage groups and identities with the azuread provider. Requires admin consent.<\/td>\n<\/tr>\n<tr>\n<td><strong>Bash &#038; PowerShell support<\/strong><\/td>\n<td>Scripts for both environments<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<p>The goal from the very beginning was: production-ready in under 10 minutes\u2014without manual clicking in the Azure portal.<\/p>\n<div>\n<div><a href=\"https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure\" target=\"_blank\" rel=\"noreferrer noopener\">&#8211;> To the repository<\/a><\/div>\n<\/div>\n<h2>What is new? OIDC as the default <\/h2>\n<p>The biggest change in the repository: OIDC is now the default.<\/p>\n<p>Previously, the focus was on the classic approach with client secret + Key Vault. It works, but it comes with the well-known drawbacks. Secrets must be stored securely, rotated, and managed.  <\/p>\n<p>With the update, we changed the structure:<\/p>\n<ul>\n<li><a href=\"https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure\" target=\"_blank\" rel=\"noreferrer noopener\">Root directory<\/a>: Workload Identity Federation for GitHub Actions or Azure DevOps\u2014recommended<\/li>\n<li><a id=\"https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure\/tree\/main\/client-secret\" href=\"https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure\/tree\/main\/client-secret\" target=\"_blank\" rel=\"noreferrer noopener\" type=\"link\">client-secret directory<\/a>: Classic approach with Key Vault\u2014for scenarios where OIDC is not possible<\/li>\n<\/ul>\n<h3>What does &#8220;secretless&#8221; mean?<\/h3>\n<p>With the OIDC variant, no client secret is created or stored anymore. Instead, GitHub Actions or Azure DevOps issues a short-lived token that Azure validates directly against the federated credential. The result:  <\/p>\n<ul>\n<li>No secret in Key Vault<\/li>\n<li>No secret in pipeline variables<\/li>\n<li>No secret that needs to be rotated<\/li>\n<li>No secret that can be leaked<\/li>\n<\/ul>\n<p>The service principal still exists, but without permanent credentials.<\/p>\n<h2>Why is OIDC more secure?<\/h2>\n<p>With client secrets, there is always a time window in which a stolen secret can be abused. Even if the secret is never intentionally shared, it can leak through debug logs, incorrect masking, or compromised backups. Once leaked, an attacker has full access until someone notices and rotates the secret.  <\/p>\n<p>With OIDC, this time window practically does not exist. The token issued by GitHub is only valid for a few minutes and is tied to a specific workflow run. Even if it were intercepted, it would already have expired before an attacker could use it.  <\/p>\n<p id=\"WarumOIDC?DieVorteileaufeinenBlick\">More importantly, control no longer lies with the secret, but with the origin. Entra ID checks whether the token actually comes from GitHub, whether it was issued for the correct repository, and whether the environment matches. A fork of the repository or a workflow from another branch is rejected\u2014even if the attacker has access to the GitHub secrets.  <\/p>\n<h3>Comparison: client secret vs. OIDC<\/h3>\n<figure>\n<table class=\"has-fixed-layout\">\n<thead>\n<tr>\n<td><strong>Aspect<\/strong><\/td>\n<td><strong>Client secret<\/strong><\/td>\n<td><strong>OIDC (Workload Identity Federation)<\/strong><\/td>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Secret rotation required?<\/td>\n<td>Yes, every 1\u20132 years<\/td>\n<td>No<\/td>\n<\/tr>\n<tr>\n<td>Leak risk<\/td>\n<td>Present<\/td>\n<td>Eliminated<\/td>\n<\/tr>\n<tr>\n<td>Control over source<\/td>\n<td>None (whoever has the secret has access)<\/td>\n<td>Repo, branch, environment<\/td>\n<\/tr>\n<tr>\n<td>Recommended by Microsoft<\/td>\n<td>Legacy<\/td>\n<td>Best practice<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<div style=\"height: 10px;\" aria-hidden=\"true\"><\/div>\n<h2>How does Workload Identity Federation work?<\/h2>\n<p>For anyone who wants to understand what happens under the hood, here is the OIDC authentication flow.<\/p>\n<p>The authentication flow in four steps:<\/p>\n<ul>\n<li>GitHub Actions \/ Azure DevOps starts and requests an OIDC token from its identity provider<\/li>\n<li>The CI\/CD provider issues a signed JWT that contains information such as: repository, branch, environment, workflow<\/li>\n<li>The token is sent to Entra ID, together with the client ID of the service principal<\/li>\n<li>Entra ID validates the token against the configured federated credential\u2014if issuer, subject, and audience match, an Azure access token is issued<\/li>\n<\/ul>\n<figure><img decoding=\"async\" style=\"aspect-ratio: 2.20492396813903; width: 913px; height: auto;\" src=\"https:\/\/whiteduck.de\/wp-content\/uploads\/2026\/01\/Picture-1-terraform-scaffolding.png\" alt=\"\" title=\"\"><figcaption>Azure Access Token<\/figcaption><\/figure>\n<p>The federated credential defines three validation points. Access is granted only if all three fields match exactly. A token from another repository or branch is rejected.  <\/p>\n<figure>\n<table class=\"has-fixed-layout\">\n<thead>\n<tr>\n<td>Field<\/td>\n<td>GitHub example<\/td>\n<td>Azure DevOps example<\/td>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Issuer<\/td>\n<td>https:\/\/token.actions.githubusercontent.com<\/td>\n<td>https:\/\/vstoken.dev.azure.com\/&lt;org-id&gt;<\/td>\n<\/tr>\n<tr>\n<td>Subject<\/td>\n<td>repo:org\/repo:environment:prod<\/td>\n<td>sc:\/\/org\/project\/service-connection<\/td>\n<\/tr>\n<tr>\n<td>Audience<\/td>\n<td>api:\/\/AzureADTokenExchange<\/td>\n<td>api:\/\/AzureADTokenExchange<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>Quick start: Go secretless on Azure in 5 minutes<\/h2>\n<p>Here is the quick guide to set up the Terraform scaffolding with OIDC.<\/p>\n<h3>Prerequisites<\/h3>\n<ul>\n<li>Azure CLI installed and authenticated (<em>az login<\/em>)<\/li>\n<li>Bash with <em>jq<\/em> or PowerShell<\/li>\n<li>Azure permissions: Subscription Owner (or Contributor + User Access Administrator) and Application Developer in Entra ID<\/li>\n<\/ul>\n<p><strong>Step 1: Clone the repository<\/strong><\/p>\n<ul>\n<li>git clone https:\/\/github.com\/whiteducksoftware\/terraform-scaffold-for-azure.git<\/li>\n<li>cd terraform-scaffold-for-azure<\/li>\n<\/ul>\n<p><strong>Step 2: <\/strong>Adjust the .env file with the desired names<\/p>\n<p><strong>Step 3:<\/strong> Configure the federated credential<\/p>\n<ul>\n<li>For GitHub Actions\u2014adjust federated_credential_github.json<\/li>\n<li>For Azure DevOps\u2014adjust federated_credential_ado.json<\/li>\n<\/ul>\n<p><strong>Step 4:<\/strong> Run the setup script (up.sh used as an example here)<\/p>\n<p>In up.sh, select the correct credential file and run it:<\/p>\n<ul>\n<li># FEDERATED_CREDENTIAL_FILE=&#8221;federated_credential_github.json&#8221; # for GitHub<\/li>\n<li># FEDERATED_CREDENTIAL_FILE=&#8221;federated_credential_ado.json&#8221; # for Azure DevOps<\/li>\n<\/ul>\n<p><strong>Step 5: Configure the CI\/CD pipeline (<\/strong>GitHub Actions example)<\/p>\n<pre><code>name: TF Deploy\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - 'main'\n\n# OIDC-Authentifizierung erfordert diese Permission.\n# Ohne 'id-token: write' kann der Workflow kein OIDC-Token anfordern.\npermissions:\n  id-token: write\n  contents: read\n\nenv:\n  tf_version: 1.14.3\n  # Aktiviert OIDC f\u00fcr den AzureRM Terraform Provider.\n  # Damit entf\u00e4llt ARM_CLIENT_SECRET komplett.\n  ARM_USE_OIDC: true\n  # Nutzt Entra ID Auth f\u00fcr den Storage Account statt Access Keys.\n  ARM_USE_AZUREAD: true\n\njobs:\n  deploy-dev:\n    name: Deploy DEV\n    runs-on: ubuntu-latest\n    # Das Environment muss exakt mit dem 'subject' in der Federated Credential \u00fcbereinstimmen.\n    # Beispiel: repo:org\/repo:environment:dev\n    # Ein Workflow ohne passendes Environment wird von Entra ID abgelehnt.\n    environment: dev\n    env:\n      stage: dev\n      rg_name: '&lt;your-resource-group-name&gt;'\n      sa_name: '&lt;your-storage-account-name&gt;'\n      sc_name: '&lt;your-blob-name&gt;'\n      # Diese drei Werte sind KEINE Secrets im klassischen Sinne.\n      # Sie sind \u00f6ffentliche Identifier \u2013 ein Angreifer kann damit alleine nichts anfangen.\n      # Die Authentifizierung erfolgt ausschlie\u00dflich \u00fcber das signierte OIDC-Token.\n      ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}\n      ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}\n      ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}\n    steps:\n      - name: Checkout\n        uses: actions\/checkout@v4\n\n      # OIDC-Login: Kein client-secret Parameter!\n      # Die Action fordert automatisch ein OIDC-Token von GitHub an\n      # und tauscht es bei Entra ID gegen ein Azure Access Token.\n      - name: Azure CLI login\n        uses: azure\/login@v2\n        with:\n          client-id: ${{ secrets.ARM_CLIENT_ID }}\n          tenant-id: ${{ secrets.ARM_TENANT_ID }}\n          subscription-id: ${{ secrets.ARM_SUBSCRIPTION_ID }}\n\n      - name: Setup Terraform\n        uses: hashicorp\/setup-terraform@v3\n        with:\n          terraform_version: ${{ env.tf_version }}\n\n      # Terraform nutzt die OIDC-Session aus dem azure\/login Step.\n      # Keine zus\u00e4tzlichen Credentials n\u00f6tig \u2013 ARM_USE_OIDC \u00fcbernimmt den Rest.\n      - name: Terraform Init\n        run: |\n          terraform init -input=false \\\n              -backend-config=\"resource_group_name=${{ env.rg_name }}\" \\\n              -backend-config=\"storage_account_name=${{ env.sa_name }}\" \\\n              -backend-config=\"container_name=${{ env.sc_name }}\" \\\n              -backend-config=\"key=${{ env.stage }}.tfstate\"\n\n      - name: Terraform Format\n        run: terraform fmt --check\n\n      - name: Terraform Validate\n        run: terraform validate\n\n      - name: Terraform Plan\n        run: |\n          terraform plan \\\n           -var-file=.\/env\/${{ env.stage }}.tfvars \\\n           -out ${{ env.stage }}_tfplan.out\n\n      - name: Terraform Apply\n        run: terraform apply -auto-approve ${{ env.stage }}_tfplan.out<\/code><\/pre>\n<h2>Migration: From client secret to <strong>Workload Identity Federation<\/strong><\/h2>\n<p>If you are already using the Terraform scaffolding with a client secret, you can switch to OIDC in just a few steps.<\/p>\n<h3>Azure DevOps<\/h3>\n<p>To switch the existing Azure DevOps service connection and service principal to federated credentials in the simplest way, proceed as follows:<\/p>\n<ul>\n<li>Create another service connection of type &#8220;Azure Resource Manager&#8221;.<\/li>\n<li>Select &#8220;App registration or managed identity (manual)&#8221;.<\/li>\n<li>Set the credential to &#8220;Workload Identity Federation&#8221;.<\/li>\n<li>Assign a name and enter your tenant ID.<\/li>\n<li>In the next step, copy the issuer and the subject identifier and keep them for later.<\/li>\n<li>Then complete and save the service connection.<\/li>\n<li>Switch to Entra ID and edit the service principal.<\/li>\n<li>Under &#8220;Certificates and secrets&#8221;, create a federated credential and select &#8220;Other issuer&#8221; as the scenario.<\/li>\n<li>Insert the issuer and subject identifier from step 6 and save.<\/li>\n<li>Update the pipeline to use the new service connection. Afterwards, the old service connection with the credential type &#8220;Secret&#8221; can be removed. <\/li>\n<li>The secret on the service principal should also be deleted.<\/li>\n<\/ul>\n<h3>GitHub<\/h3>\n<p>For migration to a federated identity credential for GitHub, the following steps are required:<\/p>\n<ul>\n<li>Switch to Entra ID and edit the service principal.<\/li>\n<li>Under &#8220;Certificates and secrets&#8221;, create a federated credential and select &#8220;GitHub Actions deploying Azure resources&#8221; as the scenario.<\/li>\n<li>Set organization, repository, and entity type so that the following subject identifier results: repo:myorg\/myrepo:environment:dev (using the dev environment as an example).<\/li>\n<li>The secret on the service principal should also be deleted.<\/li>\n<\/ul>\n<p>In both cases, the pipeline or workflow must be updated to use secret-free authentication.<\/p>\n<h2>Conclusion<\/h2>\n<p>Secrets in CI\/CD pipelines are a thing of the past\u2014at least for authentication to Azure. With Workload Identity Federation, Microsoft provides a secure, maintenance-free way to connect GitHub Actions and Azure DevOps pipelines to Azure. No client secret that needs to be rotated. No leak risk from accidentally logged credentials. No nightly pipeline outages because someone forgot the rotation.    <\/p>\n<p>Our updated Terraform scaffolding for Azure makes getting started as easy as possible:<\/p>\n<ul>\n<li>OIDC is now the default\u2014the secure option is also the simplest<\/li>\n<li>Both GitHub Actions and Azure DevOps are supported<\/li>\n<li>One script, five minutes, and the infrastructure is ready<\/li>\n<\/ul>\n<p>If you have any questions, issues, or suggestions for improvement, please <a id=\"https:\/\/outlook.office365.com\/book\/WDWebsiteBuchungsanfrage@whiteduck.de\/?ismsaljsauthenabled=true\" href=\"https:\/\/outlook.office365.com\/book\/WDWebsiteBuchungsanfrage@whiteduck.de\/?ismsaljsauthenabled=true\" target=\"_blank\" rel=\"noreferrer noopener\" type=\"link\">contact<\/a> us! We are happy to help and welcome issues and pull requests in the repository. <\/p>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The problem with secrets in CI\/CD pipelines Secrets in CI\/CD  [&#8230;]<\/p>\n","protected":false},"author":28,"featured_media":29315,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[593],"tags":[575,580,587,581,588],"class_list":["post-29371","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-azure-2","tag-azure-2","tag-cloud-native-2","tag-kubernetes-2","tag-terraform-2","tag-terraform-scaffolding-2"],"acf":[],"_links":{"self":[{"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/posts\/29371","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/users\/28"}],"replies":[{"embeddable":true,"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/comments?post=29371"}],"version-history":[{"count":0,"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/posts\/29371\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/media\/29315"}],"wp:attachment":[{"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/media?parent=29371"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/categories?post=29371"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/whiteduck.de\/en\/wp-json\/wp\/v2\/tags?post=29371"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}