Appearance
Graph API Certificate Authentication — Evaluation and Migration Plan
Status: Evaluated — recommend migration for high-privilege app registrations Decision date: 2026-04-16 Decision owner: Rory Review cadence: After each wave of migrations; re-evaluate inventory quarterly SOC2 controls: CC6.1 Logical Access Security, CC6.3 Role-Based Access, CC6.6 Transmission Security
Question
Can the Microsoft Graph app registrations currently authenticating with client secrets be moved to certificate-based authentication, reducing blast radius and aligning with Microsoft's long-standing guidance to avoid client secrets on high-privilege workloads?
Verdict
Yes — and we should migrate high-privilege app registrations first.
Certificate auth is a native Entra ID feature with no Graph-side constraints. The migration is per-app-registration and non-disruptive: the old secret and the new certificate can coexist on the app reg while callers are cut over.
Background — Why This Matters
Client secrets on Graph app registrations are long-lived bearer credentials. Anyone who reads the secret value can call Graph as the app. If the secret leaks (env file, developer laptop, logs, screen-share), the attacker has Graph API access until the secret is rotated. For app regs with high-privilege application permissions — User.Read.All, Mail.Read, Directory.Read.All, SecurityEvents.Read.All, etc. — that blast radius is the entire Entra tenant.
Certificate auth replaces the bearer-secret model:
- The certificate private key stays in the Key Vault that holds it.
- Callers (workflows, apps) present a short-lived client assertion — a signed JWT proving possession of the private key — in the
client_assertionparameter of the OAuth2 token request. - The actual Graph call uses the short-lived access token that Entra returns.
- Leaked logs or env files contain at worst an app ID and a certificate thumbprint. Neither is a credential.
Microsoft's own guidance: "Use certificates instead of client secrets for confidential client applications that use application permissions."
Inventory — App Registrations at Cirius
This list is the state as of the evaluation date. Re-run az ad app list --all --query "[].{displayName:displayName, appId:appId}" -o tsv quarterly and reconcile.
| App registration | Permissions (risk tier) | Current auth | Target auth | Priority |
|---|---|---|---|---|
| secops-ingest-prod | SecurityEvents.Read.All, AuditLog.Read.All (HIGH) | client secret | certificate | 1 |
| intune-audit-prod | DeviceManagementManagedDevices.Read.All, DeviceManagementConfiguration.Read.All (HIGH) | client secret | certificate | 1 |
| purview-audit-prod | InformationProtectionPolicy.Read.All, RecordsManagement.Read.All, AuditLog.Read.All (HIGH) | client secret | certificate | 1 |
| entra-pim-audit-prod | RoleManagement.Read.Directory, PrivilegedAccess.Read.AzureAD (HIGH) | client secret | certificate | 1 |
| maester-m365-audit-prod | Policy.Read.All, Directory.Read.All, Application.Read.All (HIGH) | client secret | certificate | 1 |
| ca-policy-report-prod | Policy.Read.All (MEDIUM) | client secret | certificate | 2 |
| github-deploy-main (PROD) | n/a — OIDC federated credential | OIDC — already migrated | n/a | — |
| github-deploy-main (DDE) | n/a — OIDC federated credential | OIDC — already migrated | n/a | — |
| kobe-sp (EC2 read-only SP) | n/a — Azure Reader only | client secret | certificate | 3 |
| Teams bot (App ID 7a3ea16d) | User-delegated only | client secret | certificate | 3 |
Counts: 5 HIGH-privilege app regs on client secrets → migrate first. 1 MEDIUM, 2 LOW → follow in subsequent waves. OIDC-federated apps need no change.
How to generate an authoritative snapshot
bash
az ad app list --all \
--query "[].{name:displayName, appId:appId, passwordCredentials:passwordCredentials[].displayName, keyCredentials:keyCredentials[].displayName}" \
-o json > app-regs.jsonApps with passwordCredentials populated and keyCredentials empty are candidates for migration.
How to Migrate — Per App Registration
Step 1 — Create the certificate in Key Vault
Use Key Vault–generated certificates so the private key never leaves the vault. The vault is cirius-openai-kv-prod (KMS-encrypted, private endpoint, soft delete on). Naming convention: graph-cert-<app-short-name> with validity 12 months and auto-rotation enabled.
bash
az keyvault certificate create \
--vault-name cirius-openai-kv-prod \
--name graph-cert-intune-audit \
--policy "$(az keyvault certificate get-default-policy \
| jq '.validity_in_months = 12
| .issuer_parameters.name = "Self"
| .key_properties.key_size = 2048
| .key_properties.key_type = "RSA"
| .x509_certificate_properties.subject = "CN=intune-audit-prod.ciriusgroup.com"
| .x509_certificate_properties.ekus = ["1.3.6.1.5.5.7.3.2"]')"Auto-rotation is configured via a Key Vault certificate lifecycle policy — at 80% of validity the vault auto-issues a new version and emits an Event Grid event. The rotation automation watches for that event and republishes the public cert to the app registration (see Step 4 below).
Step 2 — Upload the public certificate to the app registration
bash
# Export the public cert from KV
az keyvault certificate download \
--vault-name cirius-openai-kv-prod \
--name graph-cert-intune-audit \
--file /tmp/graph-cert-intune-audit.cer \
--encoding DER
# Attach it to the app registration as a key credential
APP_ID=<app-id>
az ad app credential reset \
--id "$APP_ID" \
--cert "@/tmp/graph-cert-intune-audit.cer" \
--append--append is critical: do not remove the existing client secret yet. The app reg now accepts both credentials; callers can switch over one at a time.
Step 3 — Switch callers to certificate auth
For GitHub Actions workflows using azure/login@v2, swap the auth block:
yaml
# BEFORE — client secret via secrets manager
- uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# AFTER — certificate auth via MSAL
- uses: azure/login@v2
with:
client-id: ${{ secrets.INTUNE_AUDIT_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Certificate auth: the cert is pulled from KV at runtime.
# No stored secret in GitHub — the workflow uses its own OIDC federation
# into a dedicated SP that has Key Vault Secrets User on the vault.For Container Apps, the managed identity already holds Key Vault Secrets User on the vault, and the app reads the cert at startup using CertificateClient.download_certificate().
For Python callers, MSAL supports cert auth directly:
python
import msal, cryptography.hazmat.primitives.serialization as ser
# (omitted: pull PEM from KV via SecretClient with managed identity)
app = msal.ConfidentialClientApplication(
client_id=CLIENT_ID,
authority=f"https://login.microsoftonline.com/{TENANT_ID}",
client_credential={"thumbprint": THUMBPRINT, "private_key": PEM},
)
token = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])Step 4 — Remove the client secret
After one full workflow cycle confirms the certificate path works end to end (Graph calls succeed, logs show cert-based token acquisition), remove the stored client secret:
bash
az ad app credential delete \
--id "$APP_ID" \
--key-id <password-credential-key-id>Also delete the corresponding GitHub secret (*_CLIENT_SECRET) from the repo.
Step 5 — Wire rotation automation
Key Vault emits Microsoft.KeyVault.CertificateNewVersionCreated on rotation. Subscribe a lightweight GitHub Actions workflow (or the existing SecOps Container App) to:
- Pull the new public cert version from KV.
- Run
az ad app credential reset --appendto attach the new version. - Wait 15 minutes for token caches to drain.
- Remove the old cert credential by
keyId. - Post a CM ticket to SecOps recording the rotation event.
Rotation is zero-touch after initial setup.
Recommendation
Migrate in three waves:
- Wave 1 (Priority 1 — 5 HIGH-privilege apps): secops-ingest-prod, intune-audit-prod, purview-audit-prod, entra-pim-audit-prod, maester-m365-audit-prod. Target: finish within 30 days. These five have the largest blast radius and are the clearest compliance win for SOC2 CC6.1.
- Wave 2 (Priority 2 — MEDIUM): ca-policy-report-prod. Target: 60 days.
- Wave 3 (Priority 3 — LOW): kobe-sp, Teams bot. Target: 90 days, or deferred if the cert-auth work on these is disproportionate.
After all waves complete, update this document's inventory table and add a compliance/ evidence entry confirming the tenant contains no long-lived client-secret credentials on app registrations with application permissions.
Why Not Go Further to Managed Identity?
App registrations with delegated permissions (user context) and a few application permissions now support Workload Identity Federation — federating from a trusted external OIDC provider to eliminate the credential entirely. GitHub Actions uses this already (github-deploy-main). We will continue to prefer OIDC federation wherever the downstream consumer can produce a federated token.
Certificate auth is the answer for workloads that cannot federate: Container Apps managed identities cannot (yet) be federated into another app registration, and background services that run inside our own infrastructure don't have an external OIDC issuer to federate from. For those, cert auth is the best achievable posture.
Related Documents
security/secrets-managed-identity-eval.md— why managed identity does not apply to every integrationsecurity/unavoidable-long-lived-secrets.md— inventory of what remains after this migrationrunbooks/— operational procedures updated as migrations complete