Wiring the audit feed into your SIEM
The audit feed is the out-of-band way a security information and event management system (a SIEM) collects the engine’s audit trail continuously, rather than a human downloading an export by hand. An Owner mints a scoped audit-feed credential, your collector presents it on every poll, and the engine returns the next batch of hash-chained events along with the chain head so the collector can checkpoint and confirm nothing went missing.
This page is the canonical account of that wiring, written for an auditor or a detection engineer who is standing up the pull and wants the exact mechanics from the engine code: the route, the cursor, the chain head, and what the events contain. The audit log concept page describes the chain itself and the on-screen export; this page owns the feed.
One fact sets the reader’s expectations first. The feed is redaction-safe for your customer data, and it deliberately carries operator and admin identity, including member emails and source IPs. That distinction is load-bearing and is spelled out in full below: the feed holds personal identifiers on purpose, so it is handled as a record that contains them.
Pull or push
Two mainstream methods exist for getting an audit trail into a SIEM, and downpipes supports both, so the choice is which one suits your SIEM rather than which one the platform happens to offer.
Pull, the rest of this page, suits a SIEM that already polls a REST endpoint: Microsoft Sentinel, Exabeam, Cribl and Sumo Logic all take it cleanly, and it uses a single feed credential you mint and revoke yourself rather than a stored secret. Its one friction is the Access perimeter above: when Cloudflare Access fronts your console hostname, you add a service token or a narrow path exemption for the collector.
Push suits a SIEM that would rather be delivered to than poll, and it is not only an HTTP shape. The engine ships eight body formats over three sinks: an HTTPS endpoint, an S3 bucket your SIEM already reads, or a syslog TLS listener. Splunk, CrowdStrike and Datadog take the HTTPS sink; QRadar and LogRhythm take CEF or LEEF over syslog TLS, which their built-in parsers require and a plain HTTPS POST does not satisfy; Wazuh takes NDJSON batches dropped into a bucket. Because the engine is the one dialling out, a Cloudflare Access perimeter on the console hostname never sees an inbound request to turn away, so there is no exemption to add at all. Read forwarding the audit log to your SIEM (push) for the eight formats, the three sinks that carry them, the security posture, and the at-least-once delivery semantics.
Choose pull when your SIEM already polls and adding one Access exemption is a small cost. Choose push when your SIEM prefers HTTP intake, or you would rather not carve any exemption into your Access perimeter. Running both at once is fine too.
The audit-feed credential
The feed is served from GET /support/audit-feed, which lives outside the /admin API. A collector presents only a bearer credential: no console seat, no Cloudflare Access seat, and no admin token (handleSupportPull, engine/src/admin/support-ingest.ts). That is what lets a headless collector poll on a schedule without an interactive sign-in. Needing no Access seat does not mean an Access-fronted hostname lets the poll through, though: when Cloudflare Access fronts the console domain, the edge blocks the collector before the engine can answer, and that interaction has its own section below.
An Owner mints the credential the same way as the vendor diagnostics credential and the Prometheus metrics credential: minted once, shown once, and stored as a SHA-384 hash of the secret rather than the secret itself. All three scopes share the one mechanism (mintIngestCredential, engine/src/admin/support-ingest.ts), each capped to a lifetime suited to its job. The audit-feed scope is long-lived, because a collector polls for as long as it runs, though it is not the longest-lived of the three: the metrics scope, covered in Prometheus metrics, runs longer again. The diagnostics scope, by contrast, is short-lived for a single support ticket.
| Scope | Default lifetime | Maximum | What it serves |
|---|---|---|---|
metrics |
365 days | 400 days | GET /metrics, the Prometheus scrape surface |
audit-feed |
90 days | 365 days | GET /support/audit-feed, the hash-chained audit events |
diagnostics |
72 hours | 7 days | GET /support/diagnostics, the signed support bundle |
Minting asks you to re-authenticate with your passkey at the moment you do it. The mint route sits in the engine’s step-up set, so a console session cookie on its own does not reach it, and the requirement is enforced on the route rather than raised as a console prompt you could skip (STEPUP_SUBS, engine/src/admin/router-core.ts). Two callers are exempt and it is worth knowing which before you script a collector’s provisioning: the break-glass bearer token and a caller arriving through Cloudflare Access are both let past, because each carries its own possession check already (requireStepUp, engine/src/admin/router-core.ts). Dual control is a separate gate, it is opt-in, and it does not stand in for this one.
The lifetimes are enforced server-side. The engine clamps a requested time to live into the range from a 60-second floor up to the scope cap, then stamps the expiry onto the grant (INGEST_TTL_CAPS_SECONDS and the clamp in mintIngestCredential, engine/src/admin/support-ingest.ts). So even an un-revoked feed credential ages out on the engine’s own clock, and the cap means it cannot be minted to last beyond a year.
The credential is presented as a single opaque bearer, because that is the one field every collector’s configuration form accepts.
GET /support/audit-feed?afterSeq=4096&limit=500 HTTP/1.1
Host: console.example.com
Authorization: Bearer dpc_AbCdEfGhI.dps_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
| Component | Meaning |
|---|---|
dpc_... before the dot |
The non-secret client id, a lookup label |
dps_... after the dot |
The secret, the bearer that authorises the poll, shown once at mint |
afterSeq |
The sequence cursor; return only events with a sequence strictly above this. Defaults to 0 |
limit |
The batch size, clamped to the range 1 to 1000. Defaults to 500 |
A wrong or absent bearer is a plain 401 with no detail, and verification is a constant-time comparison of the presented secret’s hash against the stored hash with server-side expiry, so a failed presentation leaks nothing about which part was wrong (checkIngestCredential, engine/src/admin/support-ingest.ts). The secret carries 192 bits of entropy, so brute force is not a realistic path.
If Cloudflare Access fronts the hostname
In the shipped topology the feed rides the console’s public hostname, because /support/* is proxied through the console to the routeless engine (topology). So when a Cloudflare Access application fronts that hostname, the edge turns a collector’s poll away before the engine ever sees it: Access answers with a 302 redirect to your team’s login page, whatever bearer the collector carries. The collector-side symptom is unmistakable, an HTML redirect where JSON was expected, and not a 401 from the engine. The console warns you about this at mint time when the engine detects the perimeter: the engine reports whether the mint-screen read itself arrived carrying the edge-injected cf-access-jwt-assertion header (the accessPerimeter flag on GET /admin/support, engine/src/admin/router-status.ts; the warning copy in console/src/screens/settings/support.ts).
Three arrangements let a collector through, and each keeps the feed credential as the engine-side gate.
Give the collector an Access service token (recommended)
Create a separate Access application scoped to the feed path (for example
console.example.com/support/audit-feed) with a Service Auth policy, and issue an Access service token. The collector sends two extra headers on every poll,CF-Access-Client-IdandCF-Access-Client-Secret, alongside the feed bearer. Access authenticates and logs the machine caller, and the more specific application takes precedence over the identity policies on the rest of the hostname. Collectors with a generic REST poller that can set custom headers handle this directly: Exabeam’s REST API collector, Cribl’s REST collector, Microsoft Sentinel’s codeless connector, Google Security Operations feeds and QRadar’s Universal Cloud REST API workflow all take arbitrary request headers.Or exempt the path with a narrow Bypass
If your collector cannot send extra headers, give the same path-scoped application a Bypass policy instead. Access then stops gating that one path entirely and stops logging it, and the feed’s own credential is the sole gate, which is the surface’s designed posture: a 192-bit secret, constant-time verification, and a plain
401on failure. Cloudflare’s own guidance is to prefer Service Auth over Bypass for automated traffic and to scope a Bypass as narrowly as possible, so reserve this for collectors that genuinely cannot present the token.Or give the engine its own hostname
The split topology puts the engine’s
/support/*on a separate hostname with its own Access policy, so the collector’s path never crosses the console’s perimeter at all. That is the exception, not the normal case; it is described with the topology (the split variant).
Keep the service token in its own headers, not the bearer's
Cloudflare Access offers a per-application option to read service tokens out of the Authorization header for clients that can only set one header. The feed cannot use it: the feed’s own bearer already rides Authorization, and an application configured that way consumes the header for Access before the engine can see the credential. Send the service token as the two CF-Access-* headers and keep Authorization for the feed bearer.
Polling mechanics: cursor by sequence, carry the head
Each poll returns events in ascending sequence order, only those above your cursor, bounded by your limit, together with the chain head. The collector advances its cursor with the nextAfterSeq the engine returns and polls again.
{
"kind": "downpipe-audit-feed",
"v": 1,
"afterSeq": 4096,
"nextAfterSeq": 4220,
"headSeq": 4220,
"headHash": "sha384:9f3c...e1",
"count": 124,
"events": [
{
"seq": 4097,
"ts": "2026-06-19T02:14:08.221Z",
"actorSubject": "https://acme.cloudflareaccess.com|01J9Z7M3QF8K2WX4P6R0V5T1AB",
"actorEmail": "ops@acme.example",
"actorMethod": "access",
"sourceIp": "203.0.113.7",
"action": "restore-approve",
"outcome": "success",
"target": {
"kind": "restore",
"runId": "run_01H...",
"redirectBinding": null,
"planHash": "sha384:7a2b...c4",
"isLatest": true,
"approverEmail": "lead@acme.example"
},
"prevHash": "sha384:11ab...90",
"hash": "sha384:22cd...01"
}
]
}
| Field | Meaning |
|---|---|
kind, v |
The schema marker and version, so a collector can pin the shape |
afterSeq |
The cursor the engine was asked for, echoed back |
nextAfterSeq |
The sequence to send as afterSeq on the next poll. It is the last returned event’s sequence, or the same afterSeq when the batch was empty |
headSeq |
The highest sequence in the whole log right now, so a collector knows how far behind its cursor is |
headHash |
The hash of the head entry, the value an external verifier pins to detect later truncation |
count |
How many events are in this batch |
events |
The batch, in ascending sequence order |
The checkpoint discipline follows directly. Persist nextAfterSeq after each successful batch and send it as the next afterSeq, so a restart resumes exactly where it stopped with no duplicates and no gaps. When count is zero the cursor does not move and headSeq tells you that you are caught up. When headSeq is far above your cursor you are behind and the next polls will catch up in batches of up to limit.
The cursor is a sequence number, not a timestamp, and sequences are gap-free and monotonic. That is what makes “exactly once, in order” achievable for a collector: there is no clock skew to reason about and no window to miss.
Verifying continuity from the feed
The feed is not only a stream of events, it is a stream you can prove is unbroken. Each event carries prevHash and hash, both sha384: digests, and each hash is computed over the canonical form of every field of the entry except the hash itself, including the prevHash. So each entry commits to its own content and to the prior entry’s hash. The chain is linked with SHA-384 throughout (auditHash, engine/src/admin/audit.ts).
A collector verifies continuity with two local checks as it ingests.
Link each entry to the one before it
For two consecutive events, the later event’s
prevHashmust equal the earlier event’shash. A mismatch means an entry was inserted, removed, reordered, or its predecessor was edited. Across a poll boundary the same rule holds: the first event of a new batch must link to the last event of the previous batch.Check the sequence is contiguous
Each event’s
seqmust be exactly one greater than the previous event’sseq. A gap means an entry between them is missing, even if a sophisticated tamper re-linked the hashes around the hole. This is why the verifier checks the sequence as well as the hash link.Pin the head out of band
Store
headHashfrom your most recent poll somewhere the engine cannot reach. Later you can recompute the chain over what you ingested and confirm it ends at the same head, which is how truncation after a poll becomes detectable rather than silent.
This is the tamper-evident property in operational terms: alteration is detectable, it is not prevented. A holder of the underlying storage could rewrite the entire chain from a forged start, recomputing every hash, and that fully re-chained log would still verify on its own. What the chain guarantees is narrower and real. Any edit or deletion of a past entry that is not a full re-chain breaks one of the two checks above, and the head hash you pinned lets you detect truncation that happened after you read it.
Tamper-evident means detectable, not impossible
The feed is tamper-evident, which is detection rather than prevention. A SIEM that ingests it gains the ability to alert on a broken link, a sequence gap, or a head that no longer matches the one it pinned. It does not gain a guarantee that the source store can never be rewritten. Treat the head hash as the evidence you pin, and alert on any of the three breaks.
What the feed carries, and what it does not
The events are redaction-safe for your customer data. The recorder accepts only a small closed set of target shapes and has no free-form field a caller could stuff a value into, so the trail can never hold a key, a secret, a private fingerprint, a destination credential, an endpoint, a bucket, or a region (AuditTarget and the redaction-by-construction note in engine/src/admin/audit.ts). The correct statement is: no customer backup data, no keys, no secrets.
What the feed does carry is operator and admin identity, on purpose, because attributability is the point of an audit trail.
| Field on an event | Example | What it is |
|---|---|---|
actorEmail |
ops@acme.example |
The verified display email of the human who acted, where there is one |
actorSubject |
https://acme.cloudflareaccess.com|01J9Z7M3QF8K2WX4P6R0V5T1AB |
The stable opaque principal the engine authorises on |
sourceIp |
203.0.113.7 |
The source address from the CF-Connecting-IP header the engine saw |
On the wire, target is always a small structured object keyed by kind, as in the example above, never a flat string. The table below shows describeTarget()’s one-line rendering of the same structured target, the form used in the CSV export and the console summary (engine/src/admin/audit.ts), so you can recognise the identity a given kind carries either way.
target.kind |
describeTarget() rendering |
What it is |
|---|---|---|
downpipe |
downpipe acme-kv (KV prod) |
Your own downpipe id and name |
role |
role operator for ops@acme.example |
A role change names the affected member email and the role |
grouprole |
group-role approver for group SecOps |
Your own directory group name and the mapped role |
restore / configchange / owneraction |
..., approved by lead@acme.example |
On a dual-control approval, the checker’s email under the maker-is-not-the-checker rule, appended when present |
idpconnection |
oidc connection acme-okta (create) |
A native identity-provider connection’s operator-chosen slug and the lifecycle operation |
The feed carries operator identity
Because the audit feed carries member emails, source IPs, downpipe labels, role and approver emails, and connection ids, it is never described as carrying no personal data or no identifying information. It holds none of your customer backup data, no keys, and no secrets. It does hold who did what, from where. Treat the feed as a record that contains personal identifiers, and apply your SIEM’s data-handling rules to it accordingly.
The feed is read-only. The route is a GET that returns events and the chain head, and there is no write path here that could expose customer data or mutate anything (handleSupportPull, engine/src/admin/support-ingest.ts). A collector consumes the feed; it cannot drive the engine through it.
The lease is revocable, and its expiry is tracked
The audit-feed credential is a per-grant lease, not a standing vendor-held key, and it is revocable at any moment. Revoking it is immediate: the next poll the collector attempts returns 401 as soon as you confirm, and only one credential is active per scope, so re-minting replaces the prior grant and kills the prior secret. There is no edit of the same secret, so rotation means revoke and re-mint.
The revoke asks for no step-up, deliberately and unlike the mint. Demanding a fresh re-authentication before you can shut off a credential you believe is stolen would be friction at the worst possible moment, so the gate is on the direction that opens the surface rather than the one that closes it. Rotation therefore meets the step-up on the re-mint, not on the revoke, and a collector’s credential can always be killed with whatever session you already have.
The engine keeps a customer-visible usage trail on the grant: the most recent 50 polls are recorded by timestamp, with older entries rolling over, so you can see when the credential was last used (IngestGrant.pulls and the pull recording in handleSupportPull, engine/src/admin/support-ingest.ts). The recording is best-effort and never blocks a poll.
The credential’s expiry is also auto-observed in the credential lifecycle registry, so it appears in your single view of every expiring credential. The audit-feed bearer is registered as a functional credential, identified by its public client id only, and its row is dropped automatically when you revoke the grant (upsertObservedItem for the ingest-audit-feed item, engine/src/sched/scheduler-do.ts). The secret and its hash are never written there, exactly as they are never written to the trail.
How a mint and a revoke appear in the trail itself
Opening or closing a feed credential is itself an audited event, because exposing a readable surface carries the same custody weight as a role change. A mint records a support-credential-grant and a revoke records a support-credential-revoke, each on a supportcredential target that carries the closed scope and the public client id, with the expiry on a grant (the support-credential-grant and support-credential-revoke actions in engine/src/admin/audit.ts). The secret exists once in the mint response, and neither it nor its hash reaches the log. So the feed records its own lifecycle, and a collector watching the feed sees when its own credential was granted or revoked.
Where this fits
This page owns the feed mechanics. The chain itself, the on-screen verify, and the manual JSON or CSV export are described in the audit log: hash-chained, redaction-safe, and how to verify, export and feed your SIEM, which links here for the SIEM detail.
For the other scope of the same minting mechanism, the short-lived vendor diagnostics credential, read owner-minted pull credentials. To see every minted credential’s expiry in one place, including this feed’s, read the credential lifecycle registry.
For why the feed holds operator identity rather than being scrubbed of it, and how the platform holds the line between detection and prevention in its wording, read precise claims and honesty.
Last updated .