01Sign in from a script with the MSAL device code flowVerified

A script running where it cannot open a browser, such as a remote shell, calls initiate_device_flow in MSAL for Python and the user enters the displayed code in a browser on another device. If the cache already holds an account, try acquire_token_silent first.

Approach and steps
  1. Register an app in Microsoft Entra ID and put its client ID and tenant ID in the CLIENT_ID and TENANT_ID environment variables
  2. Create a PublicClientApplication; if get_accounts returns an account, call acquire_token_silent first
  3. Otherwise call initiate_device_flow. If the result has no user_code, show error_description and stop
  4. Print flow["message"] so the user can enter the code on another device
  5. Wait in acquire_token_by_device_flow. By default this call blocks the current thread while it polls
  6. If the result has no access_token, print the error and exit. Never print or log the token itself
PythonSign-in required (Microsoft Entra)
"""Sign in with the device code flow (MSAL for Python), reusing a cached account first."""
import os
import sys

import msal

SCOPES = ["User.Read"]


def get_token(app, scopes):
    accounts = app.get_accounts()
    if accounts:
        result = app.acquire_token_silent(scopes, account=accounts[0])
        if result and "access_token" in result:
            return result
    flow = app.initiate_device_flow(scopes=scopes)
    if "user_code" not in flow:
        raise RuntimeError("could not start device flow: %s" % flow.get("error_description"))
    print(flow["message"], file=sys.stderr)  # where to go and which code to enter
    return app.acquire_token_by_device_flow(flow)  # blocks until sign-in, expiry or cancel


def main():
    app = msal.PublicClientApplication(
        os.environ["CLIENT_ID"],
        authority="https://login.microsoftonline.com/" + os.environ["TENANT_ID"],
    )
    result = get_token(app, SCOPES)
    if "access_token" not in result:
        print("sign-in failed:", result.get("error"), result.get("error_description"))
        return 1
    print("signed in; token expires in", result.get("expires_in"), "seconds")  # never print the token
    return 0


if __name__ == "__main__":
    sys.exit(main())
Output(2026-09-12)
silent first, then device flow: ok
Example instruction for Copilot

Write get_token(app, scopes) using the device code flow in MSAL for Python. Try acquire_token_silent first when the cache has an account. Raise RuntimeError if initiate_device_flow returns no user_code. Read CLIENT_ID and TENANT_ID from environment variables and never print the token.

Caution

The code expires 15 minutes after the request by default (expires_in). Your organization's sign-in policy may not allow the device code flow, so check with your admins first. In this example the cache lives only in memory, so the next run asks for a code again (persisting it is a separate tip). Do not parse the token to make decisions.

Availability
Checked with msal 1.38.0 (Python 3.12 venv). Requires a Microsoft Entra ID work or school account.
Requires
msal 1.38.0
Permissions
User.Read (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msal 1.38.0)
Source
Microsoft Learn, "Microsoft identity platform and the OAuth 2.0 device authorization grant flow"
MSAL Python, "MSAL Python Documentation"
Verified
2026-09-12 (v1)
Supporting passages from the sources
When possible, we recommend you use the supported Microsoft Authentication Libraries (MSAL) instead to acquire tokens and call secured web APIs.
A successful response would contain “user_code” key, among others
By default, this method’s polling effect will block current thread.
From the moment the request is sent, the user has 15 minutes to sign in. This is the default value for expires_in.

02Persist the MSAL token cache encrypted and keep it out of the repositoryVerified

MSAL for Python does not write its cache to disk, so without extra work every run needs a new sign-in. PersistedTokenCache from msal-extensions stores the cache in a file encrypted with DPAPI on Windows, and it handles file locking and reloading for you.

Approach and steps
  1. Call build_encrypted_persistence(path) to get OS-appropriate encrypted storage (DPAPI on Windows, Keychain on macOS, LibSecret on Linux)
  2. Pass PersistedTokenCache(persistence) as token_cache to PublicClientApplication
  3. Keep the file outside the repository (for example under your user profile) and set it with the TOKEN_CACHE_PATH environment variable
  4. Also add the cache file name to .gitignore so it cannot be committed by accident
  5. Where encryption is unavailable, stop rather than fall back to plain text; the official sample falls back only when you ask it to
