Get the Zoom link
Cloud Security Office Hours Banner

Kusto Query Language (KQL), hands-on

KQL is how you question data in Microsoft Sentinel, Microsoft Defender XDR and Azure Data Explorer. Build a local copy of Sentinel's AWSCloudTrail table, follow a leaked access key through it, and learn to recognise a query that returns nothing because it cannot work.

Jump to the walk-through All how-to guides

· · Vendor-neutral

Time: ~2 hours  ·  Difficulty: Beginner  ·  You need: Docker with 4 GB of memory to spare and several gigabytes of disk, and Python 3. No Azure account. On an Apple silicon Mac, turn on Docker Desktop's Rosetta setting first (step 1); without Docker, use a free Azure Data Explorer cluster.

How this page was checked: every command and query was run as printed against the Kusto emulator (Azure Data Explorer engine 1.0.9766.21061, the latest image of 27 September 2026) in Docker Desktop on an Apple silicon Mac under Rosetta, and every output is pasted from that run. Not run: the free-cluster route, and the analytics rule inside a Microsoft Sentinel workspace. The rule's query runs here like every other, and the rule file uses only fields that Microsoft's own published CloudTrail rules use.

One query language across Microsoft's security data

Kusto is the query engine behind Azure Data Explorer, and KQL is the language you use to ask it questions. The same language runs in Azure Monitor Logs, in Microsoft Sentinel (which keeps its data in Log Analytics workspaces), in advanced hunting in Microsoft Defender XDR, and in Microsoft Fabric. Analysts in a security operations centre (SOC) write it to hunt through logs; detection engineers write it so that a security information and event management (SIEM) system can run it on a schedule and raise alerts.

The distinction that matters most: a query only reads. It names a table, reshapes it and hands back a table, and it cannot change any data. A management command starts with a dot (.create, .ingest, .show) and changes or inspects the database itself. Commands belong to Azure Data Explorer and Fabric, which is why this lab can use a few of them to build its table. In Sentinel you never create AWSCloudTrail: Sentinel's Amazon Web Services (AWS) connectors fill it, and you write only queries.

So the lab runs on the Kusto emulator, the Azure Data Explorer engine in a free Docker container, and builds a table with Sentinel's name for it, using Sentinel's column names and types. Every query operator and function used here is one Microsoft documents as available in Sentinel, so what you write carries across.

On this page

  1. Tables in, tables out
  2. Walk-through: one leaked key, five detections
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

Tables in, tables out

A KQL query starts with the name of a table. Each line after that begins with a pipe, |, and one operator. The pipe hands the table so far to the operator; the operator does one job, such as dropping rows, adding a column or counting, and hands a new table on. The table you named is never changed, and whatever leaves the last operator is your result.

AWSCloudTrail                                       // all 24 events
| where isnotempty(ErrorCode)                       // the 10 that failed
| summarize Failures = count() by SourceIpAddress   // one row per address
| sort by Failures desc                             // biggest first
 SourceIpAddress | Failures
-----------------+----------
 203.0.113.50    | 7
 192.0.2.20      | 3
(2 rows)

Read it as four tables in a row (you can run it yourself once step 3 has built the table). Every prefix of a query is itself a complete query, so the quickest way to debug one is to delete operators from the bottom and run it again until the table stops surprising you. Two rules before you start: everything is case-sensitive (table names, column names, operators, functions), and // starts a comment.

Walk-through: one leaked key, five detections

The lab is 24 AWS CloudTrail events from one morning, trimmed to the fields this guide needs but keeping CloudTrail's real field names and nesting. The access key of build-bot, a continuous integration (CI) user, has leaked: from 203.0.113.50 someone checks whose key it is, tries to list Identity and Access Management (IAM) users, Elastic Compute Cloud (EC2) instances and Secrets Manager secrets (and is mostly refused), then lists the Simple Storage Service (S3) buckets. Next they create a user, backup-svc, attach AdministratorAccess to it, give it an access key, and use the new identity to stop CloudTrail logging. Around that runs an ordinary morning: sign-ins, one of them without multi-factor authentication (MFA) and one failed; an administrator creating a service account; an application refused the same S3 object three times; a CI role describing instances.

