How to

Kerberos RC4: collect it with Alloy, watch it with Grafana

A step by step guide to collecting Kerberos telemetry from your domain controllers with Grafana Alloy, shipping it to Loki through Fleet Management, and using a published dashboard to see exactly what RC4 removal is going to break. Including the four things that silently return nothing.


Microsoft is removing RC4 as the default supported encryption type for Active Directory domain controllers, tracked as CVE-2026-20833. The reason is Kerberoasting: an attacker who can request a service ticket encrypted with RC4 can take it away and brute-force the service account’s password offline, at their own pace, with nothing on your network to see.

The rollout ran in three phases. January 2026 added the audit signals. April 2026 made AES the default for accounts with no explicit encryption type, with a rollback still available. The July 2026 update removed audit mode and the rollback, and enforcement is now the only supported configuration. If you are reading this after installing it, RC4 works only where it has been configured explicitly.

The remediation Microsoft documents is a set of PowerShell scripts from the microsoft/Kerberos-Crypto repository. You run one, you get a table, you work down the list:

.\Get-KerbEncryptionUsage.ps1 -Encryption RC4

That is a reasonable place to start and a dangerous place to stop, because of one property of the problem the script cannot capture.

A batch job that runs on the first working day of the month. A backup agent that only touches the domain during a full run. A vendor appliance that renews a ticket every ninety days. None of them are in Tuesday’s snapshot. All of them break the day enforcement lands.

RC4 usage is not a state you can read off the directory. It is a behaviour, spread unevenly over weeks, and the only honest way to measure a behaviour is to watch it continuously.

So this is a guide to doing exactly that: turn the events on, collect them with Grafana Alloy, push the config to every domain controller at once with Fleet Management, and read the result on a dashboard you can import. About an hour of work, and then a month of waiting, which is the part that actually matters.

The overview row of the Kerberos RC4 dashboard: RC4 tickets issued, accounts using RC4, services without AES keys, ETYPE_NOTSUPP failures, AES adoption and domain controllers reporting.
Where this ends up: RC4 tickets still being issued, accounts with no AES key material, and what the KDC itself is already refusing.

Everything below is published. The dashboard and the collector configs are in igorfasano-tech/grafana-observability, free to take.

What you need before you start

  • Domain controllers on Windows Server 2019, 2022 or 2025
  • Grafana Alloy 1.14 or later, which you will install on the DCs and nowhere else
  • Loki, self-hosted or Grafana Cloud
  • Grafana 11 or later

Nothing goes on the clients. There is no scheduled script and no CSV anywhere. Two event streams off the domain controllers answer the whole question.

Step 1: confirm the ticket events are being audited

Kerberos ticket auditing is on by default on a domain controller, under the Default Domain Controllers Policy. Confirm rather than assume:

auditpol /get /subcategory:"Kerberos Authentication Service","Kerberos Service Ticket Operations"

You want Success and Failure on both. The Failure half is not optional here: it carries event 4771 and the 0xE results, which are the requests that are already failing today.

If you need to set it explicitly, it lives under Advanced Audit Policy Configuration, Account Logon, in the Default Domain Controllers Policy.

Why these events are enough

Since the November 2022 updates for CVE-2022-37966, a single event 4769 carries considerably more than it used to:

FieldWhat it answers
TicketEncryptionTypeWhat cipher was actually used for this ticket
ServiceAvailableKeysWhether the service account holds AES key material at all
ServiceSupportedEncryptionTypesThe msDS-SupportedEncryptionTypes bitmask on the object
ClientAdvertizedEncryptionTypesWhat the client machine offered
DCSupportedEncryptionTypesWhat the KDC itself advertises
Status0xE when client and service could not agree

Watch for one trap in that table. As a ticket encryption type, 0x17 is RC4-HMAC and 0x18 is RC4-HMAC-EXP. In the msDS-SupportedEncryptionTypes bitmask, 0x18 means AES128 plus AES256. The exact opposite meaning, same number, two fields apart in the same event.

Step 2: turn on the KDC’s own events

The ticket events are the client’s side of the story. The KDC writes its own, and during a migration they are the more useful ones, because they tell you what it is about to refuse. Microsoft added nine, documented in KB5073381:

EventWhat the KDC is telling you
201The client advertised only RC4, and the service has no msDS-SupportedEncryptionTypes setissued
202The service account holds no AES keys, and msDS-SupportedEncryptionTypes is not setissued
203Same condition as 201blocked
204Same condition as 202blocked
205RC4 explicitly enabled in the domain policy DefaultDomainSupportedEncTypes
206The service accepts only AES, and the client does not advertise AESissued
207The service is configured for AES, but the account holds no AES keysissued
208Same condition as 206blocked
209Same condition as 207blocked