PythonSign-in required (Microsoft Entra)
"""Keep the MSAL token cache in an encrypted file outside the repository."""
import os
import sys

import msal
from msal_extensions import PersistedTokenCache, build_encrypted_persistence

SCOPES = ["User.Read"]
# Point this outside the repo (e.g. your user profile) and list the file name in .gitignore
CACHE_PATH = os.environ.get("TOKEN_CACHE_PATH", "token_cache.bin")


def build_app():
    persistence = build_encrypted_persistence(CACHE_PATH)  # raises if encryption is unavailable
    print("cache:", type(persistence).__name__, "encrypted:", persistence.is_encrypted)
    return msal.PublicClientApplication(
        os.environ["CLIENT_ID"],
        authority="https://login.microsoftonline.com/" + os.environ["TENANT_ID"],
        token_cache=PersistedTokenCache(persistence),  # file lock and reload are handled
    )


def main():
    app = build_app()
    accounts = app.get_accounts()
    result = app.acquire_token_silent(SCOPES, account=accounts[0]) if accounts else None
    if not result:
        flow = app.initiate_device_flow(scopes=SCOPES)
        if "user_code" not in flow:
            print("could not start sign-in:", flow.get("error_description"))
            return 1
        print(flow["message"], file=sys.stderr)
        result = app.acquire_token_by_device_flow(flow)
    print("token ready" if "access_token" in result else "failed: %s" % result.get("error"))
    return 0


if __name__ == "__main__":
    sys.exit(main())
Example instruction for Copilot

Add a token cache to an MSAL for Python PublicClientApplication using build_encrypted_persistence and PersistedTokenCache from msal-extensions. Read the path from the TOKEN_CACHE_PATH environment variable and stop with an exception if encryption is unavailable. Do not add a plain-text fallback.

Caution

The cache holds access tokens and related data, so never place it in a shared folder or a repository. msal-extensions is meant for public client apps such as desktop tools, not web apps. Do not reuse one machine's cache on another. Plan how the file is removed when someone leaves or a device is replaced.

Availability
Checked with msal 1.38.0 and msal-extensions 1.3.1 (on Windows 11 the persistence is FilePersistenceWithDataProtection with is_encrypted True).
Requires
msal 1.38.0, msal-extensions 1.3.1
Permissions
User.Read (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365) (2026-09-12, Python 3.12.10 (venv) / Windows 11, msal 1.38.0, msal-extensions 1.3.1)
Source
GitHub (AzureAD), "Microsoft Authentication Extensions for Python"
MSAL Python, "MSAL Python Documentation"
Microsoft Learn, "Custom token cache serialization in MSAL for Python"
Verified
2026-09-12 (v1)
Supporting passages from the sources
The token cache includes a file lock, and auto-reload behavior under the hood.
This class does NOT actually persist the cache on disk/db/etc.
Windows - DPAPI is used for encryption.
It is recommended to use this library for cache persistance support for Public client applications such as Desktop apps only.

03Authenticate unattended jobs with a certificate instead of a client secretVerified

For jobs with no signed-in user, pass the path of a PFX certificate to ConfidentialClientApplication and get a token with acquire_token_for_client. With a PFX file MSAL uses the SHA-256 thumbprint. The example reads license counts and needs only LicenseAssignment.Read.All.

Approach and steps
  1. Get a certificate and upload its public part under Certificates & secrets > Certificates in the app registration
  2. Keep the PFX (with the private key) in a locked-down folder on the server; pass its path in CERT_PATH and its passphrase in CERT_PASSPHRASE
  3. Pass client_credential={"private_key_pfx_path": ..., "passphrase": ...} (the form added in msal 1.29.0)
  4. Request app-only tokens with https://graph.microsoft.com/.default; asking for individual permission names is not supported
  5. Pick the least privileged permission from the API page's Permissions table; listing subscribedSkus needs the LicenseAssignment.Read.All application permission
  6. Fetch only the needed properties with $select, and give requests a timeout
PythonSign-in required (Microsoft Entra)
"""App-only token with a certificate (no client secret), then read license counts."""
import os
import sys

import msal
import requests

GRAPH = "https://graph.microsoft.com/v1.0"
SCOPES = ["https://graph.microsoft.com/.default"]  # app-only tokens always use /.default


def build_app():
    credential = {"private_key_pfx_path": os.environ["CERT_PATH"]}
    if os.environ.get("CERT_PASSPHRASE"):
        credential["passphrase"] = os.environ["CERT_PASSPHRASE"]
    return msal.ConfidentialClientApplication(
        os.environ["CLIENT_ID"],
        authority="https://login.microsoftonline.com/" + os.environ["TENANT_ID"],
        client_credential=credential,
    )


def seat_rows(payload):
    rows = []
    for sku in payload.get("value", []):
        enabled = (sku.get("prepaidUnits") or {}).get("enabled", 0)
        rows.append((sku.get("skuPartNumber"), sku.get("consumedUnits", 0), enabled))
    return rows


def main():
    result = build_app().acquire_token_for_client(scopes=SCOPES)
    if "access_token" not in result:
        print("token error:", result.get("error"), result.get("error_description"))
        return 1
    resp = requests.get(
        GRAPH + "/subscribedSkus",
        params={"$select": "skuPartNumber,consumedUnits,prepaidUnits"},
        headers={"Authorization": "Bearer " + result["access_token"]},
        timeout=30,
    )
    resp.raise_for_status()
    for name, used, enabled in seat_rows(resp.json()):
        print(f"{name}: {used} of {enabled} assigned")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Output(2026-09-12)
seat_rows: [('SKU_A', 14, 25), ('SKU_B', 0, 0)]
Example instruction for Copilot

Write a script that gets an app-only token with a PFX certificate via MSAL for Python ConfidentialClientApplication and reads only skuPartNumber, consumedUnits and prepaidUnits from Graph /subscribedSkus using $select. Do not use a client secret. Read IDs and paths from environment variables and put the counting in a seat_rows(payload) function.

Caution

Application permissions are not limited by any signed-in user's access and reach tenant-wide data; granting them needs admin consent. Never commit the PFX or its passphrase. Track certificate expiry and replace it before it lapses. The PEM plus SHA-1 thumbprint form is documented as deprecated.

Availability
Checked with msal 1.38.0 and requests 2.34.2. Microsoft Graph v1.0 subscribedSkus (documentation checked 2026-09-12).
Requires
msal 1.38.0, requests 2.34.2
Permissions
LicenseAssignment.Read.All (application)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msal 1.38.0, requests 2.34.2)
Practical edition
Operating AI Agents ── Separate what Entra Agent ID and Agent 365 cover
Source
MSAL Python, "MSAL Python Documentation"
Microsoft Learn, "Microsoft identity platform application authentication certificate credentials"
Microsoft Learn, "List subscribedSkus"
Microsoft Learn, "Scopes and permissions in the Microsoft identity platform"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Client credentials requests in your client service must include scope={resource}/.default.
This usage will automatically use SHA-256 thumbprint of the certificate.
The Microsoft identity platform allows an application to use its own credentials for authentication anywhere a client secret could be used
This method supports only the $select OData query parameter to help customize the response.

04Use a managed identity for code running in Azure so it holds no credentialsVerified

Code running on Azure compute such as VMs or app hosting can get tokens with ManagedIdentityCredential, so no secret or certificate is stored anywhere. Pick a user-assigned managed identity by its client_id. The example reads license counts with msgraph-sdk.

Approach and steps
  1. Turn on a system-assigned managed identity on the resource that runs the code, or create a user-assigned identity and attach it
  2. Use ManagedIdentityCredential in code; for a user-assigned identity pass the AZURE_CLIENT_ID environment variable as client_id
  3. Give GraphServiceClient scopes=["https://graph.microsoft.com/.default"]
  4. Grant the identity only the least privileged Graph permission for the API you call (LicenseAssignment.Read.All here); ask an admin to grant it
  5. Your own PC has no managed identity: test locally with a developer sign-in, and pin ManagedIdentityCredential in Azure
