Managing extensionAttribute in Microsoft Entra ID
A practical guide to reading and writing the 15 legacy extension attributes on user objects, covering both cloud-only and directory-synchronized accounts.
1. Background
extensionAttribute1 through extensionAttribute15 are general-purpose string fields originally introduced by the Exchange schema extension in on-premises Active Directory. They are widely reused as free-form tags for licensing tiers, employee classification, lifecycle state, cost centers, and similar metadata.
In Microsoft Graph they are not top-level properties. All fifteen live inside a single complex property on the user object:
user
└── onPremisesExtensionAttributes
├── extensionAttribute1 : String
├── extensionAttribute2 : String
└── ... through extensionAttribute15
Two consequences follow from this, and they cause most of the confusion:
- The property is not returned by default. You must explicitly request it with
$select/ the-Propertyparameter, or you will silently get nothing back. - Whether you can write it depends entirely on which directory is the source of authority for that object.
Source of authority
onPremisesSyncEnabled | Source of authority | Where you set the value |
|---|---|---|
false or null | Entra ID (cloud-only account) | Microsoft Graph — Update-MgUser |
true | On-premises Active Directory | On-prem AD — Set-ADUser, then sync |
For synchronized objects, Entra ID rejects the write outright. There is no permission, role, or API version that changes this — the cloud copy is a read-only replica of the on-premises value, and the sync engine will overwrite anything you manage to change.
Attribute value rules
- Single-valued Unicode string, maximum 1024 characters.
- Not indexed for
$filterunless you use advanced query parameters (see §5). - Writing a hashtable with one key leaves the other fourteen attributes untouched; the API performs a merge, not a replace, on the complex property.
- Passing
$nullclears an individual attribute.
2. Prerequisites
# Microsoft Graph SDK - for cloud-side read/write
Install-Module Microsoft.Graph.Users -Scope CurrentUser
# ActiveDirectory module (RSAT) - required only for synchronized objects
# Windows client:
Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'
# Windows Server:
Install-WindowsFeature RSAT-AD-PowerShell
Graph permissions
| Operation | Delegated scope | Entra role required |
|---|---|---|
| Read | User.Read.All | Directory Readers |
| Write (cloud-only user) | User.ReadWrite.All | User Administrator |
The scope alone is not sufficient for writes. A delegated call needs both the consented scope and a directory role that permits user modification. If the role is assigned through Privileged Identity Management, activate it before connecting — the access token captures role membership at sign-in time.
3. Complete end-to-end script
Everything below runs as a single script. Set the variables in the CONFIG block, and it detects the source of authority and routes the write to the correct directory automatically.
<#
.SYNOPSIS
Read and write extensionAttribute values on an Entra ID user, routing writes to
the correct source of authority (cloud vs. on-premises Active Directory).
.NOTES
Requires: Microsoft.Graph.Users
ActiveDirectory (RSAT) - only if the target object is synchronized
#>
#region CONFIG ---------------------------------------------------------------
$TargetUpn = 'user1@contoso.com' # user to operate on
$AttributeName = 'extensionAttribute5' # which of the 15 to manage
$NewValue = 'SampleValue' # value to write; $null clears it
$WhatIfOnly = $true # $true = report current state, do not write
#endregion -------------------------------------------------------------------
#region 1. CONNECT -----------------------------------------------------------
# Reconnect explicitly rather than reusing a cached context. A session established
# earlier with only read scopes will silently fail writes with HTTP 403.
if (Get-MgContext) { Disconnect-MgGraph | Out-Null }
Connect-MgGraph -Scopes 'User.Read.All','User.ReadWrite.All' -NoWelcome
$ctx = Get-MgContext
Write-Host "Connected as $($ctx.Account) to tenant $($ctx.TenantId)" -ForegroundColor Cyan
if ($ctx.Scopes -notcontains 'User.ReadWrite.All') {
Write-Warning 'Write scope was not granted. Read operations will work; writes will fail with 403.'
}
#endregion -------------------------------------------------------------------
#region 2. READ CURRENT STATE ------------------------------------------------
# Invoke-MgGraphRequest is used instead of Get-MgUser here because it returns the raw
# JSON payload. The typed SDK cmdlet only populates the properties named in -Property,
# which makes an unrequested field indistinguishable from a genuinely empty one.
$select = 'id,displayName,userPrincipalName,onPremisesSyncEnabled,' +
'onPremisesSamAccountName,onPremisesDomainName,' +
'onPremisesLastSyncDateTime,onPremisesExtensionAttributes'
$user = Invoke-MgGraphRequest -Method GET -Uri "v1.0/users/$TargetUpn`?`$select=$select"
Write-Host "`n--- Current state ---" -ForegroundColor Cyan
[pscustomobject]@{
DisplayName = $user.displayName
ObjectId = $user.id
SyncedFromOnPrem = [bool]$user.onPremisesSyncEnabled
SamAccountName = $user.onPremisesSamAccountName
LastSync = $user.onPremisesLastSyncDateTime
CurrentValue = $user.onPremisesExtensionAttributes.$AttributeName
} | Format-List
Write-Host '--- All 15 attributes ---' -ForegroundColor Cyan
$user.onPremisesExtensionAttributes.GetEnumerator() |
Where-Object { $_.Value } | # drop the empty ones
Sort-Object { [int]($_.Key -replace '\D') } |
Format-Table Key, Value -AutoSize
#endregion -------------------------------------------------------------------
#region 3. WRITE - routed by source of authority -----------------------------
if ($WhatIfOnly) {
Write-Host "`nWhatIf mode: no changes written." -ForegroundColor Yellow
return
}
if (-not $user.onPremisesSyncEnabled) {
# ---- CLOUD-ONLY: Entra ID is authoritative ----
# Only the key supplied is modified; the other 14 attributes are preserved.
Write-Host "`nCloud-only object -> writing via Microsoft Graph" -ForegroundColor Green
Update-MgUser -UserId $user.id -OnPremisesExtensionAttributes @{
$AttributeName = $NewValue
}
$check = Invoke-MgGraphRequest -Method GET `
-Uri "v1.0/users/$($user.id)?`$select=onPremisesExtensionAttributes"
Write-Host "Value is now: $($check.onPremisesExtensionAttributes.$AttributeName)"
} else {
# ---- SYNCHRONIZED: on-premises AD is authoritative ----
# Graph rejects writes here with HTTP 400 Request_BadRequest:
# "Unable to update the specified properties for on-premises mastered
# Directory Sync objects..."
Write-Host "`nSynchronized object -> writing to on-premises Active Directory" -ForegroundColor Green
Import-Module ActiveDirectory -ErrorAction Stop
$sam = $user.onPremisesSamAccountName
if ($null -eq $NewValue) {
Set-ADUser -Identity $sam -Clear $AttributeName -Server $user.onPremisesDomainName
} else {
Set-ADUser -Identity $sam -Replace @{ $AttributeName = $NewValue } `
-Server $user.onPremisesDomainName
}
$adValue = (Get-ADUser -Identity $sam -Properties $AttributeName `
-Server $user.onPremisesDomainName).$AttributeName
Write-Host "On-premises value is now: $adValue"
Write-Host 'The cloud copy updates on the next sync cycle (default: every 30 minutes).' -ForegroundColor Yellow
}
#endregion -------------------------------------------------------------------
#region 4. VERIFY THE CLOUD COPY ---------------------------------------------
# For synchronized objects, run this again after the sync cycle has completed.
# Compare onPremisesLastSyncDateTime against the value captured in section 2:
# - timestamp advanced AND value present -> success
# - timestamp advanced BUT value absent -> a sync rule is filtering the attribute
# - timestamp unchanged -> the cycle has not run yet
$after = Invoke-MgGraphRequest -Method GET `
-Uri "v1.0/users/$($user.id)?`$select=onPremisesExtensionAttributes,onPremisesLastSyncDateTime"
[pscustomobject]@{
Attribute = $AttributeName
ValueBefore = $user.onPremisesExtensionAttributes.$AttributeName
ValueAfter = $after.onPremisesExtensionAttributes.$AttributeName
SyncBefore = $user.onPremisesLastSyncDateTime
SyncAfter = $after.onPremisesLastSyncDateTime
} | Format-List
Disconnect-MgGraph | Out-Null
#endregion -------------------------------------------------------------------
Walkthrough
| Section | What it does and why |
|---|---|
| CONFIG | All environment-specific values are isolated at the top. $WhatIfOnly defaults to $true so a first run only reports. |
| 1. Connect | Disconnects first to avoid inheriting a cached read-only token — the most common cause of an otherwise inexplicable 403. Then verifies the write scope actually landed. |
| 2. Read | Uses Invoke-MgGraphRequest for the raw payload. Get-MgUser -Property populates only the named fields, so an unrequested property looks identical to an empty one — a real trap when diagnosing "why is this blank". Also captures onPremisesSyncEnabled, which drives everything downstream. |
| 3. Write | Branches on source of authority. Cloud-only objects go through Update-MgUser; synchronized objects go to Set-ADUser against the owning domain. The -Server parameter targets the correct domain in a multi-domain forest. |
| 4. Verify | Re-reads the cloud copy and shows before/after alongside the sync timestamp, which distinguishes "hasn't synced yet" from "synced but the attribute was filtered out". |
4. Forcing a synchronization cycle
The ADSync module exists only on the server running the sync engine — it cannot be imported on a workstation. Identify that server from the connector account's description:
Get-ADUser -Filter "SamAccountName -like 'MSOL_*'" -Properties Description |
Select-Object SamAccountName, Description | Format-List
The description records the machine name in plain text. To trigger a delta cycle:
Invoke-Command -ComputerName <SYNC-SERVER> -ScriptBlock {
Import-Module ADSync
Start-ADSyncSyncCycle -PolicyType Delta
}
This requires local administrator rights (or ADSyncOperators membership) on that server plus PowerShell remoting enabled. In practice it is rarely worth pursuing for a routine attribute change — the scheduler runs a delta cycle every 30 minutes by default.
5. Bulk operations
Reporting across the tenant:
# Client-side filter - simple, but enumerates every user
Get-MgUser -All -Property 'displayName,userPrincipalName,onPremisesExtensionAttributes' |
Where-Object { $_.OnPremisesExtensionAttributes.ExtensionAttribute5 } |
Select-Object DisplayName, UserPrincipalName,
@{n='Value'; e={ $_.OnPremisesExtensionAttributes.ExtensionAttribute5 }} |
Export-Csv .\report.csv -NoTypeInformation
# Server-side filter - far faster on large tenants, needs advanced query parameters
Get-MgUser -All -ConsistencyLevel eventual -CountVariable c `
-Filter "onPremisesExtensionAttributes/extensionAttribute5 eq 'SampleValue'" `
-Property 'displayName,userPrincipalName'
Bulk write from a CSV with columns UserPrincipalName,Value:
Import-Csv .\input.csv | ForEach-Object {
try {
$u = Invoke-MgGraphRequest -Method GET `
-Uri "v1.0/users/$($_.UserPrincipalName)?`$select=id,onPremisesSyncEnabled,onPremisesSamAccountName"
if ($u.onPremisesSyncEnabled) {
Set-ADUser -Identity $u.onPremisesSamAccountName `
-Replace @{ extensionAttribute5 = $_.Value } -ErrorAction Stop
Write-Host "AD $($_.UserPrincipalName) -> $($_.Value)" -ForegroundColor Green
} else {
Update-MgUser -UserId $u.id `
-OnPremisesExtensionAttributes @{ extensionAttribute5 = $_.Value } -ErrorAction Stop
Write-Host "CLOUD $($_.UserPrincipalName) -> $($_.Value)" -ForegroundColor Green
}
} catch {
Write-Host "FAIL $($_.UserPrincipalName): $($_.Exception.Message)" -ForegroundColor Red
}
}
6. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| Property is blank but a value exists in the portal | Not requested in $select / -Property | Explicitly request onPremisesExtensionAttributes |
| Some fields on the returned object are empty | -Property populates only what was named | Request every field you intend to read, or use Invoke-MgGraphRequest |
403 Authorization_RequestDenied | Missing scope, or missing directory role | Verify with (Get-MgContext).Scopes; confirm User Administrator; activate the PIM role, then reconnect |
400 Request_BadRequest — "on-premises mastered Directory Sync objects" | Object is synchronized; Entra ID is not authoritative | Write to on-premises AD with Set-ADUser |
Start-ADSyncSyncCycle not recognized | Not running on the sync server | Remote to the sync server, or wait for the scheduled cycle |
| Value set in AD never appears in the cloud | Sync rule filtering, or the object is out of scope for sync | Check the sync rules and the OU filtering configuration |
| Value reverts unexpectedly after some time | An upstream provisioning system owns the attribute | Identify the authoritative writer before setting values by hand |
Operational cautions
extensionAttribute1-15come from the Exchange schema. In an Exchange hybrid deployment these fields may be consumed by address book policies or dynamic distribution group filters — changing one can have visible mail-flow side effects.- Distinguish a genuinely empty attribute from one holding a placeholder string such as
"NULLVALUE"or"N/A". Downstream filters treat a placeholder as a populated value, which is a frequent source of incorrect group membership. - Before scripting a change across many objects, confirm nothing else already writes the attribute. Identity management and HR provisioning platforms commonly own several of these fields and will silently revert manual edits on their next run.
No comments:
Post a Comment