Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCESet-EntraUserExtensionAttribute.ps1
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

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.

Set-EntraUserExtensionAttribute.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-UserPrincipalNamestring—The user to update. Also comes in from the pipeline, so a CSV with a UserPrincipalName column just works.
-AttributeNumberint—Which extension attribute to write, from 1 to 15. Required.
-Valuestring—The value to store, up to 1,024 characters. An empty value clears the attribute. Also read from a Value column in the pipeline.
-Clearswitch—Clear the attribute instead of setting it.
-WhatIfswitch—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 -WhatIf

The real run, with a record of what happened.

Import-Csv .\cost-centers.csv | .\Set-EntraUserExtensionAttribute.ps1 -AttributeNumber 15 | Export-Csv .\attribute-log.csv -NoTypeInformation

Wipe a value that shouldn't be there.

.\Set-EntraUserExtensionAttribute.ps1 -UserPrincipalName [email protected] -AttributeNumber 10 -Clear

What you'll see

Example outputvalues are illustrative
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

  1. Look the user up first. Get-MgUser pulls the account's current attributes and its OnPremisesSyncEnabled flag, so the script knows the old value and whether it's even allowed to write.
  2. 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.
  3. Write with a plain PATCH. The update goes through Invoke-MgGraphRequest with a small JSON body. That's deliberate: to clear an attribute, Graph needs a real JSON null, and sending the raw request is the reliable way to get one.
  4. Return one row per user. Old value, new value, and what happened. Pipe it to Export-Csv and 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).