PythonSign-in required (Microsoft Entra)
"""Read license counts from code running in Azure, using its managed identity."""
import asyncio
import os

from azure.identity.aio import ManagedIdentityCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.subscribed_skus.subscribed_skus_request_builder import (
    SubscribedSkusRequestBuilder,
)

SCOPES = ["https://graph.microsoft.com/.default"]


def seat_summary(skus):
    rows = []
    for s in skus:
        enabled = s.prepaid_units.enabled if s.prepaid_units else 0
        rows.append((s.sku_part_number, s.consumed_units or 0, enabled or 0))
    return rows


async def main():
    # user-assigned identity: set AZURE_CLIENT_ID; system-assigned: leave it unset
    async with ManagedIdentityCredential(client_id=os.environ.get("AZURE_CLIENT_ID")) as cred:
        client = GraphServiceClient(credentials=cred, scopes=SCOPES)
        query = SubscribedSkusRequestBuilder.SubscribedSkusRequestBuilderGetQueryParameters(
            select=["skuPartNumber", "consumedUnits", "prepaidUnits"])
        page = await client.subscribed_skus.get(
            request_configuration=RequestConfiguration(query_parameters=query))
        for name, used, enabled in seat_summary(page.value or []):
            print(f"{name}: {used} of {enabled} assigned")


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
seat_summary: [('SKU_A', 14, 25), ('SKU_B', 0, 0)]
Example instruction for Copilot

Write code that reads /subscribedSkus from Azure using the async ManagedIdentityCredential from azure-identity and msgraph-sdk. Use the user-assigned identity named by AZURE_CLIENT_ID, or the system-assigned one when it is unset. Select only skuPartNumber, consumedUnits and prepaidUnits, and put the counting in seat_summary(skus).

Caution

A managed identity works only inside Azure. If no identity is attached, or the client_id is wrong, no token is returned. Every piece of code on that resource can use the identity's permissions, so separate resources by purpose. Do not share a managed identity token cache across machines.

Availability
Checked with azure-identity 1.25.3 and msgraph-sdk 1.62.0 (locally up to the sign-in step; not run on Azure).
Requires
azure-identity 1.25.3, msgraph-sdk 1.62.0
Permissions
LicenseAssignment.Read.All (application)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, azure-identity 1.25.3, msgraph-sdk 1.62.0)
Source
Microsoft Learn, "Managed identities for Azure resources"
Microsoft Learn, "Azure Identity client library for Python"
Microsoft Learn, "Using Managed Identity (MSAL for Python)"
Microsoft Learn, "List subscribedSkus"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Managed identities eliminate the need for developers to manage these credentials.
Managed identity authentication is supported either indirectly via DefaultAzureCredential or directly via ManagedIdentityCredential
this SDK provides a better developer experience by allowing the app to run on private developer machines where managed identity doesn't exist.
If an incorrect identifier is used for the user-assigned managed identity, no token will be returned as well.

05Read external API keys from Key Vault at run time, not from code or config filesVerified

Keep API keys for services that cannot use Entra ID in Key Vault and read them at run time with SecretClient.get_secret from azure-keyvault-secrets. DefaultAzureCredential uses your developer sign-in locally and a managed identity in Azure, so the same code runs in both.

Approach and steps
  1. Manage the vault with RBAC and give a read-only script's identity Key Vault Secrets User (not Secrets Officer, which can also write)
  2. Pass the vault URL in KEY_VAULT_URL and the secret name in SECRET_NAME
  3. Call credential.get_token("https://vault.azure.net/.default") first so sign-in failures show up before any vault request
  4. Use get_secret(name).value. Never print or log the value; record only the name and version (properties.version)
  5. Handle ClientAuthenticationError and ResourceNotFoundError separately, with messages that never include the value
PythonSign-in required (Microsoft Entra)
"""Read an API key from Azure Key Vault at run time; never print or log the value."""
import os
import sys

