Azure Identity as Code, Just-in-Time Admin Access, and Pipelines with No Stored Secrets

  • Entra ID
  • PIM
  • Terraform
  • Azure DevOps

Before

Standing administrative role assignments.

What I built

Tenant identity managed entirely in Terraform — management groups, custom RBAC roles, 30+ service principals — with just-in-time admin access through Entra ID PIM and OIDC workload identity federation, so pipelines store no cloud credentials.

01PipelineAzure DevOps02OIDC tokenshort-lived03Entra IDfederated identity04Azurescoped accessNo stored cloud credentials. Admin roles arejust-in-time through PIM.

Production work described at the level I can share publicly. Code samples are illustrative patterns, not production code; identifiers are documentation placeholders.

The problem: identity built by hand

  • An Azure tenant spanning production and non-production subscriptions had dozens of applications, integrations and platform tools, each needing service principals, security groups and role assignments.
  • Done by hand, that produces:
    • credentials created in the portal, with no record of why
    • role grants nobody can explain six months later
    • no consistent permission model across subscriptions
    • standing admin rights: people hold powerful roles all day, every day
    • secrets in pipeline variables: the most common way cloud credentials leak

The approach

  • Everything identity-related is code: reviewed, versioned, reproducible.
  • Admin rights exist only while in use: just-in-time activation instead of standing assignment.
  • Pipelines prove who they are without a secret: federation instead of client secrets.
CI run → signed OIDC token (subject = org / workspace / run phase)
       → Entra ID federated credential (trusts that exact issuer + subject)
       → short-lived Azure access token → deploy

No client secret exists anywhere in the chain.

What I built

1. The tenant's identity surface, entirely in Terraform

  • A management group hierarchy (a root with production and non-production groups) that sets the scope boundary for every role.
  • Custom RBAC roles at management-group scope for distinct functions (security administration, SOC, IAM administration, log reading, networking, database, billing), replacing broad built-in roles like Contributor.
  • Security groups with subscription-level role assignments.
  • 30+ application registrations and service principals, one per integration, each with exactly the Microsoft Graph permissions it needs, typed correctly as Application (no signed-in user) or Delegated (acts as the signed-in user).
  • A central mapping that gives each new subscription a default role set, with per-subscription overrides, so onboarding a subscription is a reviewed config change.
  • Decommissioned groups tracked explicitly, so Terraform never recreates something that was deliberately removed.

Illustrative: a custom read-only role scoped to a management group.

resource "azurerm_role_definition" "soc_reader" {
  name        = "soc-reader"
  scope       = azurerm_management_group.root.id
  description = "Read-only visibility for the security operations team"

  permissions {
    actions     = ["*/read"]
    not_actions = []
  }

  assignable_scopes = [azurerm_management_group.root.id]
}

2. Just-in-time privileged access with Entra ID PIM

Standing administrative role assignments are replaced with eligible assignments. Admins activate a role when they need it, for a limited time, and access is reviewed on a schedule.

Typical activation controls in PIM are a maximum activation window, MFA at activation, a written justification and, for the most sensitive roles, approval by a second person. Access reviews then confirm that each eligible assignment is still needed.

3. No stored credentials in pipelines

Every Azure Terraform workspace authenticates with OIDC workload identity federation. I also built Azure DevOps service connections that support workload identity federation.

Illustrative: the Entra side of the trust, and the workspace settings that use it.

# Trust tokens from HCP Terraform, but only for one workspace's apply phase.
resource "azuread_application_federated_identity_credential" "apply" {
  application_id = azuread_application.terraform.id
  display_name   = "tfc-network-apply"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://app.terraform.io"
  subject        = "organization:example-org:project:infra:workspace:azure-network:run_phase:apply"
}
# Workspace environment variables. No client secret anywhere.
TFC_AZURE_PROVIDER_AUTH = true
TFC_AZURE_RUN_CLIENT_ID = <application (client) ID>
ARM_TENANT_ID           = <tenant ID>
ARM_SUBSCRIPTION_ID     = <subscription ID>

How OIDC workload identity federation works

  1. For each run, the CI platform issues a short-lived signed token (a JWT). Its subject claim identifies the organization, project, workspace and run phase (plan or apply).
  2. An Entra ID app registration holds a federated credential that trusts that issuer and that exact subject.
  3. Entra ID exchanges the token for an Azure access token. There's no client secret to leak, store or rotate.
  4. Because the run phase is in the subject, plan and apply can get different identities: read-only for plan, write for apply (TFC_AZURE_PLAN_CLIENT_ID / TFC_AZURE_APPLY_CLIENT_ID).

Things to watch

  • The subject is the whole security boundary. A federated credential trusts whatever matches its subject. Keep subjects exact, down to the workspace and run phase, or other workspaces can borrow the identity.
  • Application vs Delegated Graph permissions are not interchangeable. Application permissions act tenant-wide without a user, and are the ones that need the closest scrutiny and admin consent.
  • Custom roles drift behind the platform. When Azure adds new actions, wildcard-based roles may grant more than intended, and explicit lists may grant less. Review them.
  • Decommissioning is part of the lifecycle. Without an explicit record of removed groups, a stale branch or an old module can quietly recreate access that was deliberately removed.

What it solves

  • Every service principal, group, role definition and role assignment is version-controlled and peer-reviewed. No portal-created credentials, no unexplained grants.
  • Admin rights exist only while they're being used.
  • The most common cloud credential leak, long-lived secrets in CI variables, is removed for Azure infrastructure pipelines.
  • New subscriptions and integrations follow one repeatable pattern.

What it doesn't solve

  • Service principals that still use secrets. Integrations that can't federate still need secret or certificate rotation and expiry alerting. Where a service connection still uses a client secret, the secret is managed outside Terraform: the configuration ignores changes to that credential, so the secret never lands in Terraform state and rotating it doesn't register as drift.
  • Over-permissioned Graph grants. Code makes grants visible, not minimal. They still need review.
  • Compromised pipelines. Federation removes the stored secret, but a compromised pipeline definition can still use the identity it's given. Protect who can edit pipelines and which branches can run apply.

Checklist

  • [ ] Management groups, custom roles, groups and assignments all in code
  • [ ] One service principal per integration, with least-privilege Graph permissions
  • [ ] Application vs Delegated permissions reviewed for each service principal
  • [ ] Standing admin assignments replaced with PIM eligible assignments
  • [ ] Pipelines use OIDC federation; no client secrets in CI variables
  • [ ] Federated credential subjects scoped to an exact workspace and run phase
  • [ ] Decommissioned identities tracked so they can't be recreated
  • [ ] Rotation and expiry alerting for any remaining secrets

Further reading

Get in touch