The Problem#
Machine tags in Microsoft Defender for Endpoint are how you categorize devices for scoping, reporting, and dynamic device groups. “Offboarded,” “VIP,” “Kiosk,” “Critical” – whatever your taxonomy is, tags drive visibility and policy.
The Defender portal lets you tag devices one at a time. The API lets you automate it. But writing a robust tagging script that handles edge cases – device not found, ambiguous name matches, tag already present, per-device tag overrides – takes more effort than most people invest. So they either tag manually or ship a fragile one-off script that breaks on the first edge case.
How It Works#
Set-DefenderMachineTag.ps1 takes either a CSV file or a single device name, looks it up in Defender, and adds or removes a tag. It handles ambiguous name matches, checks if the tag already exists before touching the API, and supports per-row tag overrides from the CSV. Both bulk and single-device modes support -WhatIf.
# Bulk add from CSV
.\Set-DefenderMachineTag.ps1 -CSVPath "C:\Data\Devices.csv"
# Single device
.\Set-DefenderMachineTag.ps1 -DeviceName "DESKTOP-ABC123" -MachineTag "VIP"
# Dry run -- see what would happen
.\Set-DefenderMachineTag.ps1 -CSVPath "C:\Data\Devices.csv" -Action Remove -WhatIf
# EU tenant
.\Set-DefenderMachineTag.ps1 -CSVPath "C:\Data\Devices.csv" -ApiBaseUrl "https://api-eu.securitycenter.microsoft.com"The script uses the Defender for Endpoint API (api.securitycenter.windows.com), not the Graph API. Authentication goes through Connect-AzAccount – interactive browser login, no app registration or secrets needed.
Prerequisites#
- PowerShell 5.1+ with the
Az.Accountsmodule (Install-Module Az.Accounts) - A Defender for Endpoint RBAC role with machine write access
- A CSV file with a
DeviceNamecolumn (for bulk mode)
The script authenticates interactively via Connect-AzAccount and acquires a Defender API token using Get-AzAccessToken. No app registration, no secrets, no certificates, no managed identity.
CSV Format#
Single tag for all devices (using the -MachineTag parameter):
DeviceName
DESKTOP-001
DESKTOP-002
SERVER-003Per-row tags (overrides -MachineTag when a Tag column is present):
DeviceName,Tag
DESKTOP-001,Offboarded
DESKTOP-002,VIP
SERVER-003,CriticalRows with an empty Tag value fall back to the -MachineTag parameter.
Implementation#
Device Lookup and Ambiguity Handling#
The script fetches all machines from Defender once and matches locally. The Defender API’s machine endpoint doesn’t support reliable server-side tag filtering, so pulling the full list and filtering in PowerShell is more consistent. OData pagination is handled automatically – if your tenant has more machines than fit in one response, the script follows @odata.nextLink until it has everything.
Device lookup uses exact DNS name matching first, then falls back to prefix matching. If a device name matches multiple machines (common with recycled hostnames), the script warns and takes the first match:
$candidates = $AllDef | Where-Object { $_.computerDnsName -like "$name*" }
$target = $candidates | Where-Object { $_.computerDnsName -ieq $name } | Select-Object -First 1
if (-not $target) {
$target = $candidates | Select-Object -First 1
$amb = $true
}
if ($amb -and $candidates.Count -gt 1) {
Write-Warning ("Ambiguous match for '{0}' -> taking '{1}' (found {2} candidates)" -f $name, $target.computerDnsName, $candidates.Count)
$stats.Ambiguous++
}Idempotent Tagging#
Before adding a tag, the script checks if it already exists. Before removing, it checks if the tag is present. This prevents unnecessary API calls and keeps the stats accurate:
if ($Action -eq 'Add') {
if ($tags -contains $tag) {
$stats.AlreadyTagged++
continue
}
$body = @{ Value = $tag; Action = 'Add' }
Invoke-DefenderCall -Headers $DefH -Uri "$ApiBaseUrl/api/machines/$id/tags" -Method Post -Body $body | Out-Null
}Per-Row Tag Overrides#
If the CSV includes a Tag column, the script uses it per row instead of the global -MachineTag parameter:
$PerRowTag = ($CSV | Get-Member -Name Tag -MemberType NoteProperty) -ne $null
$tag = if ($PerRowTag -and $row.Tag) { [string]$row.Tag } else { $MachineTag }API Throttling#
The Defender API has rate limits (roughly 100 calls per minute per application). The script handles HTTP 429 responses with exponential backoff – if you hit the limit, it reads the Retry-After header and waits automatically, retrying up to 5 times per call. In large tenants with hundreds of devices in the CSV, expect the tag operations to take a few minutes.
Summary Stats#
The script tracks detailed statistics and prints a summary at the end:
$stats = [ordered]@{
Total = 0; NotFound = 0; Ambiguous = 0; AlreadyTagged = 0;
Added = 0; Removed = 0; NotPresent = 0; Skipped = 0; Errors = 0
}Security Considerations#
The script runs under your identity via Connect-AzAccount. Whoever runs it gets write access to machine tags across the entire tenant. Know who has access.
Machine tags affect Defender device groups, which affect security policies. Changing a tag isn’t cosmetic – it can change what policies apply to a device. Always run with -WhatIf first, especially for removals.
The Defender API base URL varies by region. The script defaults to https://api.securitycenter.windows.com (global). Use -ApiBaseUrl to target EU (https://api-eu.securitycenter.microsoft.com) or GCC High (https://api-gcc.securitycenter.microsoft.us). If you use the wrong base URL for your tenant, you’ll get empty results or auth errors.
When to Use This / When Not To#
Use this when you need to tag or untag devices in bulk – offboarding, categorization, compliance grouping, or cleanup of deprecated tags. The -DeviceName parameter also makes it useful for quick one-off tagging without a CSV.
Don’t use this for dynamic, real-time tagging. If your tagging logic should run automatically when devices are onboarded or change state, build an Azure Function or Logic App that reacts to Defender events instead of running a batch script.