I Pulled Exchange Custom Attributes From Graph. Here’s Where It Stops Working.

Microsoft Graph finally exposes Exchange custom attributes on synchronized groups, but it's read-only and won't touch your M365 Groups. I spent a morning testing where the new API helps and where you still need…

I Pulled Exchange Custom Attributes From Graph. Here's Where It Stops Working.

I had a script that connected to Exchange Online every morning to read fifteen attributes off a few hundred groups. Just read them. No writes, no fancy logic, pull CustomAttribute1 through CustomAttribute15 so a downstream migration tool could sort groups into waves by cost centre. And every morning that script spent the first ninety seconds doing the Connect-ExchangeOnline dance, loading a cmdlet module the size of a small country, just to read some metadata that already lives in the directory.

So when the note landed this morning, November 12, that Graph now surfaces these attributes on groups, I dropped what I was doing and tried it. Here’s roughly how the morning went, including the part where I convinced myself it was broken before I realised it was working exactly as designed.

The thing that actually changed

Group objects in Graph now carry an onPremisesExtensionAttributes collection, the same shape that user objects have had for years. It holds extensionAttribute1 through extensionAttribute15. If you’ve only ever lived in Exchange, those are the attributes you know as CustomAttribute1–15. Same underlying slots in Active Directory, two different names depending on which admin tool is squinting at them. Exchange calls them Custom; AD and Graph call them extension. Nobody at Microsoft has ever reconciled that vocabulary and I’ve stopped expecting them to.

What this means in practice: for groups that are synchronised from on-premises AD, I can now read those fifteen values through the Graph API without touching Exchange PowerShell at all. That’s the whole story, and it’s smaller than the headline suggests, but it closed a real gap for me, so I’m not complaining.

First attempt, first faceplant

I connected with the read scope and pulled a group I knew had attributes set:

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Group.Read.All' -NoWelcome
​
$group = Get-MgGroup -GroupId '00000000-0000-0000-0000-000000000000'
$group.OnPremisesExtensionAttributes.ExtensionAttribute1

Empty. Nothing. I checked the same group in Exchange Online and CustomAttribute1 was very much populated. For about four minutes I was ready to write the angry post about how Microsoft shipped a half-finished property.

The problem was mine. Graph doesn’t return onPremisesExtensionAttributes by default, it’s one of those properties you have to ask for explicitly with -Property. Leave it off and you get a null that looks exactly like “this group has no attributes” rather than “you didn’t request the data.” That distinction has bitten me before on user objects and it bit me again here. If you take one thing from this: a null from Graph can mean “empty” or “not selected,” and they look identical.

With the select clause added, the values appeared:

audit.ps1PowerShell
$group = Get-MgGroup -GroupId '00000000-0000-0000-0000-000000000000' `
    -Property 'id,displayName,onPremisesSyncEnabled,onPremisesExtensionAttributes'
​
$group.OnPremisesExtensionAttributes.ExtensionAttribute1

If you’re not in PowerShell, building this into a C# worker, a Python job, whatever calls Graph directly, the property lives on the standard group resource. You read it with a plain GET against the group endpoint, and the $select is doing the same job the -Property parameter does above: it’s mandatory, because the collection isn’t in the default projection.

request.httpHTTP
GET https://graph.microsoft.com/v1.0/groups/{id}?$select=id,displayName,onPremisesSyncEnabled,onPremisesExtensionAttributes
Authorization: Bearer {token}

That’s v1.0, the production endpoint, not beta, so you’re not building automation on a preview surface that can change under you. The same property is mirrored on /beta/groups/{id} if you’re already living there, but there’s no reason to: for this, v1.0 is the one to call. Drop the $select and you get a group object with no onPremisesExtensionAttributes field at all, which, again, reads as “empty” to code that isn’t looking carefully. Ask for it by name every time.

The version that survives contact with a real tenant

One group is a demo. I needed this across a few hundred synced groups, with pagination that doesn’t fall over at the 999th result, and with null-handling that tells me the difference between “unset” and “I couldn’t read it.” This is what I settled on, read-only, so there’s no -WhatIf to worry about, but I still don’t trust a loop that assumes every object is shaped the way I expect:

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Group.Read.All' -NoWelcome
​
$props = 'id,displayName,onPremisesSyncEnabled,onPremisesExtensionAttributes'
$results = [System.Collections.Generic.List[object]]::new()
​
try {
    # -All handles paging; without it you silently cap at the first page
    $groups = Get-MgGroup -All -Property $props -ErrorAction Stop
}
catch {
    Write-Error "Failed to enumerate groups: $($_.Exception.Message)"
    return
}
​
foreach ($g in $groups) {
​
    # Only synced groups carry meaningful on-prem attributes
    if (-not $g.OnPremisesSyncEnabled) { continue }
​
    $ext = $g.OnPremisesExtensionAttributes
​
    # Distinguish 'not returned' from 'genuinely empty'
    if ($null -eq $ext) {
        Write-Warning "$($g.DisplayName): extension attributes not returned, check -Property"
        continue
    }
​
    $results.Add([pscustomobject]@{
        DisplayName   = $g.DisplayName
        GroupId       = $g.Id
        CostCentre    = $ext.ExtensionAttribute1
        MigrationWave = $ext.ExtensionAttribute2
        AllSet        = @(1..15 | Where-Object {
                            -not [string]::IsNullOrWhiteSpace($ext."ExtensionAttribute$_")
                        }).Count
    })
}
​
$results | Sort-Object MigrationWave | Format-Table -AutoSize

Two deliberate choices in there. I skip anything where OnPremisesSyncEnabled is false, because cloud-only groups will never have these populated and including them just dirties the output. And I treat a null collection as a loud warning, not a quiet skip, because the one time it’s null for a synced group, it’s almost certainly a select-clause mistake and I want to know.

Then I went looking for the wall

The obvious next question: can I write back? I had a wave-tagging job that stamps CustomAttribute2 on groups as they’re triaged, and if Graph could do that I’d retire the Exchange dependency entirely.

It can’t. This is read-only. onPremisesExtensionAttributes on a synced group is sourced from on-premises AD, and Graph treats it the way it treats every synced attribute, as a shadow of the authoritative copy. The real value lives in your on-prem directory, flows up through Entra Connect, and Graph shows you the result. Trying to Update-MgGroup that collection is a non-starter, and that’s actually correct behaviour: letting the cloud write a synced attribute would just get stamped over on the next sync cycle and create the kind of ghost-edit confusion that eats an afternoon.

So the write path is unchanged. For synced groups, you set the value where it’s mastered, on-premises AD, or via Exchange Online’s Set-DistributionGroup / Set-Group depending on recipient type, in a hybrid where those writes flow correctly. Graph reads; it does not author.

The limitation that’ll trip a teammate

Here’s the one I’d put in bold on the whiteboard. This only works for groups synchronised from on-premises AD, distribution groups and mail-enabled security groups that originated in your local directory. Cloud-only security groups and Microsoft 365 Groups created natively in Entra don’t have on-premises extension attributes at all, because there’s no on-premises to extend from. Query one and you’ll get a null collection that is entirely truthful: there’s nothing there.

That matters because a lot of modern tenants are a blend. Some groups were born on-prem a decade ago; the ones created last quarter are cloud-native. Run my loop across the whole tenant and the OnPremisesSyncEnabled filter quietly drops half of them, which is correct but surprising if you forgot the groups were ever mixed. And if someone on your team has standardised on M365 Groups for everything and still wants custom attributes on them, that’s an Exchange Online conversation, not a Graph one. Those cloud groups do carry custom attributes in Exchange, but they come from a different place and Graph’s onPremisesExtensionAttributes will never show them.

Checking sync status without guessing

Before trusting any of this per-group, confirm what you’re actually looking at. In the Entra admin center, open Identity > Groups > All groups, pick the group, and read the Overview blade. The field you want is the source, a synced group shows Windows Server AD (and the API-level flag behind it is onPremisesSyncEnabled = true); a cloud group shows Cloud. Same signal, two surfaces. If you’d rather not click, the property is right there in the script above, which is why I pull it on every object.

One footgun on the portal side: directory sync status and a specific group’s sync flag are not the same question. DirSync being enabled for the tenant tells you synchronisation is happening somewhere; it does not tell you this group came from on-prem. Trust the per-object onPremisesSyncEnabled, not the tenant-wide toggle.

What I actually gained

My morning read-job lost its Exchange Online connection and got faster and quieter for it. For a migration project, that’s not nothing: reading department codes, cost centres, and wave tags off hundreds of synced groups through a single Graph call, with the same auth, scopes, and throttling behaviour as the rest of a Graph-based pipeline, beats bolting an EXO session onto an otherwise clean automation runner. If you’re tagging migration waves in CustomAttribute slots on synced groups, your reporting and dry-run tooling can now live entirely in Graph.

But I’d tell anyone picking this up: do not let it convince you Exchange Online PowerShell is retired. You read with Graph. You still write with Exchange, or on-premises AD, and you still reach for Exchange for anything on cloud-only and M365 Groups. This closed one specific gap in one specific direction. Treat it as exactly that much and you won’t get surprised in six months when someone asks why the “Graph migration” left a Connect-ExchangeOnline in the write path.

Half a feature, shipped cleanly, doing the honest thing by refusing to pretend it can write. I’ll take it.