When your Microsoft Graph 403 is coming from a WAF, not from Graph
Microsoft Graph returns 403s that aren’t from Microsoft Graph. The Application Gateway WAF blocks the default python-requests/2.x User-Agent on Information Protection endpoints — the request never reaches Graph at all. First I granted the service principal Compliance Administrator to try to unblock the API. Then I tried publishing a sensitivity label to improve our compliance score — that’s when Purview told me Azure Rights Management wasn’t even active for the tenant, so I activated it via Enable-AipService and published the label. Even after Rights Management activation, still 403. Then I ran the same OAuth token through curl and got 200 instantly.
Microsoft’s 403s come in three shapes.
Application Gateway WAF reject — static HTML, no request-id, contains Microsoft-Azure-Application-Gateway:
<html><head><title>403 Forbidden</title></head>
<body><center><h1>403 Forbidden</h1></center>
<hr><center>Microsoft-Azure-Application-Gateway/v...
Conditional Access / token-policy reject — JSON with a claim_challenge header naming what the token lacks.
Graph service / authorization reject — JSON with structured error.code (Authorization_RequestDenied and friends), a request-id, and x-ms-ags-diagnostic naming the backend role instance.
The first means your request never reached Graph. The second means it reached the auth pipeline but failed policy. The third means Graph evaluated and rejected. Each is fixed differently. Conflating them sends you four layers deep when the answer was one layer wide.
To isolate User-Agent as the variable, run the same OAuth flow twice with two curl calls:
TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" | jq -r .access_token)
curl -s -w "%{http_code}\n" "$ENDPOINT" -H "Authorization: Bearer $TOKEN"
curl -s -w "%{http_code}\n" "$ENDPOINT" -H "Authorization: Bearer $TOKEN" -A "python-requests/2.32"
First returns 200, second returns 403 with the Application Gateway HTML signature. User-Agent is what changed.
The fix is one line in your HTTP client’s session-builder:
session.headers["User-Agent"] = "your-project-name/0.0.1"
The WAF triggers OWASP ModSecurity Core Rule Set rule 913101 (SCANNER-DETECTION) when it sees python-requests in the User-Agent header. The rule isn’t Microsoft-proprietary; it’s an OWASP CRS rule. Not all WAFs ship CRS by default though — I tested an AWS WAF with the AWS Managed Common Rule Set and python-requests/2.32 sailed through to the backend. Where AWS does match Azure is on the missing-or-empty UA case (NoUserAgent_HEADER rule fires; HTTP 403 at the WAF). So the common denominator across managed WAFs is “no UA = blocked”; the specific-default-UA block is more aggressive on Azure / OWASP-CRS-loading WAFs. A project-specific UA covers both: it’s diagnostic in your own logs and the upstream’s, documents that the request came from a real tool, and survives HTTP-library upgrades that might shift the default UA into or out of either rule.
Confirmed against Microsoft Graph on /beta/security/informationProtection/sensitivityLabels and /beta/informationProtection/policy. Both are Azure Information Protection beta paths.
Read the response body before reacting to the response code. Microsoft’s 403 has three distinct rejection layers, and the body shape tells you which one to look at. Bot-blocked HTML doesn’t mean the same thing as a structured Graph error.
Say your name loud and proud. Every WAF wants to know who you are.