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. The alternative is a human downloading an export by hand. An Owner mints a scoped audit-feed credential, and your collector presents it on every poll. 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 for an auditor or a detection engineer who is standing up the pull. It covers 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 covers 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. The section on what the feed carries sets this out: 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 already polling a REST endpoint: Microsoft Sentinel, Exabeam, Cribl and Sumo Logic all take it cleanly. Pull uses a single feed credential you mint and revoke yourself rather than a stored secret. Its one friction is the Access perimeter described below: 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 between a 60-second floor and 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 beyond a year.
From console 0.2.7, each scope in the console has an Expires after (days, optional) field above its mint button. Type a whole number of days, from 1 to the scope’s maximum in the table above. If you leave it blank, the engine uses the default. The console refuses a number above the maximum before it mints, so the engine clamp never shortens a term that you chose.
When dual control is on, the first mint does not make a credential. It makes an approval request for a second Owner, and that approval applies to the scope and the term together (ownerActionGate in engine/src/admin/router-status.ts). From engine 0.3.6, the request summary shows the term, so the approver sees it. After the approval, the Owner who made the request mints again with the same term. The console keeps the number in the field for this, and tells you to mint again. If you mint with a different term, the engine does not use the approval and makes a new request.
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 default 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, Google Security Operations feeds and QRadar’s Universal Cloud REST API workflow all take arbitrary request headers. Microsoft Sentinel is the exception; read the callout below before using this step for Sentinel.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 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.
Microsoft Sentinel: use the Bypass step, not the service token step
Sentinel’s RestApiPoller connector has exactly one credential slot in its stored connector definition, the auth block, already spent on the feed bearer. Azure redacts that slot on every read of the connector resource, but the two CF-Access-Client-Id/CF-Access-Client-Secret headers a service token needs have no equivalent slot; they would have to sit in the connector’s plain request.headers object instead, which Azure does not redact. Anyone with read access to the workspace’s data connectors can then read the Access service token back in clear text. There is no safe second slot to move it to: Sentinel’s RestApiPoller supports only Basic, APIKey, OAuth2 and JwtToken credential types, and no client-certificate option, so a second credential cannot be carried by this connector kind at all. For Sentinel specifically, skip the service token step above and use the Bypass step instead: scope the Access application to <host>/support/audit-feed with a Bypass policy, and the feed’s own credential, already redacted correctly by Azure, becomes the sole gate. This exception is specific to Sentinel’s RestApiPoller; the service token step remains correct for the other collectors named above, whose configuration is not exposed the same way.
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,
"earliestSeq": 1204,
"gapBefore": false,
"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 |
earliestSeq | The oldest sequence still retained in the log. Anything below it has already rolled off under retention and can never be delivered |
gapBefore | True when your cursor sat below earliestSeq - 1, so entries between the two were pruned before your collector reached them. Alarm on it |
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 nothing re-read. 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. Send the cursor as a plain non-negative integer: a present but malformed afterSeq is a 400 rather than a silent reset to 0, so a collector persisting a corrupt checkpoint fails loudly instead of replaying the whole retained log forever (handleSupportPull, engine/src/admin/support-ingest.ts).
A collector that stops for long enough can miss events permanently
The cursor is a sequence number, not a timestamp, so there is no clock skew to reason about and no re-read window to size. It does not follow that nothing can be missed. The audit log is retained under a rollover cap, and entries that roll off are gone: a collector that was stopped, locked out at the Access edge, or presenting an expired credential long enough for its cursor to fall behind earliestSeq will never receive the entries in between, and headSeq alone cannot reveal that. This is what gapBefore exists for. Alert on gapBefore being true, and treat the distance between your cursor and earliestSeq as the headroom you have left before a stalled collector starts losing records (earliestSeqOf, engine/src/admin/audit.ts; the gapBefore computation in handleSupportPull, engine/src/admin/support-ingest.ts).
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. 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.Watch the retention floor
The two checks above cover the events you received.
gapBeforecovers the ones you did not: it is true when retention rolled entries off between your cursor and the oldest sequence still held, which no link or sequence check on the delivered batch can see, because the missing entries were never in a batch. Alert on it as a delivery failure, not as an anomaly in the chain.
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. Any edit or deletion of a past entry that is not a full re-chain breaks one of the two checks above. 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 value, a private fingerprint, or a destination’s credential, endpoint, bucket or region (AuditTarget and the redaction-by-construction note in engine/src/admin/audit.ts). The feed carries no customer backup data, no keys and 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. From engine 0.3.6, a time-boxed grant also carries expiresAt, and the rendering ends until <expiresAt> |
grouprole | group-role approver for group SecOps | Your own directory group name and the mapped role. From engine 0.3.6, a mapping scoped to an identity-provider connection also carries its connId, and the rendering ends on connection <connId>, so a scoped mapping and a global mapping for the same group name are two different targets |
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 |
source-bindings | source bindings SRC_R2_media | From engine 0.3.6, on a source attach or detach. The binding names, which can show a source’s name in sanitised form. Never an id, a value or the token |
support-bundle | support bundle download (not sealed) | From engine 0.3.6. The route that served the bundle (download or generate) and whether the engine sealed it to the vendor key. Never the bundle |
idpconnection | oidc connection acme-okta (create) | A native identity-provider connection’s operator-chosen slug and the lifecycle operation. From engine 0.3.6, a SAML create also carries emailVerifiedPolicy, and the rendering names it, for example saml connection acme-saml (create, email trust trust-idp). On a denied row, the engine refuses before it reads the stored connection, so connKind is not the stored kind. It can be oidc for a SAML or OAuth2 connection |
The feed carries operator identity
Because the audit feed carries member emails, source IPs, downpipe labels, source binding names, role and approver emails, and connection ids, it carries personal data and 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. 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. 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. The bearer’s 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.
Microsoft Sentinel
For Microsoft Sentinel, deploy the Codeless connector from the standalone ARM template under integrations/microsoft-sentinel/ in the engine repo. Microsoft Sentinel walks through the deploy.
Where this fits
This page covers 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.
Last updated .