from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

VAULT_URL = os.environ["KEY_VAULT_URL"]
SECRET_NAME = os.environ.get("SECRET_NAME", "partner-api-key")
KV_SCOPE = "https://vault.azure.net/.default"


def load_secret(credential, name, make_client=SecretClient):
    credential.get_token(KV_SCOPE)  # fail fast on sign-in problems, before calling the vault
    secret = make_client(vault_url=VAULT_URL, credential=credential).get_secret(name)
    return secret.value, secret.properties.version


def main():
    credential = DefaultAzureCredential()  # developer sign-in locally, managed identity in Azure
    try:
        value, version = load_secret(credential, SECRET_NAME)
    except ClientAuthenticationError as e:
        print("sign-in failed:", type(e).__name__)
        return 1
    except ResourceNotFoundError:
        print("secret not found:", SECRET_NAME)
        return 1
    print("loaded", SECRET_NAME, "version", version)  # the value itself is never shown
    # use `value` here, e.g. as a request header for the external service
    return 0


if __name__ == "__main__":
    sys.exit(main())
Output(2026-09-12)
load_secret ok; version v1
Example instruction for Copilot

Write load_secret(credential, name) that reads an external service's API key with DefaultAzureCredential and SecretClient from azure-keyvault-secrets. Read the vault URL from KEY_VAULT_URL. Never print or log the value; show only the name and version. Let the SecretClient be swapped out through an argument for testing.

Caution

Moving a key into Key Vault does not help if the value is printed or ends up in logs or error messages. The official quickstart prints values because it demonstrates create-to-delete; business code should not. Microsoft recommends one vault per application per environment. Agree a rotation schedule with the external service owner.

Availability
Checked with azure-identity 1.25.3 and azure-keyvault-secrets 4.11.2 (locally up to the sign-in step). Assumes the vault uses the Azure RBAC permission model.
Requires
azure-identity 1.25.3, azure-keyvault-secrets 4.11.2
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, azure-identity 1.25.3, azure-keyvault-secrets 4.11.2)
Source
Microsoft Learn, "Quickstart: Azure Key Vault secret client library for Python"
Microsoft Learn, "Grant permission to applications to access an Azure key vault using Azure RBAC"
Microsoft Learn, "Scopes and permissions in the Microsoft identity platform"
Verified
2026-09-12 (v1)
Supporting passages from the sources
By using Key Vault to store secrets, you avoid storing secrets in your code, which increases the security of your app.
When the application is deployed to Azure, the same DefaultAzureCredential code can automatically discover and use a managed identity
Read secret contents including secret portion of a certificate with private key.
Our recommendation is to use a vault per application per environment (Development, Pre-Production, and Production) with roles assigned at the key vault scope.
Azure Key Vault: https://vault.azure.net
The scope parameter value is constructed by using the identifier URI for the resource and .default, separated by a forward slash (/).

06Keep a per-script ledger of Graph permissions and flag excess ones automaticallyVerified

Record the permissions each script requests in a ledger, then check in Python whether read-only scripts carry ReadWrite permissions, whether broad Directory permissions are used, and whether one app mixes delegated and application permissions.

Approach and steps
  1. For each script, record the operation (read or write), the permission type (delegated or application) and the permissions it requests
  2. Choose each permission from the Least privileged permissions column of the API page's Permissions table
  3. Flag write permissions such as ReadWrite or Send on read-only scripts
  4. Flag Directory.AccessAsUser.All, Directory.ReadWrite.All and Directory.Read.All as broad, and look for narrower ones
  5. Flag apps that mix delegated and application permissions; interactive work should use delegated permissions
  6. Attach the findings when you ask an admin for consent
PythonRuns locally
"""Flag over-broad Microsoft Graph permissions in a per-script permission ledger."""
import json
import os
import re

