Microsoft Sentinel Watchlists: The Complete Guide to Dynamic Detection Lists
Microsoft Sentinel Watchlists: The Complete Operational Guide
📋 Table of Contents
Watchlists are one of Microsoft Sentinel’s most versatile and underutilised features. They act as a queryable reference store — bring in any CSV data (IP blocklists, asset inventories, VIP user lists, privileged account registers, network CIDR ranges) and reference it directly in KQL analytic rules, hunting queries, and playbooks.
Unlike static lookup tables baked into KQL queries, Watchlists are dynamic: update the CSV and every query that references it automatically uses the new data. No rule edits, no redeployments.
1. What Are Watchlists?
A Watchlist is a named dataset stored in your Sentinel workspace, created from a CSV file. Each row in the CSV becomes a queryable record. Sentinel makes the data available via the _GetWatchlist() function in KQL.
- Maximum size: up to 10 million rows per watchlist (using Log Analytics ingestion)
- Update frequency: can be updated manually or via API/Logic App automation
- Access: via
_GetWatchlist('WatchlistAlias')— returns a table with all columns from the original CSV - Cost: data stored in watchlists does not incur standard Log Analytics ingestion charges (stored separately)
Max Rows
Update Method
Access in KQL
Ingestion Cost
2. Core Use Cases
| Watchlist Name (example) | Content | Detection Use |
|---|---|---|
| BlockedIPs | Malicious IPs / IOC IPs | Alert when traffic seen from blocked IPs |
| CorporateRanges | Internal CIDR ranges | Detect external sign-ins (not in corporate IP ranges) |
| VIPUsers | Executive / privileged user UPNs | Priority alerting for VIP account anomalies |
| ServiceAccounts | Service account names | Alert on interactive logons by service accounts |
| AssetInventory | Computer names, owners, criticality | Enrich alerts with asset context |
| AllowedCountries | ISO country codes allowed for sign-in | Alert on sign-ins from unexpected countries |
| TrustedDomains | Approved external email domains | Detect email from non-whitelisted domains |
3. Creating and Managing Watchlists
Watchlists are created in the Sentinel portal under Configuration → Watchlists → New. You upload a CSV file and designate a Search Key — the primary lookup field (must be unique per row).
CSV format requirements
- First row must be the header row with column names
- Use UTF-8 encoding (no BOM)
- The Search Key column must contain unique values — it is indexed for fast lookups
- Maximum column name length: 500 characters
Example CSV: CorporateRanges watchlist
// CSV content (save as corporate_ranges.csv): // IPRange,Location,Owner // 10.0.0.0/8,HQ Data Centre,IT Operations // 172.16.0.0/12,Branch Offices,IT Operations // 192.168.0.0/16,VPN Pool,IT Operations // 203.0.113.0/24,Azure Egress London,Cloud Team
4. Querying Watchlists — All Correct Patterns
Pattern 1: Exact set membership (most common)
// Project only the SearchKey column, then use 'in' operator
let BlockedIPs = (_GetWatchlist('BlockedIPs') | project SearchKey);
CommonSecurityLog
| where TimeGenerated > ago(24h)
| where SourceIP in (BlockedIPs)
| project TimeGenerated, SourceIP, DestinationIP, Activity, DeviceVendor, DeviceProduct
Pattern 2: Multi-column lookup with join
// Join watchlist to enrich events with additional context columns
let VIPUsers = _GetWatchlist('VIPUsers')
| project SearchKey, DisplayName = Column1, Department = Column2;
SecurityAlert
| where TimeGenerated > ago(24h)
| extend AlertUser = tostring(parse_json(Entities)[0].AadUserId)
| join kind=leftouter (VIPUsers) on $left.AlertUser == $right.SearchKey
| extend IsVIP = isnotempty(DisplayName)
| project TimeGenerated, AlertName, AlertSeverity, AlertUser, IsVIP, DisplayName, Department
Pattern 3: CIDR range matching (critical — use correct syntax)
// Build a scalar dynamic array first — REQUIRED for ipv4_is_in_any_range
let CorpRanges = toscalar(
_GetWatchlist('CorporateRanges')
| summarize make_list(SearchKey) // SearchKey = IPRange column
);
SigninLogs
| where TimeGenerated > ago(24h)
| extend IsInternal = ipv4_is_in_any_range(IPAddress, CorpRanges)
| where not(IsInternal) // External sign-ins only
| summarize
ExternalCount = count(),
Countries = make_set(tostring(LocationDetails.countryOrRegion))
by UserPrincipalName, IPAddress
| order by ExternalCount desc
| where ipv4_is_in_any_range(IPAddress, VPNRanges) — where VPNRanges is a column from a joined table. This does not work. The second argument to ipv4_is_in_any_range must be a scalar dynamic array, not a column reference. Always use toscalar(... | summarize make_list(...)).
Pattern 4: Country allowlist (geo-blocking detection)
let AllowedCountries = (_GetWatchlist('AllowedCountries') | project SearchKey);
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| extend Country = tostring(LocationDetails.countryOrRegion)
| where Country !in (AllowedCountries)
| where isnotempty(Country)
| summarize Count = count() by UserPrincipalName, IPAddress, Country
| order by Count desc
Pattern 5: Service account interactive logon detection
let ServiceAccounts = (_GetWatchlist('ServiceAccounts') | project SearchKey);
SecurityEvent
| where TimeGenerated > ago(24h)
| where EventID == 4624
| where LogonType in (2, 10) // Interactive (2) and RemoteInteractive (10)
| where Account in~ (ServiceAccounts)
| project TimeGenerated, Account, Computer, IpAddress, LogonType
5. Automating Watchlist Updates
Static watchlists quickly become stale. For IOC-based watchlists especially, you need automated refresh from your threat intel feeds. Two common automation approaches:
Logic App automation
- Trigger: Recurrence (daily or on-demand)
- Action 1: HTTP call to your threat intel feed API to fetch new IOC list
- Action 2: Convert to CSV format
- Action 3: Call Microsoft Sentinel → Watchlists → Update action to replace the watchlist content
API-based update (Python)
// Watchlist update endpoint (Azure Management API):
// PUT https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}
// /providers/Microsoft.OperationalInsights/workspaces/{ws}
// /providers/Microsoft.SecurityInsights/watchlists/{alias}
// /watchlistItems/{itemId}?api-version=2022-11-01
//
// Body: { "properties": { "itemsKeyValue": { "SearchKey": "1.2.3.4", "Source": "ThreatFeed" } } }
6. Production-Ready Detection Examples
TOR exit node detection
// Assumes a watchlist 'TORExitNodes' updated daily from dan.me.uk/torlist
let TORNodes = (_GetWatchlist('TORExitNodes') | project SearchKey);
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| where IPAddress in (TORNodes)
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName,
Country = tostring(LocationDetails.countryOrRegion)
Privileged account baseline deviation
let PrivAccounts = (_GetWatchlist('PrivilegedAccounts') | project SearchKey);
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4624
| where Account in~ (PrivAccounts)
| extend HourOfDay = hourofday(TimeGenerated)
| where HourOfDay !between (7 .. 19) // After hours
| summarize
AfterHoursLogons = count(),
Computers = make_set(Computer)
by Account, IpAddress
| order by AfterHoursLogons desc