They come in pairs on purpose. Every blocked event has a warning with the same underlying cause, so a 203 today is a 201 that nobody acted on, and the fix is the same one. The issued column is your remediation backlog and the blocked column is your outage. The entire point of the audit phase is to empty the first before anything lands in the second.

There is no log to switch on. The obvious move is to go looking for a Microsoft-Windows-Kerberos-Key-Distribution-Center/Operational channel sitting there disabled. There isn’t one. These go to the System log with the source Kdcsvc, a channel you are almost certainly already collecting.

The reason you have never seen them is not that the log is off. The KDC only emits them when the RC4 disablement phase is active, and that was a registry value.

Check your patch level before reaching for it. The July 2026 update removed audit mode and this setting with it, so on a fully patched domain controller the phase is decided for you and the value does nothing. If you are past that point, skip to step 3: you will be reading the blocked events rather than the warnings, which means you are debugging rather than preparing, and the collection matters more rather than less.

On a domain controller that still honours it:

$key = 'HKLM:\SYSTEM\CurrentControlSet\Services\Kdc'
New-ItemProperty -Path $key -Name 'RC4DefaultDisablementPhase' -Value 1 -PropertyType DWord -Force
# 0 = no change. RC4 fully permitted, nothing logged.
# 1 = audit. Warnings logged, RC4 still allowed, no behaviour change.
# 2 = enforcement. RC4 blocked for accounts that can do AES.
# The KDC service picks this up on restart.

Set it to 1 and you get the warnings without changing behaviour. That is the whole audit mode, and it is one value.

Step 3: write the collector config

Two source blocks, because they are two different channels, both feeding one processing stage.

// Security channel: the ticket events.
loki.source.windowsevent "kerberos_tickets" {
  eventlog_name = "Security"
  xpath_query   = "*[System[(EventID=4768 or EventID=4769 or EventID=4771)]]"

  bookmark_path          = "C:\\ProgramData\\GrafanaLabs\\Alloy\\data\\bookmark_kerberos.xml"
  poll_interval          = "3s"
  use_incoming_timestamp = true
  locale                 = 1033
  exclude_event_message  = true

  forward_to = [loki.process.kerberos.receiver]
}

// System channel: the KDC's own verdict.
loki.source.windowsevent "kerberos_kdc" {
  eventlog_name = "System"
  xpath_query   = "*[System[(Provider[@Name='Kdcsvc'] or Provider[@Name='Microsoft-Windows-Kerberos-Key-Distribution-Center']) and (EventID=201 or EventID=202 or EventID=203 or EventID=204 or EventID=205 or EventID=206 or EventID=207 or EventID=208 or EventID=209)]]"

  bookmark_path          = "C:\\ProgramData\\GrafanaLabs\\Alloy\\data\\bookmark_kerberos_kdc.xml"
  poll_interval          = "10s"
  use_incoming_timestamp = true
  locale                 = 1033

  forward_to = [loki.process.kerberos.receiver]
}

Five details in there are the difference between this working and this quietly doing nothing.

xpath_query must not repeat the channel name. With eventlog_name set, writing "Security!*[System[...]]" fails with “The specified query is invalid”. eventlog_name has already opened the subscription on that channel. Specifying it twice is not idempotent, it is an error.

exclude_event_message = true, but only on the Security block. The message field is the rendered English description, and it restates data already present in event_data. On the ticket events that is free volume. A busy domain controller runs at around 90 MiB an hour for 4768, 4769 and 4771 with the message included, roughly 2 GiB a day from one machine. If you are shipping to a hosted backend, go and read what your plan actually includes before you point this at it. A logs plan allowance counted in tens of gigabytes a month disappears under one chatty domain controller.

On the KDC events, leave the message in. There the message is the payload: it names the account and the service. They are also rare, so there is nothing to save.

The provider name is a trap. Microsoft-Windows-Kerberos-Key-Distribution-Center is a real ETW provider and reads like the obvious answer. KB5073381 says the source is Kdcsvc, and that is what appears in the event XML. Filter on the wrong one and you get no error and no events, which looks exactly like a clean environment. Matching both names costs nothing and survives either spelling.

Enumerate the event IDs. EventID >= 201 needs XML escaping inside the XPath string. Spelling out nine numbers avoids the question entirely.

Both blocks forward to the same receiver. One processing stage, one write, one pipeline to manage.

Step 4: promote the three labels the dashboard needs

This is the step people skip, and it is the one that makes every panel read “No data”.

loki.process "kerberos" {
  forward_to = [loki.write.default.receiver]

  stage.json {
    expressions = {
      event_id    = "event_id",
      computer    = "computer",
      channel     = "channel",
      timeCreated = "timeCreated",
    }
  }

  stage.labels {
    values = {
      event_id = null,
      computer = null,
      channel  = null,
    }
  }

  stage.timestamp {
    source           = "timeCreated"
    format           = "2006-01-02T15:04:05.9999999Z07:00"
    fallback_formats = ["2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05Z"]
  }
}

