Automate Azure PIM Role Activation Using GitHub Actions and OIDC (No Secrets Needed)
Tags: Azure PIM | GitHub Actions | OIDC | Microsoft Graph | PowerShell | DevOps | M365 Level: Intermediate — Azure AD, GitHub Actions, PowerShell
Introduction
Azure Privileged Identity Management (PIM) lets you assign roles on a just-in-time basis — users activate roles only when needed, reducing your attack surface. But what if you want to automate PIM role assignment from a CI/CD pipeline without storing any credentials?
In this article, I walk through the complete end-to-end implementation I built:
- A GitHub Actions workflow that lints and runs a PowerShell script
- The script uses OIDC (OpenID Connect) to authenticate to Azure — zero stored secrets
- It reads eligible PIM roles for a target user and creates adminAssign requests via Microsoft Graph
- The workflow has automatic retry logic and reports final pass/fail status
Everything in this article is based on a real working implementation with real errors fixed along the way.
Why OIDC Instead of Client Secrets?
Traditional CI/CD pipelines store an Azure App Registration client secret (or certificate) as a GitHub secret. That secret:
- Expires and needs manual rotation
- Can be leaked if someone gets access to your GitHub secrets
- Creates a long-lived credential that exists outside Azure's control plane
OIDC (Workload Identity Federation) replaces this with a trust relationship. GitHub Actions generates a short-lived JWT token per run. Azure AD verifies that token — no secret ever needs to be stored. The token is valid only for that specific run and expires automatically.
GitHub Actions Run
│
▼
GitHub OIDC Provider ──── JWT Token ────▶ Azure AD
(validates token
against federated
credential config)
│
▼
Issues short-lived
access token
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ GitHub Actions Workflow │
│ │
│ Job 1: PSScriptAnalyzer Lint │
│ ├── Checkout repo │
│ ├── Install PSScriptAnalyzer │
│ └── Lint Azure/AzureRoleEnable-v5.ps1 │
│ ├── Error found? → FAIL (fix and re-run) │
│ └── Clean? → trigger Job 2 │
│ │
│ Job 2: Activate PIM Roles (depends on lint passing) │
│ ├── Azure OIDC Login (no client secret) │
│ ├── Get Microsoft Graph access token via OIDC │
│ ├── Install Microsoft Graph PowerShell SDK │
│ ├── Run AzureRoleEnable-v5.ps1 (attempt 1) │
│ │ └── Fail? → wait 30s → attempt 2 │
│ └── Report final status │
└─────────────────────────────────────────────────────────────┘
│
▼ Microsoft Graph API
┌─────────────────────────────────────────────────────────────┐
│ AzureRoleEnable-v5.ps1 │
│ ├── Connect-MgGraph (using OIDC access token) │
│ ├── Get-MgUser (resolve target UPN → Object ID) │
│ ├── Get-MgRoleManagementDirectoryRoleEligibilitySchedule │
│ │ (fetch all eligible roles for user) │
│ ├── Check if role already active → skip │
│ └── New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest
│ (adminAssign action for each eligible role) │
└─────────────────────────────────────────────────────────────┘
Prerequisites
Before starting, you need:
- An Azure AD tenant (Entra ID)
- A GitHub repository
- Azure AD P2 or Microsoft Entra ID Governance licence (required for PIM)
- A user account with permissions to create App Registrations in Azure AD
- A Global Administrator or Privileged Role Administrator to grant admin consent (you can prepare everything and ask them to click one button)
Step 1 — Create the Azure App Registration
Navigate to Azure Portal → Microsoft Entra ID → App Registrations → New Registration.
| Field | Value |
|---|---|
| Name | github-pim-activator |
| Supported account types | Accounts in this org only |
| Redirect URI | Leave blank |
After creating, note down:
- Application (client) ID
- Directory (tenant) ID
Add API Permission
Go to API Permissions → Add a permission → Microsoft Graph → Application permissions.
Search for and add: RoleManagement.ReadWrite.Directory
⚠️ This permission requires admin consent. The "Grant admin consent" button will be greyed out if you are not a Global Administrator. Note this down to ask your admin later.
Add Federated Credential (OIDC)
Go to Certificates & secrets → Federated credentials → Add credential.
| Field | Value |
|---|---|
| Federated credential scenario | GitHub Actions deploying Azure resources |
| Organization | your-github-org |
| Repository | your-repo-name |
| Entity type | Branch |
| Branch | main |
| Name | github-actions-main-branch |
This tells Azure AD: "Trust JWT tokens from GitHub Actions running on the main branch of this repository."
Step 2 — Add GitHub Secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions → New repository secret.
Add these two secrets:
| Secret Name | Value |
|---|---|
AZURE_CLIENT_ID | Application (client) ID from Step 1 |
AZURE_TENANT_ID | Directory (tenant) ID from Step 1 |
No client secret is needed. OIDC handles authentication entirely.
Step 3 — The PowerShell Script
Create Azure/AzureRoleEnable-v5.ps1 in your repository. This script is PSScriptAnalyzer compliant — it passes lint checks with zero errors.
Key design decisions:
- Uses
[System.Net.NetworkCredential]::new("", $accessToken).SecurePasswordinstead ofConvertTo-SecureString -AsPlainText(avoids PSScriptAnalyzer error) - Uses
Write-Informationinstead ofWrite-Host(avoids PSScriptAnalyzer warnings) - Reads target UPN and access token from environment variables
- Checks if a role is already active before attempting to assign (skip logic)
- Exits with code
1if any role assignment fails (triggers retry in workflow)
# ============================================================
# Azure PIM Role Activator v5 - App-Only OIDC
# PSScriptAnalyzer compliant
# ============================================================
param(
[string]$TargetUserUPN = $env:TARGET_USER_UPN,
[string]$Justification = "M365 Team - GitHub Actions"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$InformationPreference = "Continue"
if ([string]::IsNullOrWhiteSpace($TargetUserUPN)) {
Write-Error "TARGET_USER_UPN required"; exit 1
}
$accessToken = $env:AZURE_ACCESS_TOKEN
if ([string]::IsNullOrWhiteSpace($accessToken)) {
Write-Error "AZURE_ACCESS_TOKEN not set"; exit 1
}
Write-Information "Connecting to Microsoft Graph..."
# Use [securestring] direct cast to avoid PSAvoidUsingConvertToSecureStringWithPlainText
$secureToken = [System.Net.NetworkCredential]::new("", $accessToken).SecurePassword
Connect-MgGraph -AccessToken $secureToken -NoWelcome
Write-Information "Resolving user: $TargetUserUPN"
$user = Get-MgUser -UserId $TargetUserUPN -Property "id,userPrincipalName,displayName" -ErrorAction Stop
Write-Information "User: $($user.DisplayName) [$($user.Id)]"
Write-Information "Fetching eligible PIM roles..."
$eligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
-Filter "principalId eq '$($user.Id)'" `
-ExpandProperty RoleDefinition `
-ErrorAction SilentlyContinue
if (-not $eligibleRoles -or $eligibleRoles.Count -eq 0) {
Write-Warning "No eligible roles found for $TargetUserUPN"
exit 0
}
Write-Information "Found $($eligibleRoles.Count) eligible role(s)."
$ok = 0; $fail = 0; $skip = 0
foreach ($role in $eligibleRoles) {
$rn = $role.RoleDefinition.DisplayName
try {
# Check if already active — skip if so
$aa = Get-MgRoleManagementDirectoryRoleAssignmentSchedule `
-Filter "principalId eq '$($user.Id)' and roleDefinitionId eq '$($role.RoleDefinitionId)'" `
-ErrorAction SilentlyContinue
if ($aa) {
Write-Information "SKIP: $rn (already active)"
$skip++
continue
}
# Submit adminAssign request
New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter @{
Action = "adminAssign"
PrincipalId = $user.Id
RoleDefinitionId = $role.RoleDefinitionId
DirectoryScopeId = if ($role.DirectoryScopeId) { $role.DirectoryScopeId } else { "/" }
Justification = $Justification
ScheduleInfo = @{
StartDateTime = (Get-Date).ToUniversalTime().ToString("o")
Expiration = @{ Type = "AfterDuration"; Duration = "PT8H" }
}
} | Out-Null
Write-Information "OK : $rn activated for 8h"
$ok++
}
catch {
Write-Error "FAIL: $rn - $($_.Exception.Message)"
$fail++
}
}
Write-Information "Summary: OK=$ok SKIP=$skip FAIL=$fail"
Disconnect-MgGraph -ErrorAction SilentlyContinue
if ($fail -gt 0) { exit 1 }
Step 4 — The GitHub Actions Workflow
Create .github/workflows/run-AzureRoleEnable.yml:
name: Azure PIM Role Activator (OIDC)
on:
workflow_dispatch:
inputs:
target_user_upn:
description: 'Target user UPN (e.g. user@domain.com)'
required: false
default: 'user@yourdomain.com'
type: string
justification:
description: 'Activation justification'
required: false
default: 'M365 Team - GitHub Actions'
type: string
permissions:
id-token: write # Required for OIDC token
contents: read # Required for checkout
jobs:
lint:
name: PSScriptAnalyzer Lint
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install PSScriptAnalyzer
shell: pwsh
run: Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
$results = Invoke-ScriptAnalyzer `
-Path "Azure/AzureRoleEnable-v5.ps1" `
-Severity Error, Warning `
-Recurse
if ($results) {
$results | Format-Table -AutoSize
$errors = $results | Where-Object { $_.Severity -eq 'Error' }
if ($errors) {
Write-Error "PSScriptAnalyzer found $($errors.Count) error(s). Fix before proceeding."
exit 1
}
} else {
Write-Host "PSScriptAnalyzer: No issues found." -ForegroundColor Green
}
activate-pim-roles:
name: Activate PIM Roles
runs-on: windows-latest
needs: lint # Only runs if lint job passes
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure OIDC Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Get Microsoft Graph Access Token via OIDC
id: get-token
shell: pwsh
run: |
$token = az account get-access-token `
--resource https://graph.microsoft.com `
--query accessToken -o tsv
echo "AZURE_ACCESS_TOKEN=$token" >> $env:GITHUB_ENV
- name: Install Microsoft Graph PowerShell SDK
shell: pwsh
run: |
Install-Module Microsoft.Graph -Scope CurrentUser -Force -AllowClobber
Import-Module Microsoft.Graph -Force
- name: Run PIM Role Activator (attempt 1)
id: attempt1
shell: pwsh
continue-on-error: true
env:
TARGET_USER_UPN: ${{ github.event.inputs.target_user_upn }}
JUSTIFICATION: ${{ github.event.inputs.justification }}
run: |
Write-Host "=== Attempt 1 ===" -ForegroundColor Cyan
& "./Azure/AzureRoleEnable-v5.ps1"
- name: Wait before retry
if: steps.attempt1.outcome == 'failure'
shell: pwsh
run: Start-Sleep -Seconds 30
- name: Run PIM Role Activator (attempt 2 on failure)
if: steps.attempt1.outcome == 'failure'
id: attempt2
shell: pwsh
continue-on-error: true
env:
TARGET_USER_UPN: ${{ github.event.inputs.target_user_upn }}
JUSTIFICATION: ${{ github.event.inputs.justification }}
run: |
Write-Host "=== Attempt 2 ===" -ForegroundColor Cyan
& "./Azure/AzureRoleEnable-v5.ps1"
- name: Report final status
shell: pwsh
run: |
$a1 = "${{ steps.attempt1.outcome }}"
$a2 = "${{ steps.attempt2.outcome }}"
if ($a1 -eq "success") {
Write-Host "PIM role activation SUCCEEDED on attempt 1." -ForegroundColor Green
} elseif ($a2 -eq "success") {
Write-Host "PIM role activation SUCCEEDED on attempt 2." -ForegroundColor Yellow
} else {
Write-Error "PIM role activation FAILED after all attempts."
exit 1
}
Step 5 — Grant Admin Consent
This is the one step that requires a Global Administrator or Privileged Role Administrator in your tenant.
- Navigate to:
Azure Portal → App Registrations → github-pim-activator → API Permissions - Click "Grant admin consent for [your tenant]"
- Confirm the dialog
Once granted, the RoleManagement.ReadWrite.Directory permission status changes from ⚠️ "Not granted" to ✅ "Granted".
Optional but recommended: Also assign the "Privileged Role Administrator" Azure AD role directly to the
github-pim-activatorService Principal via:Azure AD → Roles and administrators → Privileged Role Administrator → Add assignment → search github-pim-activator
Errors I Hit and How I Fixed Them
This is a real implementation — here are the actual errors encountered and their fixes.
Error 1: PSScriptAnalyzer — PSAvoidUsingConvertToSecureStringWithPlainText
Cause: Using ConvertTo-SecureString -AsPlainText -Force to convert the access token.
Fix: Replace with:
# Before (triggers PSScriptAnalyzer error)
$secureToken = ConvertTo-SecureString $accessToken -AsPlainText -Force
# After (PSScriptAnalyzer compliant)
$secureToken = [System.Net.NetworkCredential]::new("", $accessToken).SecurePassword
Error 2: PSScriptAnalyzer — PSAvoidUsingWriteHost (7 warnings)
Cause: Multiple Write-Host calls throughout the script.
Fix:
# Add at top of script
$InformationPreference = "Continue"
# Replace all Write-Host with Write-Information
Write-Information "Connecting to Microsoft Graph..."
Error 3: OIDC Login Failed — "client-id and tenant-id not supplied"
Cause: GitHub secrets AZURE_CLIENT_ID and AZURE_TENANT_ID had not been added to the repository yet.
Fix: Navigate to GitHub → Settings → Secrets and variables → Actions and add both secrets.
Error 4: 403 Forbidden — "Insufficient privileges"
Cause: The RoleManagement.ReadWrite.Directory Application permission was added to the App Registration, but admin consent was never granted.
Fix: A Global Administrator must click "Grant admin consent for [tenant]" in the API permissions page.
How to Run the Workflow
Once everything is set up and admin consent is granted:
- Go to your repository → Actions tab
- Select "Azure PIM Role Activator (OIDC)"
- Click "Run workflow"
- Fill in:
- Target user UPN:
user@yourdomain.com - Justification:
M365 Team - GitHub Actions
- Target user UPN:
- Click "Run workflow"
The workflow will:
- ✅ Run PSScriptAnalyzer lint
- ✅ Authenticate via OIDC (no stored secret)
- ✅ Get Microsoft Graph access token
- ✅ Resolve the user by UPN
- ✅ Fetch all eligible PIM roles
- ✅ Skip roles already active
- ✅ Submit
adminAssignrequests for remaining roles - ✅ Report pass/fail with retry on failure
Key Design Decisions
Why adminAssign and not selfActivate?
selfActivate requires delegated authentication — a real user signing in interactively. GitHub Actions uses app-only (application) authentication, which cannot impersonate a user. adminAssign works with app-only auth and assigns roles on behalf of the target user.
Why OIDC over a client secret?
No rotation, no leakage risk, no long-lived credential. The token is scoped to the exact repo + branch and expires immediately after the run.
Why PSScriptAnalyzer lint as a separate job?
It ensures the script is always clean before any Azure resources are touched. If a code change introduces a linting error, the workflow fails fast at the lint stage without ever attempting to connect to Azure.
Why retry logic?
Microsoft Graph can occasionally return transient errors on PIM requests (throttling, replication delays). A 30-second wait and second attempt handles these without requiring a full re-run.
Summary
| Component | Details |
|---|---|
| Script | Azure/AzureRoleEnable-v5.ps1 — PSScriptAnalyzer compliant |
| Workflow | .github/workflows/run-AzureRoleEnable.yml — lint + retry |
| Auth | OIDC Workload Identity Federation — zero stored secrets |
| App Registration | github-pim-activator — Application permission only |
| Graph Permission | RoleManagement.ReadWrite.Directory (requires admin consent) |
| Action | adminAssign — compatible with app-only auth |
| Trigger | Manual workflow_dispatch with UPN and justification inputs |