The walk-through builds five detections from these events:

1. Start a Kusto engine

The Kusto emulator is Microsoft's Azure Data Explorer engine packaged as a Linux container, free and needing no Azure account. Microsoft supports it on Windows and Linux computers with an x86-64 processor and at least 2 GB of memory (4 GB recommended), not on Arm processors, and the image is several gigabytes. Setting ACCEPT_EULA=Y accepts Microsoft's licence terms for the emulator, so read them first; without it the container prints EULA Not Accepted and exits.

mkdir -p ~/kql-lab && cd ~/kql-lab
docker run -d --name lab-kusto --platform linux/amd64 -e ACCEPT_EULA=Y -m 4G \
  -p 8080:8080 mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest

On an Apple silicon Mac the engine runs under emulation, which Microsoft does not support. This page was checked that way, on macOS with Docker Desktop's Use Rosetta for x86_64/amd64 emulation on Apple Silicon setting turned on (Settings, General), and every step ran. It was not tried without that setting. --platform linux/amd64 asks for the only build Microsoft publishes, so Docker does not warn that it differs from your machine; on an x86-64 computer it changes nothing.

The emulator has no screen of its own. It answers on port 8080 with no sign-in and no encryption, so keep it on your own machine. Microsoft's clients (Kusto.Explorer, Kusto.CLI, the software development kits) can connect to it; to stay within Python's standard library, save this small client instead:

cat > kql.py <<'EOF'
# kql.py: send one query, or one command (it starts with a dot), to the
# emulator and print the result as a table. Usage: python3 kql.py < file.kql
import json, sys, urllib.request, urllib.error
text = sys.stdin.read().strip()
url = "http://localhost:8080/v1/rest/" + ("mgmt" if text.startswith(".") else "query")
body = json.dumps({"db": "NetDefaultDB", "csl": text}).encode()
headers = {"Content-Type": "application/json", "Accept": "application/json"}
try:
    table = json.load(urllib.request.urlopen(urllib.request.Request(url, body, headers)))["Tables"][0]
except urllib.error.HTTPError as err:       # the engine refused: say why
    sys.exit(json.load(err)["error"]["@message"])
except urllib.error.URLError as err:        # nothing listening (yet)
    sys.exit(f"Cannot reach the emulator: {err.reason}")
show = lambda v: "" if v is None else v if isinstance(v, str) else json.dumps(v, separators=(",", ":"))
rows = [[c["ColumnName"] for c in table["Columns"]]] + [[show(v) for v in r] for r in table["Rows"]]
width = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
for n, row in enumerate(rows):
    print((" " + " | ".join(v.ljust(w) for v, w in zip(row, width))).rstrip())
    if n == 0:
        print("+".join("-" * (w + 2) for w in width))
print(f"({len(rows) - 1} row{'' if len(rows) == 2 else 's'})")
EOF

To run anything on this page, save it in a file and run python3 kql.py < file.kql, or run python3 kql.py, paste, and press Ctrl-D. Check that the engine answers (if kql.py cannot reach it yet, give it a few more seconds):

.show version
 BuildVersion   | BuildTime            | ServiceType | ProductVersion                      | ServiceOffering
----------------+----------------------+-------------+-------------------------------------+-----------------
 1.0.9766.21061 | 2026-09-27T11:42:02Z | Engine      | 2026.09.27.1134-2638-cb0a545-master |
(1 row)

Your build will be newer if Microsoft has published one since. The emulator starts with one database, NetDefaultDB, and kql.py sends everything there.

No Docker, or the emulator will not start? Create a free Azure Data Explorer cluster. It needs a Microsoft account or a Microsoft Entra user identity, not an Azure subscription or a credit card, and Microsoft grants one free cluster for a year, for commercial or non-commercial use, with about 100 GB of storage. Run each KQL block on this page in its web query editor instead of through kql.py; step 2 notes the one difference.