LEDGER = [
    {"script": "mail_tally.py", "app": "reports", "operation": "read", "type": "delegated",
     "scopes": ["Mail.Read"]},
    {"script": "license_report.py", "app": "reports", "operation": "read", "type": "application",
     "scopes": ["LicenseAssignment.Read.All"]},
    {"script": "list_audit.py", "app": "audit", "operation": "read", "type": "delegated",
     "scopes": ["Sites.ReadWrite.All"]},
    {"script": "dir_dump.py", "app": "audit", "operation": "read", "type": "delegated",
     "scopes": ["Directory.Read.All"]},
]
WRITE = re.compile(r"ReadWrite|\.Send$|\.Write|Manage|FullControl")
BROAD = {"Directory.AccessAsUser.All", "Directory.ReadWrite.All", "Directory.Read.All"}


def review(rows):
    findings = []
    for r in rows:
        for s in r["scopes"]:
            if r["operation"] == "read" and WRITE.search(s):
                findings.append((r["script"], s, "write permission on a read-only script"))
            if s in BROAD:
                findings.append((r["script"], s, "broad directory permission; look for a narrower one"))
    kinds = {}
    for r in rows:
        kinds.setdefault(r["app"], set()).add(r["type"])
    for app, k in sorted(kinds.items()):
        if len(k) > 1:
            findings.append((app, "+".join(sorted(k)), "one app mixes delegated and application permissions"))
    return findings


def main():
    path = os.environ.get("PERM_LEDGER")  # optional JSON file with the same shape as LEDGER
    rows = LEDGER
    if path:
        with open(path, encoding="utf-8") as f:
            rows = json.load(f)
    found = review(rows)
    for f in found:
        print(" | ".join(f))
    print("findings:", len(found))


if __name__ == "__main__":
    main()
Output(2026-09-12)
list_audit.py | Sites.ReadWrite.All | write permission on a read-only script
dir_dump.py | Directory.Read.All | broad directory permission; look for a narrower one
reports | application+delegated | one app mixes delegated and application permissions
findings: 3
Example instruction for Copilot

Write review(rows) that takes a permission ledger (a list of dicts with script, app, operation, type and scopes) and returns findings as (subject, permission, reason) tuples. Three rules: write permissions on read-only scripts, broad Directory permissions, and delegated and application types mixed within one app.

Caution

This check only matches names; confirm that permissions are actually sufficient against the API pages and a test tenant. Application permissions are not bounded by any signed-in user. Delegated permissions still reach everything the user can see, so also review sharing (see the linked practical tip).

Availability
Standard library only (Python 3.12). Permission names and least privileged choices checked on Microsoft Learn on 2026-09-12.
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11)
Practical edition
Grounding Copilot in Your Stored Content ── Copilot surfaces only data you can at least view
Source
Microsoft Learn, "Overview of Microsoft Graph permissions"
Microsoft Learn, "Best practices for working with Microsoft Graph"
Verified
2026-09-12 (v1)
Supporting passages from the sources
From a least-privilege perspective, the delegated permission model is the recommended approach whenever it meets the application's requirements.
Avoid using both application and delegated permissions in the same app.
Directory.Read.All is the highest privileged read-only permission for Microsoft Entra ID resources.
Choose the permission or permissions marked as least privileged for this API.

07On HTTP 429, wait for the Retry-After seconds and then retryVerified

When a request exceeds a limit, Graph returns 429 Too Many Requests with a Retry-After header giving the wait in seconds. Scripts that call Graph directly with requests should wait that long and retry, doubling the wait when the header is missing. msgraph-sdk already includes retry handling.

Approach and steps
  1. Detect throttling by status code 429. Do not retry immediately; failed requests still count against your usage
  2. Wait the Retry-After seconds, then resend the same request; if it returns 429 again, follow Retry-After again
  3. When the header is missing, double the wait each time. Set a maximum number of attempts and stop and log when it is reached
  4. Back off the same way on 503
  5. Requests inside a JSON batch are throttled one by one while the batch itself returns 200; SDKs do not retry those automatically
  6. Pass the sleep function in as an argument so the helper can be tested with fake responses without waiting
PythonRuns locally
"""Retry on 429/503, honoring Retry-After; checked here with fake responses (no network)."""
import time

RETRY_STATUS = {429, 503}


def call_with_retry(send, max_attempts=5, base_delay=1.0, sleep=time.sleep):
    """send() returns an object with .status_code and .headers, e.g. a requests.Response."""
    for attempt in range(1, max_attempts + 1):
        resp = send()
        if resp.status_code not in RETRY_STATUS or attempt == max_attempts:
            return resp
        header = resp.headers.get("Retry-After", "")
        wait = float(header) if header.isdigit() else base_delay * 2 ** (attempt - 1)
        print(f"attempt {attempt}: HTTP {resp.status_code}, waiting {wait:g}s")
        sleep(wait)
    return resp


class FakeResponse:
    def __init__(self, status_code, headers=None):
        self.status_code, self.headers = status_code, headers or {}


def demo():
    queue = [FakeResponse(429, {"Retry-After": "10"}), FakeResponse(503), FakeResponse(200)]
    waits = []
    resp = call_with_retry(lambda: queue.pop(0), sleep=waits.append)
    assert resp.status_code == 200 and waits == [10.0, 2.0], waits

    stuck = [FakeResponse(429) for _ in range(3)]
    waits2 = []
    resp2 = call_with_retry(lambda: stuck.pop(0), max_attempts=3, sleep=waits2.append)
    assert resp2.status_code == 429 and waits2 == [1.0, 2.0], waits2
    print("retried:", len(waits), "then 200;", len(waits2), "then gave up")
    # real use: call_with_retry(lambda: session.get(url, headers=headers, timeout=30))


if __name__ == "__main__":
    demo()
Output(2026-09-12)
attempt 1: HTTP 429, waiting 10s
attempt 2: HTTP 503, waiting 2s
attempt 1: HTTP 429, waiting 1s
attempt 2: HTTP 429, waiting 2s
retried: 2 then 200; 2 then gave up
Example instruction for Copilot

Write call_with_retry(send, max_attempts, base_delay, sleep) that calls send(), and on 429 or 503 waits the Retry-After seconds and retries. Without the header, double base_delay each time. Make sleep replaceable and add asserts that run it against a queue of fake responses.

Caution

This helper reads Retry-After only when it is a number of seconds. Retrying write requests can apply them twice, so decide which requests are safe to retry. For regular bulk extraction, the Graph documentation points to Microsoft Graph Data Connect. Use delta queries or change notifications instead of repeatedly listing whole collections.

Availability
Standard library only (Python 3.12), checked locally with fake responses. With real Graph calls you can return a requests.Response from send().
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11)
Source
Microsoft Learn, "Microsoft Graph throttling guidance"
Microsoft Learn, "Best practices for working with Microsoft Graph"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Wait the number of seconds specified in the Retry-After header.
Microsoft Graph SDKs already implement handlers that rely on the Retry-After header or default to an exponential backoff retry policy.
If SDKs retry throttled requests automatically when they aren't batched, throttled requests that were part of a batch aren't retried automatically.
You should employ a back-off strategy similar to 429.

08Log who ran a script, what it did and when, without tokens or personal dataVerified

For each run, write the run ID, the operator's internal ID, the action, counts and the client-request-id sent to Graph as JSON Lines. A logging Filter masks e-mail addresses and bearer tokens, and $select limits what Graph returns to the fields the tally needs.

Approach and steps
  1. Give each run a uuid run ID. For every Graph request, generate another uuid, send it in the client-request-id header and log the same value
  2. Record the operator by internal ID (the RUN_BY environment variable), never by name or e-mail address
  3. Fetch only what the tally needs with $select; this example keeps just the number of sender domains and message counts
  4. Subclass logging.Filter to replace e-mail addresses and bearer tokens in messages, and attach it to the handler with addFilter
  5. Leave MSAL's enable_pii_log at its default of False. azure-identity logs URLs and headers at INFO level, so check where that output goes
  6. Decide how long logs are kept and where, so they can be matched against Microsoft Purview audit logs
