Skip to content

Microsoft Graph error codes: what Graph actually returns

Microsoft Graph returns errors in a fixed envelope with a named error code inside it:

{ "error": { "code": "Authorization_RequestDenied", "message": "Insufficient privileges to complete the operation.", "innerError": { "date": "...", "request-id": "...", "client-request-id": "..." } } }

The HTTP status tells you the class of problem. The code tells you which check failed, and it is the part worth matching on in code. This page lists the codes, messages and statuses that a lab tenant actually returned on 31 October 2026, with the exact request that produced each one.

Every row below came from an automated run against a lab tenant. The run records the application permissions in its own token, read from the token’s roles claim: Application.ReadWrite.OwnedBy and User.ReadWrite.All, and nothing else. That matters for the permission-denied rows, because it is what makes them failures rather than accidents.

HTTP Error code Request that produced it Message (first line)
401 InvalidAuthenticationToken No Authorization header at all Access token is empty.
401 InvalidAuthenticationToken Authorization: Bearer with an empty value UnableToParseTokens
401 InvalidAuthenticationToken A token issued for a different audience Access token validation failure. Invalid audience.
401 InvalidAuthenticationToken A token with a modified signature Signature is invalid.
403 Authentication_MSGraphPermissionMissing GET /auditLogs/signIns without AuditLog.Read.All The principal does not have any one of the required Microsoft Graph permissions: AuditLog.Read.All to call this API. …and a link to Microsoft’s permissions overview
403 AccessDenied GET /identity/conditionalAccess/policies without Policy.Read.All You cannot perform the requested operation, required scopes are missing in the token.
403 Authorization_RequestDenied POST /groups without Group.ReadWrite.All Insufficient privileges to complete the operation.
400 Request_UnsupportedQuery $filter on a property that does not exist Property 'notARealProperty' does not exist as a declared property or extension property.
400 Request_UnsupportedQuery $filter=endsWith(...) without ConsistencyLevel: eventual Operator 'endsWith' is not supported because the 'ConsistencyLevel:eventual' header is missing. Refer to https://aka.ms/graph-docs/advanced-queries for more information
400 Request_UnsupportedQuery $top=5000 Invalid page size specified: '5000'. Must be between 1 and 999 inclusive.
400 Request_BadRequest POST /users with no password A password must be specified to create a new user.
400 Request_BadRequest POST /users reusing an existing userPrincipalName Another object with the same value for property userPrincipalName already exists.
405 Request_BadRequest DELETE /users (the collection, not an object) Specified HTTP method is not allowed for the request target.
400 BadRequest GET /notAResource Resource not found for the segment 'notAResource'.
400 BadRequest A body that is not valid JSON Unable to read JSON request payload. Please ensure Content-Type header is set and payload is of valid JSON format.
404 Request_ResourceNotFound GET /users/<unused GUID> Resource '<id>' does not exist or one of its queried reference-property objects are not present.
400 BadRequest GET /me with an app-only token /me request is only valid with delegated authentication flow.
400 Request_BadRequest GET /users?$skip=1 '$skip' is not supported by the service.
400 Request_BadRequest GET /organization/<unused GUID> Invalid tenant identifier; it must match that of the requested tenant.
400 Request_UnsupportedQuery $filter=contains(...) Filter operator 'Contains' is not supported.

Five things the measurements show that are easy to get wrong

Section titled “Five things the measurements show that are easy to get wrong”

1. A missing object returns 403, not 404, when your app cannot read that type

Section titled “1. A missing object returns 403, not 404, when your app cannot read that type”

The same request shape — a GET for a GUID that belongs to no object — returned three different answers depending on the collection (404, 403, and on /organization a 400 about the tenant identifier). The run also read each collection itself, which turns the pattern from an observation into a measurement:

Collection GET /<collection>?$top=1 GET /<collection>/<unused GUID>
users 200 404 Request_ResourceNotFound
applications 200 404 Request_ResourceNotFound
servicePrincipals 200 404 Request_ResourceNotFound
groups 403 Authorization_RequestDenied 403 Authorization_RequestDenied
devices 403 Authorization_RequestDenied 403 Authorization_RequestDenied
directoryRoles not read 403 Authorization_RequestDenied
directory/administrativeUnits not read 403 Authorization_RequestDenied
contacts not read 403 Authorization_RequestDenied
organization not read 400 Request_BadRequest, Invalid tenant identifier