loki.write "default" {
  endpoint {
    url = "https://logs-prod-XXX.grafana.net/loki/api/v1/push"
    basic_auth {
      username      = "YOUR_LOKI_USER_ID"
      password_file = "C:\\ProgramData\\GrafanaLabs\\Alloy\\gc-token"
    }
  }
  external_labels = {
    job = "windows_kerberos",
  }
}

Two things about that block.

The values must be null, not "". In stage.labels, null means “use the extracted value”. An empty string is taken as a literal empty label, and {event_id="4768"} then matches nothing while the data sits in Loki looking perfectly fine.

Do not promote account, service, encryption type or IP address. They are unbounded, and each one multiplies your stream count by the size of your directory. The dashboard parses all of them at query time instead. It is slower per query and dramatically cheaper to run, and it is the reason this collects a whole estate without anybody having a conversation about the bill.

If some other pipeline already ships your System channel, and most Windows fleets have one, those events arrive with no event_id label at all. A query like {channel="System", event_id=~"20[1-9]"} matches nothing. The symptom is sum by (event_id) coming back as a single unlabelled series: the events are in Loki, the pipeline shipping them just never extracted the field. Either add the label there, or accept that this needs its own pipeline.

Step 5: push it to every domain controller with Fleet Management

Here is where this stops being a lab exercise. A hardening programme is measured across every domain controller you have, and nobody wants to RDP into each one to paste a config block.

Fleet Management inverts the usual agent model. The collector holds almost nothing locally: it registers itself, declares a set of attributes, and asks the server what it should be running. The pipeline lives centrally and you attach it to machines with a matcher rather than a host list.

This is the entire local config on each DC:

remotecfg {
  url            = "https://fleet-management-prod-XXX.grafana.net"
  id             = constants.hostname
  poll_frequency = "60s"

  attributes = {
    "role" = "domain_controller",
    "env"  = "prod",
  }

  basic_auth {
    username = sys.env("FLEET_USER")
    password = sys.env("FLEET_TOKEN")
  }
}

Everything from steps 3 and 4 goes into a pipeline created once, centrally, with a matcher of role="domain_controller". Promote a new domain controller, give it the attribute, and it starts reporting Kerberos telemetry without anybody editing a file on it.

Five things to know before you build one.

Pipeline names must be valid Alloy identifiers. Underscores are fine, hyphens are not. windows_dc_kerberos works; windows-dc-kerberos is rejected with “pipeline name must be valid alloy identifier”, which is easy to miss if you name things by convention elsewhere.

Updating a pipeline is a full replacement, not a patch. Fetch the current state, change what you need, and send everything back: name, contents, matchers, enabled. Anything you leave out is removed, and dropping a matcher this way takes half the fleet quiet without telling you.

One bad pipeline blocks all the others. Fleet Management merges every matching pipeline into a single config per collector. If one fails to parse, the collector keeps running its last good config and quietly stops picking up changes to all of them. When a pipeline edit appears to do nothing, check whether a different pipeline on the same matcher is broken.

The API request body wraps the object. CreatePipeline and UpdatePipeline both want {"pipeline": {...}}. Send the fields flat and you get back pipeline is missing, which reads like you forgot an argument rather than like a shape problem.

Do not put the write token in the pipeline body. Pipeline contents are readable by anyone holding fleet-management:read, which is a much wider group than the people you would hand a write token to. Use password_file, as in step 4, and keep the secret on the host. Mind the order when you switch: put the file on the machine first, confirm it is there, and only then change the pipeline to point at it. A missing file stops the pipeline building, and see the previous point for what that does to everything else.

Step 6: check the data actually arrived

Before importing anything, confirm the labels are landing. If these come back empty, no dashboard will save you.

# Should list your job
curl -s "$LOKI/loki/api/v1/label/job/values"

# Should list your domain controllers
curl -s "$LOKI/loki/api/v1/label/computer/values"

Then the one query that proves the parsing works end to end:

sum(
  count_over_time(
    {job="windows_kerberos", event_id=~"4768|4769"}
      |= "TicketEncryptionType"
      | json event_data="event_data"
      | line_format "{{.event_data}}"
      | regexp `<Data Name='TicketEncryptionType'>(?P<etype>[^<]*)</Data>`
      | etype=~"0x17|0x18"
    [7d]
  )
)

That is your RC4 ticket count for the week. If the label queries return data and this returns nothing, read the next section before you start doubting your environment.

The escaping trap that returns nothing

This one is worth the price of the article.