PythonRuns locally
"""JSON Lines audit log for a script run, masking e-mail addresses and bearer tokens."""
import json
import logging
import os
import re
import uuid
from collections import Counter
from datetime import datetime, timezone

EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
BEARER = re.compile(r"Bearer\s+[\w.~+/=-]+")
SELECT = ["from", "receivedDateTime"]  # ask Graph only for what the tally needs


class RedactFilter(logging.Filter):
    def filter(self, record):
        msg = EMAIL.sub("[email]", record.getMessage())
        record.msg, record.args = BEARER.sub("Bearer [redacted]", msg), None
        return True


def audit_logger(path):
    handler = logging.FileHandler(path, encoding="utf-8")
    handler.addFilter(RedactFilter())
    log = logging.getLogger("audit")
    log.setLevel(logging.INFO)
    log.addHandler(handler)
    return log


def event(log, run_id, action, **fields):
    rec = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "run_id": run_id,
           "run_by": os.environ.get("RUN_BY", "unknown"), "action": action, **fields}
    log.info(json.dumps(rec, ensure_ascii=False))


def main():
    run_id = str(uuid.uuid4())
    log = audit_logger("out_audit.jsonl")
    with open("samples/messages.json", encoding="utf-8") as f:
        rows = json.load(f)["value"]  # stands in for GET /me/messages?$select=from,receivedDateTime
    event(log, run_id, "graph.get", resource="/me/messages", select=",".join(SELECT),
          client_request_id=str(uuid.uuid4()), count=len(rows))
    domains = Counter(r["from"]["emailAddress"]["address"].split("@")[-1] for r in rows)
    event(log, run_id, "tally", sender_domains=len(domains), messages=sum(domains.values()))
    # demo of a careless line: the filter masks the address and the token before they reach the file
    sender = rows[0]["from"]["emailAddress"]["address"]
    event(log, run_id, "debug", note="sender %s auth Bearer abc.def.ghi" % sender)
    logging.shutdown()
    with open("out_audit.jsonl", encoding="utf-8") as f:
        text = f.read()
    assert "@" not in text and "abc.def" not in text
    print("audit lines:", len(text.splitlines()), "| redacted: ok")
    print(text.splitlines()[-1])


if __name__ == "__main__":
    main()
Output(2026-09-12)
audit lines: 3 | redacted: ok
{"ts": "2026-09-12T03:49:23+00:00", "run_id": "448cf962-193d-4a16-a2ed-440adffb5bfe", "run_by": "unknown", "action": "debug", "note": "sender [email] auth Bearer [redacted]"}
Example instruction for Copilot

Write audit_logger(path) and event(log, run_id, action, **fields) that write a script's run log as JSON Lines, with UTC time, run ID, the operator ID from RUN_BY and the action. Use a logging.Filter to mask e-mail addresses and tokens starting with Bearer, and assert that no @ remains in the log.

Caution

Masking patterns cannot catch personal data in every form; first design the code so personal data never reaches the logger. The Graph documentation recommends logging full requests and responses for support cases; if you keep them, drop the Authorization header and personal data in bodies. Whether operator IDs count as personal data, and how long to keep logs, follow your internal rules.

Availability
Standard library only (Python 3.12), checked with the sample messages.json. The response shape follows Microsoft Graph v1.0 messages.
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11)
Practical edition
Operating AI Agents ── Track agent use in Purview audit logs
Source
Microsoft Learn, "Best practices for working with Microsoft Graph"
Python documentation, "Logging HOWTO"
Microsoft Learn, "Azure Identity client library for Python"
MSAL Python, "MSAL Python Documentation"
Verified
2026-09-12 (v1)
Supporting passages from the sources
On every request to Microsoft Graph, generate a unique GUID, send it in the client-request-id HTTP request header, and also log it in your application's logs.
Use the $select query parameter to limit the properties returned by a query to those needed by your application.
instances of Filter can be added to both Logger and Handler instances (through their addFilter() method).
Credentials log basic information, including HTTP sessions (URLs, headers, etc.) at INFO level. These log entries don't contain authentication secrets.
When enabled, logs may include PII (Personal Identifiable Information).