The correlation is exact across all five collections where both levels were tested: each one the app could read returned 404 for a GUID belonging to nothing, and each one it could not read returned 403 for the same thing. The three marked “not read” were only probed with a GUID, so they are consistent with the pattern rather than evidence for it.

Our reading of the mechanism is that permission on the resource type is checked before the object is looked up, so there is nothing left to find out once the type check fails. The run cannot see inside Graph to confirm an ordering; what it does establish is the correlation, and the correlation is what you can use.

Two consequences. If you are debugging, a 403 on a single-object GET says nothing about whether the object exists — do not read it as “deleted” — and a 404 means your app can read that collection, which is a useful thing to learn by accident. If you are writing detections, the same pair is a permission probe: an app sending single-object GETs with invented GUIDs across collections maps exactly which types it can read, from the status codes alone, without reading one real object.

2. $count=true without the header does not fail — it is silently ignored

Section titled “2. $count=true without the header does not fail — it is silently ignored”

GET /users?$count=true with no ConsistencyLevel header returned HTTP 200, and the run checked the response body: @odata.count was not present. So the request succeeded and the parameter did nothing.

That is worse than an error, and it is the finding on this page most likely to cost someone an afternoon. In the same run:

Query Result
$count=true, without the header 200, and @odata.count absent from the body
$count=true, with the header (control) 200, and @odata.count present
$filter=endsWith(userPrincipalName,'...'), without the header 400 Request_UnsupportedQuery, message names the header
$search="displayName:lab", without the header 400 Request_UnsupportedQuery, message names the header
$filter=startsWith(...)&$orderby=..., without the header 400 Request_UnsupportedQuery, message does not name the header

Microsoft’s advanced-queries page says the header and $count go together. The lab shows what happens when you send $count without the header: nothing visible. Code that reads @odata.count gets undefined, and a count that renders as zero or blank looks like an empty tenant rather than a missing header.

3. $select, $count and read-only properties are all silently ignored

Section titled “3. $select, $count and read-only properties are all silently ignored”

The $count row above is one case of a pattern that shows up three times in these runs.

Request Result
GET /users?$select=notARealProperty 200, no error. Our reading, from how $select works, is that the property is simply absent from the objects; the run recorded the status, not the body
GET /users?$count=true without the header 200; @odata.count absent
PATCH /users/<id> setting the read-only id 204 No Content, and a read-back of the object confirmed the id was unchanged

Meanwhile a $filter on that same non-existent property returned 400 and named it.

So Graph is strict about what it is asked to evaluate and lax about what it is asked to return or store. A typo in $filter tells you immediately; the same typo in $select gives you objects missing a field, and a write to a property that cannot be written returns success. All three failures surface later, somewhere else, as missing or unchanged data — which is why they are worth knowing before you meet them.

Practical rule: never treat a 2xx from Graph as confirmation that your parameter or your write took effect. Read back what you need.

4. Three different codes mean “you do not have permission”

Section titled “4. Three different codes mean “you do not have permission””

All three are HTTP 403, and which one you get depends on which service handles the request:

Code Seen on What the message gives you
Authentication_MSGraphPermissionMissing /auditLogs/* Names the permission required (AuditLog.Read.All)
AccessDenied /identity/conditionalAccess/policies “required scopes are missing in the token”
Authorization_RequestDenied directory reads and writes (/groups, /directoryRoles, POST /groups) “Insufficient privileges to complete the operation.”

Only the first tells you what to grant. If you are matching on error codes to produce a useful message for an operator, you need all three, and for the last two you have to map the endpoint to the permission yourself.

5. A $batch returns 200 even when every request inside it failed

Section titled “5. A $batch returns 200 even when every request inside it failed”

A $batch with three inner requests — one valid read, one GUID belonging to nothing, and one endpoint the app has no permission for — returned HTTP 200 on the outer POST. The failures were in the body, per request:

Inner request Status Code
GET /users?$top=1 200 —
GET /users/<unused GUID> 404 Request_ResourceNotFound
GET /identity/conditionalAccess/policies 403 AccessDenied

This one is documented, unlike the four above: Microsoft’s JSON batching page says “A 200 status code on the batch response headers doesn’t indicate that the individual requests inside the batch succeeded.” It is here because it is easy to miss, and the lab shows what it looks like in practice: the outer status tells you only that the batch was accepted. Error handling that checks the HTTP status of a $batch call and stops there will treat a batch where every request failed as a success. Each entry in responses carries its own status and its own error, and those are the ones to check.

This is the reason these pages exist, so it is worth being precise about it rather than claiming more than was checked.

Microsoft’s current Graph error reference has no list of named error codes. The page at learn.microsoft.com/graph/errors was fetched on 31 October 2026. Its two content sections are HTTP status codes and Error resource type, the latter with JSON representation and Properties beneath it. The status-code table runs 400 to 509. The only named code strings anywhere on it are badRequest and invalidRange as values in a sample JSON body, Directory_ConcurrencyViolation inside the 409 row, and insufficient_claims inside the 403 row. It points to no fuller list. Its outbound links go to the Microsoft REST API Guidelines, which specify the response format rather than the codes, to the metered-API list, and to the Conditional Access developer guidance; none of them is a code catalogue.

Where each code on this page is documented, after searching the literal code string on learn.microsoft.com:

Code Documented by Microsoft?
Authorization_RequestDenied Yes, currently — a troubleshooting article in the Entra support docs, plus the retired catalogue
Request_ResourceNotFound Only in the retired Azure AD Graph catalogue (last updated 2016, carries a retirement caution)
Request_UnsupportedQuery Only in the retired catalogue, where its message column is blank
Request_BadRequest Only in the retired catalogue
InvalidAuthenticationToken Only in an archived TechNet wiki article, marked NOINDEX and archived
BadRequest (bare) Not defined anywhere found; appears once on the current reference as a value in a sample payload
Authentication_MSGraphPermissionMissing No page found by those searches
AccessDenied (this casing) No page found by those searches. A lower-case accessDenied is defined on the OneDrive REST API error page, a different service’s catalogue

Two things follow from that table.

For five of these codes, the only Microsoft source is a page that tells you not to use the API it documents. That page is still worth citing, because its recorded message for Request_ResourceNotFound — “Resource {resource} does not exist or one of its queried reference-property objects are not present.” — is, word for word, what Graph v1.0 returned in the lab on 31 October 2026. The 2016 wording is current behaviour. Where it has drifted, the lab shows that too: the catalogue gives Authorization_RequestDenied the message “The specified credentials do not have sufficient privileges to make this request.”, and the live service returns “Insufficient privileges to complete the operation.” instead.

For the rest, a measurement is the only evidence there is. That is the whole argument for running the lab rather than reading documentation: the retired catalogue cannot tell you whether a code is still emitted, and the current reference does not name it at all.

Being explicit about this matters more than a longer table.

Code Why the lab could not produce it
Authentication_RequestFromNonPremiumTenantOrB2CTenant GET /auditLogs/signIns without AuditLog.Read.All returned Authentication_MSGraphPermissionMissing, so this code was never reached. Our reading is that the permission check runs before the licence check, which the run cannot confirm because only one error came back. Reaching it needs that permission granted.
Request_MultipleObjectsWithSameKeyValue A duplicate userPrincipalName returned Request_BadRequest instead (see the table above). The retired Azure AD Graph catalogue lists this code for exactly that case. Whether anything still returns it on Graph v1.0 is untested.
Request_ThrottledTemporarily 400 identical reads, 20 at a time, all returned 200. No throttling was seen, so there is no measured Retry-After value to report. Our reading: a near-empty tenant is a poor place to reach a tenant-wide limit, and 400 requests is a small number next to Graph’s published throttling thresholds.
Authentication_ExpiredToken Needs a token held past its lifetime; the run is short-lived.
Directory_QuotaExceeded Needs the tenant’s object quota to be exhausted.
Service_InternalServerError Not something to provoke deliberately against a live service.

The run is a single Node script in the site’s repository, triggered from GitHub Actions. It authenticates with a federated credential (GitHub OIDC, no client secret), sends each request, and records the HTTP status, the error code, the first line of the message and which fields innerError carried. It also records the application permissions in its own token, read from the token’s roles claim, so the permission-denied results rest on a measurement rather than on what the app registration is believed to hold. Anything it creates is deleted at the end. Tokens are masked in the log and never written to the output.

The results on this page come from five runs on 31 October 2026, 66 scenarios in the last of them. The last round exists to test the fixes these pages recommend rather than assert them: adding ConsistencyLevel: eventual and $count=true to the failing sort, adding the header to $search, swapping contains for startsWith, and replacing /me with /users/<id> all returned 200. Where a page says “do this instead”, that is what was measured. Two corrections the runs produced about themselves are worth recording, because they are the kind of thing that quietly invalidates a measurement: a scenario labelled “no Authorization header” was in fact sending an empty bearer value, which is a different error with a different message; and the first version truncated long messages, so a quoted message ended mid-URL. Both were found by review rather than by the script, and both are fixed.

The script and the raw output of every run are public, so you can check the results or run them against your own tenant:

Request IDs are worth keeping. Across these runs only one innerError field set was ever recorded — request-id, client-request-id and date — so that is what to expect, though the run collects the set of shapes seen rather than checking every response individually. Microsoft support asks for the request id, and it is the only way to tie a failure to a specific request after the fact.

  • AADSTS error codes — sign-in errors from the token endpoint, which are a different catalogue with different codes.

Graph call failures do not appear in the Entra sign-in logs. The sign-in that produced the token does; the rejected call does not. What records the calls themselves is MicrosoftGraphActivityLogs, which needs a diagnostic setting sending Graph activity to a Log Analytics workspace, and a tenant licensed for it.

One limitation decides how you use it: that table records the HTTP status code, not the Graph error code or the message. There is no ResponseStatusCode == "Authorization_RequestDenied". So you hunt by status and URI, and you look up the likely code from the endpoint — which is what the pages here are for.

Where the failures are, by app:

MicrosoftGraphActivityLogs
| where TimeGenerated > ago(7d)
| where ResponseStatusCode >= 400
| extend resource = tostring(split(tostring(parse_url(RequestUri).Path), "/")[2])
| summarize calls = count(), resources = dcount(resource),
statuses = make_set(ResponseStatusCode, 10),
sample = any(RequestUri)
by AppId, ServicePrincipalId, UserId
| order by calls desc

A UserId that is empty means an app-only token; a populated one means the call was made on behalf of a signed-in user, which changes who to ask about it.

The permission-probe pattern — one identity collecting 403s across many different resource types — is the one worth alerting on, because it is what mapping a tenant’s permission model looks like:

MicrosoftGraphActivityLogs
| where TimeGenerated > ago(1d)
| where ResponseStatusCode == 403
| extend resource = tostring(split(tostring(parse_url(RequestUri).Path), "/")[2])
| summarize denied = count(), distinctResources = dcount(resource),
resources = make_set(resource, 20), firstSeen = min(TimeGenerated), lastSeen = max(TimeGenerated)
by AppId, ServicePrincipalId
| where distinctResources >= 4 // starting point: tune to your tenant's noise floor
| order by distinctResources desc

Four distinct resource types is a starting point, not a threshold to trust. A new integration being configured produces the same shape, so compare firstSeen against when the app registration was created before treating it as hostile.

The 403-and-404 pair described above leaves its own trace: an identity asking for many distinct single objects and finding none of them.

MicrosoftGraphActivityLogs
| where TimeGenerated > ago(1d)
| where RequestMethod == "GET" and ResponseStatusCode in (403, 404)
| extend path = tostring(parse_url(RequestUri).Path)
| extend resource = tostring(split(path, "/")[2]), objectId = tostring(split(path, "/")[3])
| where isnotempty(objectId) and objectId matches regex "^[0-9a-fA-F-]{36}$"
| summarize lookups = dcount(objectId), resources = dcount(resource),
notFound = countif(ResponseStatusCode == 404), denied = countif(ResponseStatusCode == 403)
by AppId, ServicePrincipalId, bin(TimeGenerated, 1h)
| where lookups >= 20 and resources >= 3 // starting point
| order by lookups desc

A legitimate client looks up objects it has reason to believe exist, so a high ratio of misses to distinct identifiers is the signal, not the volume on its own.

Rejected tokens are a different question, and a quieter one:

MicrosoftGraphActivityLogs
| where TimeGenerated > ago(7d)
| where ResponseStatusCode == 401
| summarize calls = count(), uris = dcount(RequestUri), ips = dcount(IPAddress),
agents = make_set(UserAgent, 5)
by AppId, ServicePrincipalId, bin(TimeGenerated, 1h)
| where calls >= 50 // starting point
| order by calls desc

Sustained 401s usually mean a client with a wrong audience or an expired cached token, which is a reliability problem rather than a security one. It is worth a look when the identity is one you did not expect to call Graph at all, because a token obtained legitimately for another resource and replayed against Graph produces exactly this.

The Roles and Scopes columns on this table carry what the calling token actually held, which is the quickest way to answer “what is this app allowed to do” without going to the app registration:

MicrosoftGraphActivityLogs
| where TimeGenerated > ago(30d)
| summarize calls = count(), roles = make_set(Roles, 10), scopes = make_set(Scopes, 10)
by AppId, ServicePrincipalId
| order by calls desc