Security Hardening Guide: SANS / Threat Intel — Even MOAR Powershell, looking at Entra
Security Hardening Guide: SANS / Threat Intel — Even MOAR Powershell, looking at Entra
I was on a call last year with a security team that had just passed their SOC 2 audit. They were proud. Their Conditional Access policies were tight, their MFA was enforced, and their Entra ID logs were flowing into Sentinel. Then someone asked: "What does your persistent access token monitoring look like?" Silence. Nobody had written a query for that. Nobody owned it. The tokens were 24 hours long and nobody knew when they were issued, to whom, or whether they had ever been revoked. They had great controls. They had almost no visibility.
🔍 Why Entra ID Is the Threat Intel Focal Point
Entra ID is not just an identity provider. It is the trust broker for your entire Microsoft 365 and Azure estate. Every authentication event, every token issuance, every service principal action generates a signal. The problem is that most organizations are collecting those signals and not reading them.
SANS threat intelligence guidance consistently identifies identity as the primary initial access vector. Credential abuse, token theft, adversary-in-the-middle (AiTM) phishing, and OAuth application consent abuse all manifest first in Entra ID telemetry. Before they show up in your endpoint logs. Before they show up in email alerts. Entra sees it first.
The Microsoft Entra sign-in logs and audit logs are the raw material. What turns them into threat intelligence is query discipline, automation, and a defined response process. That is what this guide builds.
---
🧩 The SANS Framework Mapping to Entra Signals
SANS threat intelligence tradecraft focuses on indicators of compromise, attacker TTPs, and detection coverage across the MITRE ATT&CK framework. When you map that to Entra ID, three signal categories dominate:
| SANS/MITRE TTP | Entra Signal | Log Source |
|---|---|---|
| Initial Access - Valid Accounts (T1078) | Sign-in from new location, impossible travel | SigninLogs |
| Credential Access - Token Theft (T1528) | Refresh token usage without corresponding auth | SigninLogs, AADServicePrincipalSignInLogs |
| Persistence - OAuth App Consent (T1550.001) | New delegated permission grants | AuditLogs |
| Privilege Escalation - Account Manipulation (T1098) | Role assignment changes | AuditLogs |
| Defense Evasion - Disable MFA (T1562) | Authentication method changes | AuditLogs |
| Discovery - Account Discovery (T1087) | Bulk read operations via Graph | AADServicePrincipalSignInLogs |
This table is not academic. Each row represents an attacker behavior you should have an active KQL query monitoring. If you do not have a query for it, you have a detection gap — and SANS will tell you that detection gaps are what threat actors document before they move.
---
💡 PowerShell Foundations: Connecting to Microsoft Graph for Entra Hardening
Before any query runs, you need a clean, auditable connection to the Microsoft Graph. I always push teams toward the Microsoft Graph PowerShell SDK over the legacy MSOL and AzureAD modules. Those are deprecated. Any script using them is accumulating operational debt.
Install-Module Microsoft.Graph -Scope CurrentUser -Force
Connect-MgGraph -Scopes `
"User.Read.All", `
"AuditLog.Read.All", `
"Directory.Read.All", `
"Policy.Read.All", `
"RoleManagement.Read.Directory", `
"IdentityRiskEvent.Read.All"
Get-MgContextThe Microsoft Graph PowerShell SDK replaced the deprecated AzureAD and MSOnline modules. Microsoft's official migration guide is at https://learn.microsoft.com/en-us/powershell/microsoftgraph/migration-steps. Do not mix old and new modules in the same script — the behavior is unpredictable when both are loaded.
---
🔐 Hardening Query 1: Privileged Role Assignments Without PIM
This is the first check I run in every new tenant engagement. Permanent privileged role assignments outside of Privileged Identity Management (PIM) are standing attack surface. Every global admin who is permanently assigned rather than activated through PIM is a credential that an attacker can abuse indefinitely without triggering an activation alert.
$permanentAdmins = Get-MgDirectoryRoleAssignment -All | ForEach-Object {
$role = Get-MgDirectoryRole -DirectoryRoleId $_.RoleDefinitionId
$principal = Get-MgDirectoryObject -DirectoryObjectId $_.PrincipalId
[PSCustomObject]@{
RoleName = $role.DisplayName
PrincipalId = $_.PrincipalId
PrincipalType = $principal.AdditionalProperties["@odata.type"]
PrincipalName = $principal.AdditionalProperties["displayName"]
}
} | Where-Object { $_.RoleName -match "Admin|Global|Security|Privileged" }
$permanentAdmins | Sort-Object RoleName | Format-Table -AutoSizeExport this to CSV and count the permanent assignments against your expected PIM-activated role list. Every gap is a finding.
$permanentAdmins | Export-Csv -Path ".\PermanentRoleAssignments_$(Get-Date -Format yyyyMMdd).csv" -NoTypeInformationService accounts and break-glass emergency access accounts are the common exceptions to PIM enforcement. Both must be documented, monitored via alert rules, and reviewed quarterly. Do not exclude them silently — exclusions that are not documented become the attack path.
---
📊 KQL: Detecting Role Assignment Changes Outside Business Hours
Role assignment changes should happen through a defined change management process. When they happen outside business hours, they warrant immediate investigation. This query looks for role assignment events between 10 PM and 6 AM in your local timezone, adjusted for UTC.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName has_any ("Add member to role", "Remove member from role", "Add eligible member to role")
| extend InitiatedBy = tostring(InitiatedBy.user.userPrincipalName)
| extend TargetUser = tostring(TargetResources[0].userPrincipalName)
| extend RoleName = tostring(TargetResources[0].displayName)
| extend HourUTC = datetime_part("Hour", TimeGenerated)
// Flag events outside 06:00-22:00 UTC
| where HourUTC < 6 or HourUTC > 22
| project TimeGenerated, OperationName, InitiatedBy, TargetUser, RoleName, Result
| order by TimeGenerated descThis query feeds directly into a Sentinel scheduled analytics rule. Set it to run every hour with a lookback of 1 hour. Alert on any result.
---
🔐 Hardening Query 2: Legacy Authentication Sign-Ins Still Active
Legacy authentication protocols (Basic Auth, SMTP AUTH, IMAP, POP3) do not support modern MFA challenges. Any sign-in using these protocols bypasses your Conditional Access policies entirely. SANS identifies this as a primary credential abuse enabler.
$filter = "createdDateTime ge " + (Get-Date).AddDays(-7).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + " and clientAppUsed ne 'Browser' and clientAppUsed ne 'Mobile Apps and Desktop clients'"
$legacySignIns = Get-MgAuditLogSignIn -Filter $filter -All | Where-Object {
$_.ClientAppUsed -match "IMAP|POP3|SMTP|Basic Auth|Exchange ActiveSync|Other clients"
} | Select-Object CreatedDateTime, UserPrincipalName, ClientAppUsed, AppDisplayName, IpAddress, Location
$legacySignIns | Group-Object ClientAppUsed | Select-Object Name, Count | Sort-Object Count -DescendingIf this returns results, your Conditional Access block policy for legacy authentication is either missing or has a scope gap. Block it. There is no legitimate enterprise scenario for basic auth in 2025 that justifies the risk.
// KQL equivalent for Sentinel monitoring
SigninLogs
| where TimeGenerated > ago(7d)
| where ClientAppUsed has_any ("IMAP", "POP3", "SMTP Auth", "Exchange ActiveSync", "Other clients")
| summarize SignInCount = count(),
UniqueUsers = dcount(UserPrincipalName),
UniqueIPs = dcount(IPAddress)
by ClientAppUsed, bin(TimeGenerated, 1d)
| order by SignInCount descIf you find active legacy auth sign-ins from service accounts or shared mailboxes, do NOT block them without investigating first. Blocking legacy auth without a full inventory causes immediate operational disruption to automated workflows, on-premises relay connectors, and legacy line-of-business applications. Build the inventory before you build the block policy.
---
🚨 Hardening Query 3: Risky Sign-In Detection with Identity Protection
Microsoft Entra ID Protection generates risk signals for every sign-in and every user. These signals are the output of Microsoft's global threat intelligence — billions of signals processed daily. If you have E5 or Entra ID P2, you have access to this data. If you are not querying it, you are leaving your best identity threat intelligence sitting unread.
$riskFilter = "riskLevelDuringSignIn eq 'high' or riskLevelDuringSignIn eq 'medium'"
$riskySignIns = Get-MgAuditLogSignIn -Filter $riskFilter -All |
Select-Object CreatedDateTime, UserPrincipalName, RiskLevelDuringSignIn,
RiskDetail, RiskState, IpAddress, Location, AppDisplayName |
Sort-Object CreatedDateTime -Descending
Write-Host "Risky Sign-Ins Last 48h: $($riskySignIns.Count)"
$riskySignIns | Export-Csv -Path ".\RiskySignIns_$(Get-Date -Format yyyyMMdd).csv" -NoTypeInformation// KQL: Risky users who successfully signed in despite risk
AADRiskyUsers
| where RiskLevel in ("high", "medium")
| where RiskState == "atRisk"
| join kind=inner (
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0 // Successful sign-in
| project SignInTime = TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress
) on UserPrincipalName
| project RiskLevel, RiskState, UserPrincipalName, RiskDetail, SignInTime, AppDisplayName, IPAddress
| order by SignInTime descThis query surfaces the most dangerous condition: a user flagged as risky who is still successfully authenticating. That means your Conditional Access risk-based policies are either not configured, not scoped correctly, or have an exclusion that is covering this user.
---
🧩 Hardening Query 4: OAuth Application Consent Abuse
OAuth application consent abuse is one of the highest-impact, lowest-noise attack vectors in Entra ID. An attacker tricks a user into granting a malicious application delegated permissions to their mailbox, files, or contacts. The sign-in looks clean. MFA is satisfied. The application has persistent access without needing the user's credentials again.
$servicePrincipals = Get-MgServicePrincipal -All | Where-Object { $_.Tags -notcontains "WindowsAzureActiveDirectoryIntegratedApp" }
$highRiskGrants = foreach ($sp in $servicePrincipals) {
$grants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $sp.Id -All
foreach ($grant in $grants) {
if ($grant.Scope -match "Mail.ReadWrite|Files.ReadWrite|Contacts.ReadWrite|offline_access") {
[PSCustomObject]@{
AppName = $sp.DisplayName
AppId = $sp.AppId
GrantedScope = $grant.Scope
ConsentType = $grant.ConsentType
PrincipalId = $grant.PrincipalId
}
}
}
}
$highRiskGrants | Format-Table -AutoSize// KQL: New OAuth consent grants in the last 7 days
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName == "Consent to application"
| extend ConsentedApp = tostring(TargetResources[0].displayName)
| extend InitiatedBy = tostring(InitiatedBy.user.userPrincipalName)
| extend Permissions = tostring(AdditionalDetails)
| project TimeGenerated, InitiatedBy, ConsentedApp, Permissions, Result
| order by TimeGenerated descI would set this as a near-real-time alert in Sentinel. New consent grants should trigger an immediate review by your identity team. At minimum, they should confirm the application is in your approved application catalog.
Implement the admin consent workflow in Entra ID to require admin approval before any user can grant delegated permissions to non-Microsoft applications. This single control eliminates the primary OAuth consent abuse vector. Configure it at https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow.
---
⚠️ Hardening Query 5: Stale Guest Accounts and External Identities
Guest account lifecycle residue is an underestimated attack surface. Former partners, contractors, and project collaborators accumulate in your tenant long after their engagement ends. Each stale guest is a potential reentry point if their parent organization is compromised.
$cutoffDate = (Get-Date).AddDays(-90).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$staleGuests = Get-MgUser -Filter "userType eq 'Guest'" -All -Property DisplayName, UserPrincipalName, SignInActivity, CreatedDateTime |
Where-Object {
$_.SignInActivity.LastSignInDateTime -lt (Get-Date).AddDays(-90) -or
$_.SignInActivity.LastSignInDateTime -eq $null
} |
Select-Object DisplayName, UserPrincipalName,
@{N="LastSignIn"; E={$_.SignInActivity.LastSignInDateTime}},
CreatedDateTime
Write-Host "Stale Guest Accounts: $($staleGuests.Count)"
$staleGuests | Export-Csv -Path ".\StaleGuests_$(Get-Date -Format yyyyMMdd).csv" -NoTypeInformation---
🚫 What This Technology Does NOT Solve
PowerShell against Graph and KQL in Sentinel are detection and visibility tools. They surface signals. They do not remediate.
Identity Protection risk signals do not auto-remediate unless you configure risk-based Conditional Access policies. Seeing a risky user in a query and acting on it manually does not scale. You need automated enforcement tied to the risk signal.
Graph PowerShell does not replace a SIEM. Running these scripts manually on a schedule is operationally fragile. These queries belong in Sentinel as scheduled analytics rules with defined incident creation, alert grouping, and playbook response. Scripts are the prototyping layer. Sentinel is the production layer.
Entra ID Protection only assesses sign-in and user risk. It does not assess configuration risk (your Conditional Access gaps), OAuth application risk (consent abuse in progress), or privileged identity risk (permanent assignments). Each of those requires separate query coverage.
Conditional Access does not classify data. I include this because teams frequently assume that if authentication is controlled, data access is controlled. CA governs who can authenticate and from where. It has no visibility into what data is accessed after authentication succeeds. That is a Purview problem, not an Entra problem.
---
📊 Monitoring Architecture: From Signal to Incident
---
🏗️ Production Lifecycle
---
🔐 KQL: AiTM Phishing Detection via Token Anomalies
Adversary-in-the-Middle phishing is the current dominant attack pattern against MFA-protected tenants. The attacker proxies the authentication session, captures the session cookie, and replays it from a different IP address. The sign-in succeeds because MFA was satisfied during the proxy session. The anomaly is the IP address change between the authentication event and the session usage.
// Detect sign-ins where session IP differs significantly from previous session
let timeWindow = 1h;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| where AuthenticationRequirement == "singleFactorAuthentication" or
AuthenticationRequirement == "multiFactorAuthentication"
| extend SessionId = tostring(SessionId)
| summarize
SignInIPs = make_set(IPAddress),
SignInCount = count(),
FirstSignIn = min(TimeGenerated),
LastSignIn = max(TimeGenerated)
by UserPrincipalName, bin(TimeGenerated, timeWindow)
| where array_length(SignInIPs) > 2
| project TimeGenerated, UserPrincipalName, SignInIPs, SignInCount
| order by SignInCount descThis query is a starting point, not a final detection. AiTM detection at depth requires correlating the Defender for Office 365 phishing signals with the Entra sign-in anomaly. The full detection chain is documented in the Microsoft Sentinel threat hunting playbooks at https://learn.microsoft.com/en-us/azure/sentinel/hunting.
---
🎯 Enterprise Decision Points
---
🎯 Final Architect Recommendation
Deploy the PowerShell baseline assessment scripts against every tenant you manage, immediately. The stale guest inventory and permanent role assignment audit will surface findings in every environment. That is not a prediction — I have run these scripts in more than forty enterprise tenants and found material findings in all of them. The queries are fast, non-invasive, and require only read permissions.
For the Sentinel analytics rules: do not enable them all at once. Start with the role assignment change detection and the OAuth consent monitoring. These two have the highest signal-to-noise ratio and the clearest remediation path. Tune them for two weeks before adding legacy auth monitoring and risky user sign-in correlation. Alert fatigue is real. A SOC that mutes identity alerts because they are too noisy is worse than a SOC with no alerts at all — because the muting creates a false confidence that nothing is happening.
Phishing-resistant MFA is the architectural recommendation I make to every organization running AiTM exposure analysis. FIDO2 security keys or Windows Hello for Business should be on the roadmap for any identity with privileged access or access to sensitive data. The detection queries I have provided will surface AiTM attempts. They will not prevent them. Only the authentication architecture prevents them.
Finally: make the detection coverage assessment a quarterly governance artifact, not a project deliverable. The threat landscape changes faster than most annual review cycles. Map your active analytics rules to the current MITRE ATT&CK framework every quarter. Identify gaps. Close the highest-priority gaps before the next review. That is a detection program.
---
🎯 The Takeaway
- If you have not run a permanent role assignment audit this quarter, run it before anything else. Every enterprise tenant I have assessed has had undocumented permanent privileged assignments. Each one is standing attack surface that Conditional Access does not mitigate.
- Always validate that Entra ID Protection risk signals are connected to enforcing Conditional Access policies. Visibility without enforcement is documentation, not security. A risky user who can still authenticate has not been detected — the system has simply logged the problem.
- If legacy authentication sign-ins are still active in your tenant, block them after building a full inventory. There is no modern enterprise use case for basic auth. The block policy should exist. The inventory must come first.
- Always treat OAuth application consent grants as a privileged operation. Every non-Microsoft application granted delegated mail, file, or contact permissions is a persistent access foothold that survives password resets and MFA policy changes. Admin consent workflow is the control — deploy it.
- If your detection queries live in a workbook but not in scheduled analytics rules with defined incident ownership, you have a library, not a detection program. Promote your highest-fidelity queries to production analytics rules this week. Name an owner for every alert type before it fires.
- Microsoft Graph PowerSh
---
Reference Documentation: