In this module

EI1.8 KQL Fundamentals for Identity Security

60-80 minutes · Module 1 · Free
Operational Objective
You need to query the sign-in logs to verify a conditional access policy, detect a suspicious pattern, or build a baseline — but you are not yet fluent in KQL. The Mastering KQL course provides comprehensive KQL training, but you need the core patterns now, specifically for identity security data. This subsection teaches the essential KQL operators you will use in every subsequent module, applied directly to the SigninLogs and related identity tables.
Deliverable: Working fluency with the KQL operators most commonly used for identity security: where, project, extend, summarize, parse_json, mv-expand, let, and the time functions. Enough to write and modify the queries in every subsequent module of this course.
⏱ Estimated completion: 20 minutes
OPERATIONAL FLOW Input Process Analyse Decide Output

Figure EI1.8 — Operational workflow from input through documented output.

Figure — KQL Fundamentals for Identity Security.

The operators you will use most

KQL (Kusto Query Language) is a pipe-based query language — you start with a table name and pipe the data through a sequence of operators that filter, transform, and aggregate. If you have used PowerShell pipelines or Unix command-line tools, the concept is familiar. If not, the mental model is: data flows from left to right (top to bottom in a multi-line query), and each operator transforms the data before passing it to the next.

This subsection covers the operators that appear in identity security queries most frequently. The Mastering KQL for Cybersecurity course covers the full language in depth — if you want comprehensive KQL training, take that course alongside this one. For the purposes of this course, the operators below are sufficient.

where — filtering rows

The where operator filters rows based on a condition. Every identity security query starts with a where clause that scopes the data to the relevant time window and conditions.

// Filter to successful sign-ins in the last 24 hours
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0

The where operator supports all standard comparison operators: == (equals), != (not equals), >, <, >=, <=. For string matching, == is case-sensitive, =~ is case-insensitive, has checks if a string contains a word (word-boundary-aware), contains checks if a string contains a substring (no word boundary), and startswith / endswith check prefixes and suffixes.

// has vs contains — practical difference
SigninLogs
| where AppDisplayName has "Exchange"
// Matches: "Office 365 Exchange Online" (word boundary match)
// Does NOT match: "DataExchange" (no word boundary)

SigninLogs
| where AppDisplayName contains "Exchange"
// Matches both: "Office 365 Exchange Online" AND "DataExchange"
// Usually not what you want — use has instead
// Filter for specific applications
SigninLogs
| where AppDisplayName in ("Office 365 Exchange Online", 
    "Office 365 SharePoint Online", "Microsoft Teams")
Expand for Deeper Context

For identity security, has is almost always preferable to contains because it is faster (uses the index) and more precise (matches word boundaries). Use contains only when you need substring matching.

The in operator checks membership in a list — essential for multi-value filtering:

project — selecting columns

The project operator selects which columns to include in the output and optionally renames them. Use it to reduce output to the fields you need and create readable column names.

SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| project 
    Time = TimeGenerated,
    User = UserPrincipalName,
    App = AppDisplayName,
    IP = IPAddress,
    Risk = RiskLevelDuringSignIn,
    CA = ConditionalAccessStatus

project-away removes specific columns while keeping everything else — useful when you want most fields but need to drop a few verbose ones. project-rename renames columns without dropping any.

extend — adding calculated columns

The extend operator adds new columns based on expressions. This is how you parse JSON fields, calculate derived values, and create classification labels.

// Parse JSON and create classification columns
SigninLogs
| where TimeGenerated > ago(24h)
| extend 
    Country = tostring(LocationDetails.countryOrRegion),
    City = tostring(LocationDetails.city),
    DeviceOS = tostring(DeviceDetail.operatingSystem),
    IsCompliant = tostring(DeviceDetail.isCompliant)
| extend TrustLevel = case(
    IsCompliant == "true", "High",
    Country in ("US", "GB"), "Medium",
    "Low"
    )

The case() function is the KQL equivalent of if/else — it evaluates conditions in order and returns the value for the first match. Essential for creating security classifications.

The tostring() function converts a value to a string — required when accessing nested JSON fields like LocationDetails.countryOrRegion because KQL treats the extracted value as a dynamic type by default.

summarize — aggregating data

The summarize operator groups rows and calculates aggregate values. This is the operator that turns thousands of sign-in events into actionable summaries.

// Count sign-ins per user, per application
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| summarize 
    SignInCount = count(),
    DistinctApps = dcount(AppDisplayName),
    DistinctIPs = dcount(IPAddress),
    Countries = make_set(tostring(LocationDetails.countryOrRegion), 5)
    by UserPrincipalName
| order by SignInCount desc

The aggregate functions you will use most: count() counts rows in each group, dcount() counts distinct values (approximate), make_set() creates an array of unique values, min() and max() find the earliest/latest timestamp or smallest/largest value, and any() returns an arbitrary value from the group (useful when you just need a sample).

// Hourly sign-in volume — detect spikes or gaps
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| summarize SignInCount = count() by bin(TimeGenerated, 1h)
| order by TimeGenerated asc
// Render as a time chart to visualize the pattern
// | render timechart
Expand for Deeper Context

The by clause specifies the grouping columns — like SQL's GROUP BY. You can group by multiple columns: by UserPrincipalName, AppDisplayName creates a group for each user+app combination.

Time-based grouping uses bin(): by bin(TimeGenerated, 1h) groups events into one-hour buckets, producing a time-series that shows patterns over time.

parse_json and mv-expand — working with arrays

Many sign-in log fields contain JSON arrays that must be parsed before they can be queried. The two operators you need are parse_json() (converts a string to a JSON object) and mv-expand (expands an array into individual rows).

You have already seen these in EI1.5 (conditional access evaluation):

// Parse and expand the ConditionalAccessPolicies array
SigninLogs
| mv-expand CAPolicy = parse_json(ConditionalAccessPolicies)
| extend PolicyName = tostring(CAPolicy.displayName)
| extend PolicyResult = tostring(CAPolicy.result)

And in EI1.4 (authentication details):

// Parse the AuthenticationDetails array
SigninLogs
| extend AuthDetail = parse_json(AuthenticationDetails)
| extend PrimaryMethod = tostring(AuthDetail[0].authenticationMethod)
| extend MFAMethod = tostring(AuthDetail[1].authenticationMethod)

The key difference: mv-expand creates one row per array element (expanding the result set), while array indexing (AuthDetail[0]) extracts a specific element by position without expanding. Use mv-expand when you want to analyze each array element individually (like each CA policy evaluation). Use array indexing when you know the position of the element you want (like the first and second authentication steps).

let — reusable variables and subqueries

The let operator defines named variables or subqueries that can be referenced later in the query. Essential for building complex detection queries that compare current activity against a baseline.

// Define a time range and a list of high-risk apps
let timeRange = 24h;
let sensitiveApps = dynamic(["Azure Portal", "Microsoft Graph Explorer", 
    "Azure Active Directory PowerShell"]);
SigninLogs
| where TimeGenerated > ago(timeRange)
| where AppDisplayName in (sensitiveApps)
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress

let is also used to define subqueries whose results are joined with the main query — the pattern used in the token replay detection query in EI1.2 and the service principal baseline query in EI1.3.

Time functions

Identity security queries constantly work with time — comparing sign-in timestamps, calculating durations, and detecting time-based anomalies.

ago() calculates a time relative to now: ago(24h) is 24 hours before the current time, ago(7d) is 7 days, ago(30m) is 30 minutes.

// Calculate time between consecutive sign-ins for the same user
// Useful for detecting rapid sign-in attempts (spray or brute force)
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType != 0  // Failed sign-ins
| order by UserPrincipalName, TimeGenerated asc
| extend PrevTime = prev(TimeGenerated, 1)
| extend PrevUser = prev(UserPrincipalName, 1)
| where UserPrincipalName == PrevUser
| extend GapSeconds = datetime_diff('second', TimeGenerated, PrevTime)
| where GapSeconds < 5  // Less than 5 seconds between failures
| project TimeGenerated, UserPrincipalName, IPAddress, GapSeconds
// Results: rapid-fire failed sign-ins — likely automated attack
Expand for Deeper Context

between() filters a time range: where TimeGenerated between (ago(48h) .. ago(24h)) selects events from 48 to 24 hours ago (yesterday only).

datetime_diff() calculates the difference between two timestamps: datetime_diff('minute', Timestamp2, Timestamp1) returns the number of minutes between two events — the foundation for impossible travel detection.

bin() rounds timestamps to intervals for grouping: bin(TimeGenerated, 1h) rounds to the nearest hour, enabling time-series analysis.

Data type handling and common pitfalls

KQL is strongly typed, and the sign-in log fields contain a mix of types that trip up new query writers. Here are the most common issues and how to handle them:

Dynamic type fields — LocationDetails, DeviceDetail, ConditionalAccessPolicies, and AuthenticationDetails are all dynamic type (JSON). You must use tostring() to extract values for comparison: tostring(LocationDetails.countryOrRegion) not just LocationDetails.countryOrRegion. Without tostring(), comparisons against string literals may fail silently — the query returns no results without an error.

Expand for Deeper Context

Empty vs null — some fields are empty strings (""), some are null, and some are not present at all. Use isnotempty() and isnotnull() defensively. For example, where isnotempty(tostring(LocationDetails.countryOrRegion)) filters out sign-ins where the country could not be determined from the IP address. If you skip this filter, your aggregations include blank entries that distort the results.

Numeric ResultType — ResultType is stored as an integer. Compare it with == (numeric equality), not has or contains (string operations). where ResultType == 0 is correct. where ResultType has "0" will not work as expected and may match other codes containing the digit 0.

Case sensitivity — string comparisons with == are case-sensitive. UPNs in Entra ID are case-insensitive but may be stored with varying capitalization. Use =~ for case-insensitive comparison when filtering by UPN: where UserPrincipalName =~ "admin@domain.com".

The prev() function — used in the rapid sign-in detection query above — requires the data to be sorted first. Always use order by before prev() or next(), and verify the grouping: prev() looks at the previous row in the result set regardless of which user it belongs to. The where UserPrincipalName == PrevUser filter ensures you only compare consecutive events for the same user.

Complete worked example: investigating a suspicious sign-in

Here is a complete multi-step query that demonstrates how the operators combine in a real investigation scenario. The scenario: you received an alert for a medium-risk sign-in for a user. You need to quickly assess the sign-in, determine what the user did afterward, and decide whether to escalate.

// Complete investigation query: risky sign-in + post-auth activity
// Step 1: Find the risky sign-in
let targetUser = "casey.finance@yourdomain.onmicrosoft.com";
let riskySignIn = SigninLogs
| where TimeGenerated > ago(24h)
| where UserPrincipalName =~ targetUser
| where RiskLevelDuringSignIn in ("medium", "high")
| where ResultType == 0
| project SignInTime = TimeGenerated, IPAddress, 
    Country = tostring(LocationDetails.countryOrRegion),
    DeviceOS = tostring(DeviceDetail.operatingSystem),
    AppDisplayName, RiskLevelDuringSignIn,
    RiskEventTypes_V2,
    AuthMethod = tostring(parse_json(AuthenticationDetails)[0].authenticationMethod),
    CAStatus = ConditionalAccessStatus;
// Step 2: Get the sign-in context
riskySignIn
| extend InvestigationNotes = case(
    Country !in ("US", "GB"), strcat("ALERT: Unexpected country - ", Country),
    AuthMethod == "Password", "WARNING: Password auth used (phishing-capable)",
    CAStatus == "notApplied", "CRITICAL: No CA policy evaluated",
    "Context appears normal — review additional activity"
    )
// This produces the initial assessment in a single query
// Run the INVEST-02 and INVEST-03 queries from EI1.12 for follow-up

This query combines filtering, JSON parsing, conditional classification, and projection into a single investigation step. The case() function at the end produces an automated initial assessment note — not a replacement for human judgment, but a starting point that highlights the most concerning aspects of the sign-in.

Try it yourself

Try It — Build Your First Identity Query from Scratch

Environment: Your M365 developer tenant with Sentinel workspace.

Exercise: Without copying from the examples above, write a KQL query from scratch that answers this question: "In the last 7 days, which users signed in successfully from more than one country?"

The query should: 1. Start with SigninLogs 2. Filter to the last 7 days and successful sign-ins 3. Extract the country from LocationDetails 4. Count distinct countries per user 5. Filter to users with more than 1 country 6. Show the user, the countries, and the sign-in count

If you get stuck, refer to the summarize, extend, make_set, and dcount examples above. The answer is not provided here — building the query yourself is the learning exercise. Check your results against the EI1.9 baseline building subsection which uses this same pattern.

⚠ Compliance Myth: "We do not need KQL — we use the portal for log analysis"

The myth: The Entra admin center provides a GUI for browsing sign-in logs. We can filter by user, application, status, and date range. We do not need to learn a query language.

The reality: The portal GUI is useful for investigating individual sign-in events — clicking through the details of a specific sign-in for a specific user. It is completely inadequate for security operations at scale. You cannot correlate sign-ins across users (detecting spray patterns), calculate baselines (establishing normal behavior), join multiple tables (combining sign-in data with audit data), create time-series (identifying trends), or build automated detection rules (Sentinel analytics). Every detection rule in EI13 is a KQL query. Every verification step in the Defense Design Method is a KQL query. Every operational monitoring procedure in EI14 runs KQL queries. KQL is not optional for identity security operations.

Decision point

You are reviewing NE's Entra ID security posture. You find 4 accounts with Global Administrator role, but NE's policy says maximum 2. The extra 2 were added during the AiTM incident for emergency response and never removed. Do you remove them?

Remove them — but through the proper process, not unilaterally. Notify the account owners that their emergency GA assignment is being revoked, confirm they have their standard role assignments restored, and document the removal with the rationale ('emergency assignment during INC-NE-2026-0227-001, no longer required'). Then add a PIR action item: 'Implement PIM time-limited role assignments for future incident response — emergency GA assignments auto-expire after 8 hours rather than persisting indefinitely.' The stale emergency assignment is a governance failure, not a technical failure — the fix is procedural.

NE's Entra ID security audit reveals: 4 Global Administrators (policy says 2), 23 users with Global Reader from a completed project, a break-glass account with no monitoring rule, and 3 guest accounts with no expiry date. Which finding is the highest priority?
The 4 Global Administrators — 2 extra GAs doubles the attack surface.
The break-glass account with no monitoring rule. The 4 GAs and stale Global Readers are governance issues that should be remediated — but they are existing conditions, not active threats. The unmonitored break-glass account is a critical detection gap: if the break-glass account is compromised or misused, the SOC has no alert. A break-glass account is excluded from CA policies by design — it is the most powerful and least restricted account in the tenant. Without monitoring, its compromise or misuse is invisible. Deploy the monitoring rule (any sign-in to the break-glass account = Severity 1 alert) before addressing the other findings.
The 23 stale Global Readers — this is the largest number of affected accounts.
The 3 guest accounts — external accounts without expiry are the highest risk.

You've mapped the identity threat landscape and learned to read sign-in logs.

EI0 established that every cloud attack starts with identity. EI1 took you through the signal that matters most — interactive, non-interactive, service principal, and managed identity sign-ins. Now you engineer the defences.

  • 17 engineering modules — authentication methods, conditional access architecture, Identity Protection, PIM, token protection, application governance, and detection rules
  • The Defense Design Method — the six-step framework applied to every identity control you'll build
  • EI18 Capstone — Identity Security Architecture Design — design complete identity architectures for three realistic organisations (SMB, mid-market, regulated enterprise)
  • Identity Security Toolkit lab pack — deployable conditional access policies, PIM configurations, and Identity Protection risk rules
  • Cross-domain detection (EI16) — email-to-identity correlation and the full phishing-to-inbox-rule attack chain
Unlock the full course with Premium See Full Syllabus