SCRIPT LIBRARY · POWERSHELL
Set Extension Attributes on Entra ID Users with PowerShell
Write extensionAttribute1-15 on cloud-only Entra ID users from a CSV, with a clear note for every synced account it can't touch.
- What it does
- Sets or clears one of the fifteen extension attributes on one user or a whole CSV of them, skips accounts synced from on-prem AD, and returns a before-and-after row for each user.
- Requires
- PowerShell 7.2+ (Windows PowerShell 5.1 works too)
- Microsoft.Graph.Users and Microsoft.Graph.Authentication modules
- Permissions
- Graph scope: User.ReadWrite.All, plus a role that can edit users, such as User Administrator.
- Runs on
- Windows, macOS, Linux
- Tested
- Parse-checked and dry-run with mocked Graph cmdlets in PowerShell 7.4
Part 2 of the thread The Microsoft Graph toolbox
Those fifteen extension attributes are the junk drawer of the directory. Cost center, badge number, the building someone sits in, a flag that tells a dynamic group who belongs. Sooner or later you need to stamp a value on a few hundred users, and clicking through the admin center one profile at a time isn't going to happen.
Here's the catch nobody mentions until it bites: Graph will only write onPremisesExtensionAttributes for cloud-only users. If the account is synced from on-prem Active Directory, the value belongs to AD and Graph refuses the change. This script checks each user first, updates the ones it can, and tells you plainly which ones you'll have to fix in AD instead.
The original 2022 version of this post used the AzureAD module, which Microsoft has retired, and it didn't really work (it set a property the cmdlet never read). This one uses the Microsoft Graph PowerShell SDK, takes a CSV, and supports -WhatIf.
<#
.SYNOPSIS
Sets or clears one of the 15 extension attributes on Microsoft Entra ID users.
.DESCRIPTION
Writes extensionAttribute1-15 (onPremisesExtensionAttributes) through Microsoft Graph.
Graph only allows this for cloud-only users. Accounts synced from on-premises Active
Directory are skipped with a note, because their values have to be changed in AD.
Takes pipeline input, so a CSV with UserPrincipalName and Value columns works as-is.
Supports -WhatIf.
.PARAMETER UserPrincipalName
The user to update. Accepts pipeline input by property name.
.PARAMETER AttributeNumber
Which extension attribute to write, 1 through 15.
.PARAMETER Value
The value to store. Leave it empty (or use -Clear) to remove the current value.
.PARAMETER Clear
Clear the attribute instead of setting it.
.EXAMPLE
.\Set-EntraUserExtensionAttribute.ps1 -UserPrincipalName [email protected] -AttributeNumber 15 -Value 'Cost Center 4410'
.EXAMPLE
Import-Csv .\attributes.csv | .\Set-EntraUserExtensionAttribute.ps1 -AttributeNumber 10 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Set')]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[Alias('UPN', 'UserId')]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[ValidateRange(1, 15)]
[int]$AttributeNumber,
[Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Set')]
[AllowEmptyString()]
[ValidateLength(0, 1024)]
[string]$Value,
[Parameter(Mandatory, ParameterSetName = 'Clear')]
[switch]$Clear
)
begin {
if (-not (Get-MgContext)) { Connect-MgGraph -Scopes 'User.ReadWrite.All' -NoWelcome }
$attributeName = "extensionAttribute$AttributeNumber"
}
process {
$newValue = if ($Clear -or [string]::IsNullOrWhiteSpace($Value)) { $null } else { $Value.Trim() }
$result = [pscustomobject]@{
UserPrincipalName = $UserPrincipalName
Attribute = $attributeName
OldValue = $null
NewValue = $newValue
Result = $null
}
try {
$user = Get-MgUser -UserId $UserPrincipalName -Property 'Id,UserPrincipalName,OnPremisesSyncEnabled,OnPremisesExtensionAttributes' -ErrorAction Stop
}
catch {
$result.Result = "Failed: $($_.Exception.Message)"
return $result
}
$oldValue = $user.OnPremisesExtensionAttributes.$attributeName
$result.OldValue = $oldValue
if ($user.OnPremisesSyncEnabled) {
$result.Result = 'Skipped: synced from on-prem AD, change it there'
}
elseif ($oldValue -ceq $newValue) {
$result.Result = 'Unchanged'
}
elseif ($PSCmdlet.ShouldProcess($user.UserPrincipalName, "Set $attributeName to '$newValue'")) {
# Invoke-MgGraphRequest sends a real JSON null, which is how Graph clears the value.
$body = @{ onPremisesExtensionAttributes = @{ $attributeName = $newValue } } | ConvertTo-Json -Depth 3
try {
Invoke-MgGraphRequest -Method PATCH -Uri "v1.0/users/$($user.Id)" -Body $body -ContentType 'application/json' -ErrorAction Stop | Out-Null
$result.Result = 'Updated'
}
catch {
$result.Result = "Failed: $($_.Exception.Message)"
}
}
else {
$result.Result = 'WhatIf'
}
Write-Verbose "$($user.UserPrincipalName): $($result.Result)"
$result
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-UserPrincipalName | string | — | The user to update. Also comes in from the pipeline, so a CSV with a UserPrincipalName column just works. |
-AttributeNumber | int | — | Which extension attribute to write, from 1 to 15. Required. |
-Value | string | — | The value to store, up to 1,024 characters. An empty value clears the attribute. Also read from a Value column in the pipeline. |
-Clear | switch | — | Clear the attribute instead of setting it. |
-WhatIf | switch | — | Show what would change without writing anything. |
Run it
One user, one value.
.\Set-EntraUserExtensionAttribute.ps1 -UserPrincipalName [email protected] -AttributeNumber 15 -Value 'Cost Center 4410'A whole CSV (columns UserPrincipalName and Value), dry run first.
Import-Csv .\cost-centers.csv | .\Set-EntraUserExtensionAttribute.ps1 -AttributeNumber 15 -WhatIfThe real run, with a record of what happened.
Import-Csv .\cost-centers.csv | .\Set-EntraUserExtensionAttribute.ps1 -AttributeNumber 15 | Export-Csv .\attribute-log.csv -NoTypeInformationWipe a value that shouldn't be there.
.\Set-EntraUserExtensionAttribute.ps1 -UserPrincipalName [email protected] -AttributeNumber 10 -ClearWhat you'll see
UserPrincipalName Attribute OldValue NewValue Result
----------------- --------- -------- -------- ------
[email protected] extensionAttribute15 Cost Center 4100 Cost Center 4410 Updated
[email protected] extensionAttribute15 Cost Center 4410 Cost Center 4410 Unchanged
[email protected] extensionAttribute15 Cost Center 4410 Skipped: synced from on-prem AD, change it there
[email protected] extensionAttribute15 Cost Center 4410 Failed: Resource '[email protected]' does not exist
How it works
- Look the user up first.
Get-MgUserpulls the account's current attributes and itsOnPremisesSyncEnabledflag, so the script knows the old value and whether it's even allowed to write. - Skip what it can't or shouldn't change. Synced accounts get a "change it in AD" note. Users who already have the right value are marked Unchanged and left alone, so rerunning a CSV is harmless.
- Write with a plain PATCH. The update goes through
Invoke-MgGraphRequestwith a small JSON body. That's deliberate: to clear an attribute, Graph needs a real JSONnull, and sending the raw request is the reliable way to get one. - Return one row per user. Old value, new value, and what happened. Pipe it to
Export-Csvand you've got your change record.
Take it further
- Outgrew the fifteen? Directory extensions (the
extension_<appId>_<name>properties) let you define your own named, typed attributes through an app registration. They work on cloud-only and synced users, since Entra Connect can sync custom AD attributes into them. - Drive dynamic groups from it. A rule like
user.extensionAttribute15 -eq "Cost Center 4410"turns this script into a quick way to manage group membership in bulk. - Keep it in sync with HR. Export from your HR system nightly and run this unattended with an app registration and certificate auth. Only changed values get written.
Things that'll trip you up
- Synced users are AD's problem. For accounts that come from on-prem Active Directory, extensionAttribute1-15 are owned by AD and flow up through Entra Connect. Graph won't write them. Set the value in AD (Set-ADUser -Replace @{extensionAttribute15='...'}) and let the next sync carry it up.
- You'll see onPremisesExtensionAttributes even for cloud users. The name is a leftover from when these only came from Exchange on-prem. For cloud-only users they're just regular writable attributes, despite what the name suggests.
- Some changes don't show up right away. Dynamic groups that use these attributes can take a while to re-evaluate, and the Exchange address book has its own lag. Give it time before assuming the write failed.
- Fifteen slots fill up fast. If different teams are all using these, write down who owns which number. When you run out, or you need a typed value like a date or a number, use a directory extension instead (see below).