Skip to content

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 permissionsUser.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_assertion parameter 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 registrationPermissions (risk tier)Current authTarget authPriority
secops-ingest-prodSecurityEvents.Read.All, AuditLog.Read.All (HIGH)client secretcertificate1
intune-audit-prodDeviceManagementManagedDevices.Read.All, DeviceManagementConfiguration.Read.All (HIGH)client secretcertificate1
purview-audit-prodInformationProtectionPolicy.Read.All, RecordsManagement.Read.All, AuditLog.Read.All (HIGH)client secretcertificate1
entra-pim-audit-prodRoleManagement.Read.Directory, PrivilegedAccess.Read.AzureAD (HIGH)client secretcertificate1
maester-m365-audit-prodPolicy.Read.All, Directory.Read.All, Application.Read.All (HIGH)client secretcertificate1
ca-policy-report-prodPolicy.Read.All (MEDIUM)client secretcertificate2
github-deploy-main (PROD)n/a — OIDC federated credentialOIDC — already migratedn/a
github-deploy-main (DDE)n/a — OIDC federated credentialOIDC — already migratedn/a
kobe-sp (EC2 read-only SP)n/a — Azure Reader onlyclient secretcertificate3
Teams bot (App ID 7a3ea16d)User-delegated onlyclient secretcertificate3

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.json

Apps 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:

  1. Pull the new public cert version from KV.
  2. Run az ad app credential reset --append to attach the new version.
  3. Wait 15 minutes for token caches to drain.
  4. Remove the old cert credential by keyId.
  5. Post a CM ticket to SecOps recording the rotation event.

Rotation is zero-touch after initial setup.


Recommendation

Migrate in three waves:

  1. 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.
  2. Wave 2 (Priority 2 — MEDIUM): ca-policy-report-prod. Target: 60 days.
  3. 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.


  • security/secrets-managed-identity-eval.md — why managed identity does not apply to every integration
  • security/unavoidable-long-lived-secrets.md — inventory of what remains after this migration
  • runbooks/ — operational procedures updated as migrations complete

Internal use only — Cirius Group