2. Load the lab data

cat > cloudtrail-lab.jsonl <<'EOF'
{"eventTime":"2026-09-15T08:55:12Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"responseElements":{"ConsoleLogin":"Success"},"additionalEventData":{"MFAUsed":"Yes"}}
{"eventTime":"2026-09-15T09:02:40Z","eventSource":"iam.amazonaws.com","eventName":"CreateUser","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"requestParameters":{"userName":"svc-reporting"},"readOnly":false}
{"eventTime":"2026-09-15T09:03:05Z","eventSource":"s3.amazonaws.com","eventName":"ListBuckets","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"readOnly":true}
{"eventTime":"2026-09-15T09:10:22Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.10","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/ci-deploy/runner-4821"},"readOnly":true}
{"eventTime":"2026-09-15T09:15:47Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.21","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/bob","userName":"bob"},"responseElements":{"ConsoleLogin":"Success"},"additionalEventData":{"MFAUsed":"No"}}
{"eventTime":"2026-09-15T09:20:03Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.77","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"errorMessage":"Failed authentication","responseElements":{"ConsoleLogin":"Failure"},"additionalEventData":{"MFAUsed":"No"}}
{"eventTime":"2026-09-15T09:31:10Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T09:47:55Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T10:01:30Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T10:02:05Z","eventSource":"sts.amazonaws.com","eventName":"GetCallerIdentity","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"readOnly":true}
{"eventTime":"2026-09-15T10:02:31Z","eventSource":"iam.amazonaws.com","eventName":"ListUsers","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:02:38Z","eventSource":"iam.amazonaws.com","eventName":"ListRoles","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:02:52Z","eventSource":"iam.amazonaws.com","eventName":"GetAccountAuthorizationDetails","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:03:05Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:09Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeVpcs","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:14Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeSecurityGroups","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:22Z","eventSource":"secretsmanager.amazonaws.com","eventName":"ListSecrets","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:03:40Z","eventSource":"s3.amazonaws.com","eventName":"ListBuckets","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"readOnly":true}
{"eventTime":"2026-09-15T10:06:12Z","eventSource":"iam.amazonaws.com","eventName":"CreateUser","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc"},"readOnly":false}
{"eventTime":"2026-09-15T10:06:30Z","eventSource":"iam.amazonaws.com","eventName":"AttachUserPolicy","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc","policyArn":"arn:aws:iam::aws:policy/AdministratorAccess"},"readOnly":false}
{"eventTime":"2026-09-15T10:06:51Z","eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc"},"responseElements":{"accessKey":{"userName":"backup-svc","accessKeyId":"AKIAIOSFODNN7EXAMPLE","status":"Active"}},"readOnly":false}
{"eventTime":"2026-09-15T10:09:03Z","eventSource":"cloudtrail.amazonaws.com","eventName":"StopLogging","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/backup-svc","userName":"backup-svc"},"requestParameters":{"name":"arn:aws:cloudtrail:us-east-1:111122223333:trail/org-trail"},"readOnly":false}
{"eventTime":"2026-09-15T10:40:18Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.10","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/ci-deploy/runner-4821"},"readOnly":true}
{"eventTime":"2026-09-15T11:40:26Z","eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"requestParameters":{"userName":"svc-reporting"},"responseElements":{"accessKey":{"userName":"svc-reporting","accessKeyId":"AKIAI44QH8DHBEXAMPLE","status":"Active"}},"readOnly":false}
EOF

Kusto stores data in typed columns. The simplest faithful load is a staging table with one column of type dynamic, which holds any JSON (JavaScript Object Notation) value, one whole event per row:

.create table RawCloudTrail (Event: dynamic)

Now push the file in. format="json" means one JSON object per line, and the mapping says where the column's value comes from: the path $ is the whole event. Microsoft meant .ingest inline for experiments like this one, not for production.

cat > load.kql <<'EOF'
.ingest inline into table RawCloudTrail with (format="json", ingestionMapping='[{"Column":"Event","Properties":{"Path":"$"}}]') <|
EOF
cat cloudtrail-lab.jsonl >> load.kql
python3 kql.py < load.kql

On a free cluster, paste the whole of load.kql into the query editor and run it. Then look inside what you loaded instead of trusting that the command succeeded:

RawCloudTrail
| summarize Rows = count(), NullEvents = countif(isnull(Event))
 Rows | NullEvents
------+------------
 24   | 0
(1 row)

A load can succeed and still be empty. Run the same .ingest without ingestionMapping and the engine reports no errors and 24 rows, and all 24 are null: nothing told it which part of each line belongs in Event. A row count proves that rows arrived, not that they hold anything.

3. Give it Sentinel's shape

Sentinel stores CloudTrail in a table called AWSCloudTrail, with its own column names: eventTime arrives as TimeGenerated, sourceIPAddress as SourceIpAddress, userIdentity.arn as UserIdentityArn. Microsoft's AWSCloudTrail table reference also types three columns as string, not dynamic: RequestParameters, ResponseElements and AdditionalEventData hold JSON as plain text. Build the same thing:

.set-or-replace AWSCloudTrail <|
    RawCloudTrail
    | project
        TimeGenerated        = todatetime(Event.eventTime),
        EventName            = tostring(Event.eventName),
        SourceIpAddress      = tostring(Event.sourceIPAddress),
        UserIdentityArn      = tostring(Event.userIdentity.arn),
        UserIdentityUserName = tostring(Event.userIdentity.userName),
        ErrorCode            = tostring(Event.errorCode),
        RequestParameters    = tostring(Event.requestParameters),
        ResponseElements     = tostring(Event.responseElements),
        AdditionalEventData  = tostring(Event.additionalEventData)

That command is a query with a destination. project chooses the output columns and names them; Event.eventName reaches into the dynamic value; todatetime() and tostring() give each column its type, and tostring() on a nested object produces its JSON text, which is what Sentinel stores. .set-or-replace saves the result as a table, replacing its rows if it exists, so running it twice is harmless.

These are the nine columns this page uses. The real table has more than 50, such as UserIdentityAccountId; a query that names one missing here fails with an error naming the column, so it cannot mislead you quietly. AWSCloudTrail | getschema lists a table's columns and their types.

4. Filter rows: the trail was stopped

AWSCloudTrail
| where EventName in ("StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors")
| project TimeGenerated, EventName, UserIdentityUserName, SourceIpAddress
 TimeGenerated        | EventName   | UserIdentityUserName | SourceIpAddress
----------------------+-------------+----------------------+-----------------
 2026-09-15T10:09:03Z | StopLogging | backup-svc           | 203.0.113.50
(1 row)

where keeps the rows for which its condition is true, and in tests a value against a list. These four calls stop a trail, delete it, or change what it records. One happened, from the attacker's address, made by the identity created minutes before, and the other 23 events stay out, which matters as much. == is exact and case-sensitive, =~ ignores case, and lists work the same way (in and in~). Searching inside text is where people slip:

AWSCloudTrail
| summarize HasUser = countif(EventName has "User"), ContainsUser = countif(EventName contains "User")
 HasUser | ContainsUser
---------+--------------
 0       | 4
(1 row)

countif() counts the rows where its condition is true. has matches a whole term: Kusto splits text into runs of letters and digits and indexes every term of three or more characters, which makes has fast. CreateUser is one term, so has "User" finds nothing, while in an Amazon Resource Name (ARN) slashes and hyphens split terms, so UserIdentityArn has "backup" does match user/backup-svc. contains matches any part of the text, ignoring case, and cannot use the index: it found both CreateUser events, ListUsers and AttachUserPolicy.

5. Reach into JSON text: a sign-in without MFA

Whether a console sign-in used MFA is recorded in additionalEventData.MFAUsed. In Sentinel's table that column is a string, and the dot only reaches into dynamic values, so AdditionalEventData.MFAUsed fails with a semantic error: a loud failure, which is the good kind. parse_json() turns the text into a dynamic value, tostring() turns the field you pull out back into text you can compare, and extend adds the result as a new column and keeps all the others:

AWSCloudTrail
| where EventName == "ConsoleLogin"
| extend MFAUsed = tostring(parse_json(AdditionalEventData).MFAUsed)
| where MFAUsed == "No"
| project TimeGenerated, UserIdentityUserName, SourceIpAddress, MFAUsed
 TimeGenerated        | UserIdentityUserName | SourceIpAddress | MFAUsed
----------------------+----------------------+-----------------+---------
 2026-09-15T09:15:47Z | bob                  | 198.51.100.21   | No
 2026-09-15T09:20:03Z | alice                | 192.0.2.77      | No
(2 rows)

Two rows, and one of them is wrong. Alice's sign-in from 192.0.2.77 failed: there was no session for MFA to protect, and a rule that fires on it is noise. The outcome is in responseElements.ConsoleLogin:

AWSCloudTrail
| where EventName == "ConsoleLogin"
| extend MFAUsed = tostring(parse_json(AdditionalEventData).MFAUsed),
         Outcome = tostring(parse_json(ResponseElements).ConsoleLogin)
| where Outcome == "Success" and MFAUsed == "No"
| project TimeGenerated, UserIdentityUserName, SourceIpAddress, Outcome, MFAUsed
 TimeGenerated        | UserIdentityUserName | SourceIpAddress | Outcome | MFAUsed
----------------------+----------------------+-----------------+---------+---------
 2026-09-15T09:15:47Z | bob                  | 198.51.100.21   | Success | No
(1 row)

Bob, and only Bob. The alarm AWS Security Hub checks for this (control CloudWatch.3) also requires a successful sign-in, and Microsoft's own AWS_ConsoleLogonWithoutMFA.yaml leaves failed ones out too. When a column is already dynamic, parse_json() passes the value through unchanged, so writing it costs nothing and the query works whichever type the column has.

6. Count, then threshold: a burst of refusals

A first draft of a rule for a leaked key being tried out: count AccessDenied errors per source address, and alert at five.

AWSCloudTrail
| where ErrorCode == "AccessDenied"
| summarize Denied = count() by SourceIpAddress
| where Denied >= 5
 SourceIpAddress | Denied
-----------------+--------
(0 rows)

Nothing. A quiet morning, or a rule that cannot fire? Take the threshold away and look at what the data holds:

AWSCloudTrail
| where isnotempty(ErrorCode)
| summarize Events = count() by ErrorCode
 ErrorCode                    | Events
------------------------------+--------
 AccessDenied                 | 7
 Client.UnauthorizedOperation | 3
(2 rows)

EC2 reports a refusal as Client.UnauthorizedOperation. The draft counted four of the attacker's seven refusals, one short of its own threshold. Other services spell a refusal differently again: the metric filter AWS Security Hub expects for unauthorized API calls (control CloudWatch.2) matches any code ending in UnauthorizedOperation or starting with AccessDenied, such as AccessDeniedException, and a refusal by a virtual private cloud (VPC) endpoint's policy is logged as VpceAccessDenied. Note isnotempty(), not isnull(): in Kusto a string cannot be null, so a missing errorCode arrives as an empty string and isnull(ErrorCode) is never true, here or in Sentinel.

A query that returns nothing and a query that cannot work return the same thing. Before you trust an empty result, remove the threshold or the filter and confirm that the rows you meant to count exist and look the way you assumed. Make that control part of testing every rule.

AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count(), Codes = make_set(ErrorCode) by SourceIpAddress
| where Denied >= 5
 SourceIpAddress | Denied | Codes
-----------------+--------+-------------------------------------------------
 203.0.113.50    | 7      | ["AccessDenied","Client.UnauthorizedOperation"]
(1 row)

The attacker's address, and not the application's three refusals. Why contains and not a list: in with an exact list counts only the spellings you already know, which is how the draft failed, while the two words the codes share also catch prefixed and suffixed variants. has would not work here, because AccessDeniedException and UnauthorizedOperation are single terms that are not the terms AccessDenied or Unauthorized. The price is a scan instead of an index lookup, which is small for one table over a short window. make_set() puts the distinct codes in the result, so whoever reads the alert sees what was counted.

7. Time: ten-minute buckets

AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count() by SourceIpAddress, bin(TimeGenerated, 10m)
| sort by SourceIpAddress asc, TimeGenerated asc
 SourceIpAddress | TimeGenerated        | Denied
-----------------+----------------------+--------
 192.0.2.20      | 2026-09-15T09:30:00Z | 1
 192.0.2.20      | 2026-09-15T09:40:00Z | 1
 192.0.2.20      | 2026-09-15T10:00:00Z | 1
 203.0.113.50    | 2026-09-15T10:00:00Z | 7
(4 rows)

bin() rounds each time down to a multiple of ten minutes, so rows fall into buckets aligned to the clock. All seven of the attacker's refusals land in the 10:00 bucket, within a minute of each other. The application's three are one per bucket across half an hour: something retrying, not someone exploring. For a fixed window, between includes both ends: where TimeGenerated between (datetime(2026-09-15 10:00) .. datetime(2026-09-15 10:10)).

8. Two events, one story: a new user given a key at once

Creating a user and immediately giving it an access key is a way to keep access after the leaked key is revoked. That is two events, so it needs a join. let gives a query a name you can use further down; each let ends with a semicolon.

let Created = AWSCloudTrail | where EventName == "CreateUser"
    | project CreatedAt = TimeGenerated, CreatedBy = UserIdentityUserName, SourceIpAddress,
              NewUser = tostring(parse_json(RequestParameters).userName);
let Keyed = AWSCloudTrail | where EventName == "CreateAccessKey"
    | project KeyAt = TimeGenerated, NewUser = tostring(parse_json(RequestParameters).userName);
Created
| join kind=inner Keyed on NewUser
| project NewUser, CreatedAt, KeyAt, Gap = KeyAt - CreatedAt, CreatedBy, SourceIpAddress
 NewUser       | CreatedAt            | KeyAt                | Gap      | CreatedBy | SourceIpAddress
---------------+----------------------+----------------------+----------+-----------+-----------------
 backup-svc    | 2026-09-15T10:06:12Z | 2026-09-15T10:06:51Z | 00:00:39 | build-bot | 203.0.113.50
 svc-reporting | 2026-09-15T09:02:40Z | 2026-09-15T11:40:26Z | 02:37:46 | alice     | 198.51.100.20
(2 rows)

join kind=inner pairs each row on the left with every row on the right that has the same NewUser. Both users match, and svc-reporting is alice doing her job: she created it at 09:02 and gave it a key more than two and a half hours later. The detection is the time window, a where after the join:

let Created = AWSCloudTrail | where EventName == "CreateUser"
    | project CreatedAt = TimeGenerated, CreatedBy = UserIdentityUserName, SourceIpAddress,
              NewUser = tostring(parse_json(RequestParameters).userName);
let Keyed = AWSCloudTrail | where EventName == "CreateAccessKey"
    | project KeyAt = TimeGenerated, NewUser = tostring(parse_json(RequestParameters).userName);
Created
| join kind=inner Keyed on NewUser
| where KeyAt between (CreatedAt .. CreatedAt + 10m)
| project NewUser, CreatedAt, KeyAt, Gap = KeyAt - CreatedAt, CreatedBy, SourceIpAddress
 NewUser    | CreatedAt            | KeyAt                | Gap      | CreatedBy | SourceIpAddress
------------+----------------------+----------------------+----------+-----------+-----------------
 backup-svc | 2026-09-15T10:06:12Z | 2026-09-15T10:06:51Z | 00:00:39 | build-bot | 203.0.113.50
(1 row)

Thirty-nine seconds, by the leaked identity, from the attacker's address. Always write the kind. Leave it out and KQL uses innerunique, which keeps only one left-hand row for each key value, and Microsoft's documentation says which one is chosen at random: a user who signed in twice can quietly become a user who signed in once.

9. Make it a Sentinel analytics rule

In Sentinel a detection is a scheduled analytics rule: the query, plus how often to run it, how far back to look, when to alert, and what the alert is about. You can build one in the portal's rule wizard or deploy it as an Azure Resource Manager (ARM) template. Microsoft writes its own rules as YAML files, in the format its rule-authoring guide describes, and publishes them in the Azure-Sentinel repository's AWS analytic rules folder. That format is a good way to keep a rule under version control. Save step 6's query in the same shape; the id is a globally unique identifier (GUID) you generate once:

cat > AWS_RefusedCallBurst.yaml <<'EOF'
id: 200e45de-0a6e-4803-8137-248a3378a8dd
name: Burst of refused AWS API calls from one address
description: Identifies five or more refused AWS API calls from one source address.
severity: Medium
requiredDataConnectors:
  - connectorId: AWS
    dataTypes:
      - AWSCloudTrail
  - connectorId: AWSS3
    dataTypes:
      - AWSCloudTrail
queryFrequency: 10m
queryPeriod: 20m
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Discovery
relevantTechniques:
  - T1580
  - T1087.004
query: |
  AWSCloudTrail
  | where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
  | summarize Denied = count(), Codes = make_set(ErrorCode) by SourceIpAddress
  | where Denied >= 5
entityMappings:
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: SourceIpAddress
customDetails:
  DeniedCalls: Denied
version: 1.0.0
kind: Scheduled
EOF

10. Test the rule both ways

The emulator has no scheduler, but you can count what two consecutive runs would see by writing their 20-minute windows by hand: the run at 10:00 looks back to 09:40, the run at 10:10 to 09:50.

AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize
    RunAt1000 = countif(TimeGenerated between (datetime(2026-09-15 09:40) .. datetime(2026-09-15 10:00))),
    RunAt1010 = countif(TimeGenerated between (datetime(2026-09-15 09:50) .. datetime(2026-09-15 10:10)))
    by SourceIpAddress
 SourceIpAddress | RunAt1000 | RunAt1010
-----------------+-----------+-----------
 192.0.2.20      | 1         | 1
 203.0.113.50    | 0         | 7
(2 rows)

The 10:10 run sees the attacker's seven refusals and raises an alert; the 10:00 run sees one of the application's and none of the attacker's, and stays silent. Keep a check like this with the rule as its test, one window that must alert and one that must not: the second is what catches a rule that fires on everything.

Hands-on exercises

Exercise 1

The attacker gave backup-svc administrator rights (D5). Write a query that finds any attachment of a policy named AdministratorAccess, whether to a user, a group or a role, and shows who did it, to what, and when.

Show the answer
AWSCloudTrail
| where EventName in ("AttachUserPolicy", "AttachGroupPolicy", "AttachRolePolicy")
| extend Params = parse_json(RequestParameters)
| extend PolicyArn = tostring(Params.policyArn),
         Target = coalesce(tostring(Params.userName), tostring(Params.groupName),
                           tostring(Params.roleName))
| where PolicyArn endswith "/AdministratorAccess"
| project TimeGenerated, UserIdentityUserName, EventName, Target, PolicyArn
 TimeGenerated        | UserIdentityUserName | EventName        | Target     | PolicyArn
----------------------+----------------------+------------------+------------+---------------------------------------------
 2026-09-15T10:06:30Z | build-bot            | AttachUserPolicy | backup-svc | arn:aws:iam::aws:policy/AdministratorAccess
(1 row)

Parse the JSON once into a column, then pull fields from it. The target's name sits in a different field for each call, and coalesce() returns the first argument that is not null, or for strings not empty: the empty-string rule from step 6 working in your favour. endswith matches the AWS managed policy and any customer policy given the same name, and both are worth a look.

Exercise 2

A colleague's version of the burst query buckets time in ten-minute steps that start at three minutes past (a rule that runs at :03, :13 and :23 splits time the same way). It returns nothing, although the seven refusals are still in the data. What happened, and how do you prove it?

AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count()
    by SourceIpAddress, Bucket = bin_at(TimeGenerated, 10m, datetime(2026-09-15 10:03))
| where Denied >= 5
 SourceIpAddress | Bucket | Denied
-----------------+--------+--------
(0 rows)
Show the answer

Run the control: the same query with the threshold removed.

AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count()
    by SourceIpAddress, Bucket = bin_at(TimeGenerated, 10m, datetime(2026-09-15 10:03))
| sort by SourceIpAddress asc, Bucket asc
 SourceIpAddress | Bucket               | Denied
-----------------+----------------------+--------
 192.0.2.20      | 2026-09-15T09:23:00Z | 1
 192.0.2.20      | 2026-09-15T09:43:00Z | 1
 192.0.2.20      | 2026-09-15T09:53:00Z | 1
 203.0.113.50    | 2026-09-15T09:53:00Z | 3
 203.0.113.50    | 2026-09-15T10:03:00Z | 4
(5 rows)

The burst straddles a bucket boundary. Three refusals fell before 10:03:00 and four after, and neither bucket reaches five. Nothing about the attack changed, only where the boundaries fell. This is why the rule in step 9 counts over its whole lookback instead of binning inside it, and uses a lookback longer than its frequency.

Exercise 3

Another colleague looks for the stopped trail over the last day, gets a count of zero, and concludes that nobody touched it. What went wrong, and what should they have run first?

AWSCloudTrail
| where TimeGenerated > ago(1d)
| where EventName == "StopLogging"
| count
 Count
-------
 0
(1 row)
Show the answer

ago() counts back from the engine's clock, and these events happened on 15 September 2026, so the time filter removed every one of them. The control is to ask the data what time it covers:

AWSCloudTrail
| summarize Earliest = min(TimeGenerated), Latest = max(TimeGenerated)
 Earliest             | Latest
----------------------+----------------------
 2026-09-15T08:55:12Z | 2026-09-15T11:40:26Z
(1 row)

On lab data, use fixed times instead. Sentinel adds more layers of the same trap: the Log Analytics time picker shows the last 24 hours unless your query sets its own range, and a scheduled rule only ever sees its lookback. And if you sent these lab events to a real Log Analytics workspace, Azure Monitor would replace every TimeGenerated more than two days older than its arrival with the arrival time, so their dates would not survive.

Exercise 4

Turn the trail query from step 4 into a Sentinel rule (D1). What goes in tactics and relevantTechniques, and what should the query add so that it alerts only when the change succeeded?

Show the answer

Sentinel still uses ATT&CK's pre-v19 names, so the mapping it accepts is the one in Microsoft's own AWS_LogTampering.yaml:

tactics:
  - DefenseEvasion
relevantTechniques:
  - T1562.008
AWSCloudTrail
| where EventName in ("StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors")
| where isempty(ErrorCode)
| project TimeGenerated, EventName, UserIdentityUserName, SourceIpAddress

isempty(ErrorCode) keeps only calls that succeeded, so this returns step 4's single row: the StopLogging call went through. Microsoft's library puts refused attempts in a separate, low-severity rule, AWS_ClearStopChangeTrailLogs.yaml. In ATT&CK v19.2 this behaviour is T1685.002 Disable or Modify Cloud Log, under the new Defense Impairment tactic (TA0112): T1562.008 is revoked, and TA0005, which Sentinel still calls DefenseEvasion, is renamed Stealth. Neither new name is in Sentinel's tactic list yet, and Microsoft's content checks reject T1685.002 because no tactic they know contains it. Map to what your platform accepts, and expect the mapping to move when it catches up.

Common mistakes

Where next