Alloy serialises each event with Go’s encoding/json, which HTML-escapes <, > and & into \u003c, \u003e and \u0026. The line as stored in Loki does not contain angle brackets at all. It contains their escape sequences.

So this returns exactly zero results, silently:

{event_id="4769"} |= "TicketEncryptionType'>0x17<"

No error. No warning. An empty panel that looks like a clean environment.

The order that works is the one in the query above: filter on a bare field name first, then parse the JSON, then reformat the line, and only then apply a regex containing angle brackets, because by that point the JSON parser has unescaped them.

Step 7: import the dashboard

Grab dashboard.json from the repository, import it, and pick your Loki datasource from the dropdown at the top.

The datasource is a dashboard variable rather than an import-time input, so you can point the same dashboard at a self-hosted Loki and at Grafana Cloud from one browser tab. If you want to publish your own version to the public grafana.com library, note that its importer is built around an __inputs block and rejects a datasource variable silently. There is a --grafana-com build flag in the repository for exactly that.

Leave the range at seven days. A monthly batch account will not show up in the last hour, and finding those is the entire point.

Step 8: read it

The two worklists are not the same job

The service account worklist showing accounts with no AES key material, next to the encryption family chart and RC4 tickets by domain controller.
Accounts that cannot do AES at all. The fix here is a password reset and nothing else will work.

No AES key material. ServiceAvailableKeys with no AES entry means the account physically cannot negotiate AES, whatever the policy says. The password predates the AES upgrade. The fix is a password reset, and the value does not have to change, because it is the reset itself that regenerates the AES keys. Then set msDS-SupportedEncryptionTypes = 24 (0x18, AES128 plus AES256).

If an account genuinely still needs RC4 for a while, the documented rollback value is 28 (0x1C), which is RC4 plus both AES types. Not 0x24, which looks similar and is RC4 plus an AES256 session key with no AES long-term keys.

AES available, RC4 advertised. The service is already AES-capable and the negotiation is being dragged down from the other side. On Java and SAP hosts the usual cause is krb5.ini listing rc4-hmac in default_tkt_enctypes. The JVM reads that file at startup, so editing it does nothing until the service restarts.

Two lists, two different teams, two different change windows. Mixing them wastes a lot of people’s time, which is why they are separate panels.

One filter in those queries is worth knowing about. Events where ServiceAvailableKeys is not applicable carry a literal -, which passes “not empty”, “not N/A” and “contains no AES”. Without excluding it explicitly, krbtgt and friends show up on the password-reset worklist. False positives, and the kind that gets somebody to reset twelve service accounts for nothing.

What is already broken

Table of requests failing with KDC_ERR_ETYPE_NOTSUPP, listing service, requesting account and client IP.
KDC_ERR_ETYPE_NOTSUPP. Not future problems, current ones.

0xE means client and service could not agree. These are broken today, and they are the hardest to find by any other means. Until the disablement phase is emitting, a 0xE in event 4769 is the only trace. Most clients then fall back to NTLM, which makes the application look healthy while silently downgrading the authentication.

Your own domain controllers, and the KDC’s opinion

Domain controller posture panels showing DCSupportedEncryptionTypes per DC, and the KDC verdict row with events 201 to 209.
One DC of four still advertising RC4, and the KDC events 201 to 209 underneath.

DCSupportedEncryptionTypes is on every ticket event, which means your own domain controllers report their own posture without you querying anything. It is common to find one that was rebuilt from an old image, or one site that never got the policy.

The panel worth explaining is DCs Reporting the Policy. A domain controller that emits nothing looks identical to one with nothing to report, and those mean opposite things. If that count is lower than the number of DCs you have, the rest are not clean, they are silent.

That distinction has to survive contact with the panel, and by default it does not. A Grafana stat with no series takes the base threshold colour, so “Requests Blocked by the KDC” with nothing behind it renders as a full green tile. It reads as an all-clear and it is the absence of evidence. The fix is two lines of panel config: a neutral base step with an explicit green step at zero, so a real zero is green and no data is grey, plus a noValue of “not reporting” instead of a number that was never there.

That one is invisible in the JSON. It only shows up when the panel is rendered and somebody looks at it, which is the argument for screenshotting a dashboard before you trust it.

Then wait

Turn the collection on well before you plan to enforce anything. Let it run for a full month. Then look at what appeared in week three that was not there on day one.

That gap is the entire value of doing this as a time series rather than a table. A snapshot tells you what was true when you ran it. A time series tells you what is true over the window that matters, and for an authentication protocol that window is at least as long as your slowest scheduled job.

Understand that before you change a policy, and the enforcement day is uneventful. Which is the whole point. Nobody gives you credit for a hardening project that went badly.

To go further

← all writing