Skip to main content
A log drain is an outbound pipe from Lua’s network to yours, carrying your agents’ records and a credential you gave us. Four things follow from that, and this page is all four in one place: where a drain may point, what leaves in the payload, how the credential is held, and what “verified” means — which is narrower than it sounds. None of it is configurable. These are properties of the feature, not settings on a drain.

Where a drain may point

A destination URL is checked when the drain is created, and the refusal names the rule that fired: The first four answer 422 DRAIN_ENDPOINT_INVALID; not_allowlisted answers 422 DRAIN_ENDPOINT_NOT_ALLOWED. The message reads endpoint refused by the '<reason>' rule: <detail>.
DNS availability is not a precondition for saving configuration. A host that resolves to nothing is normally accepted at create time and refused later, at delivery — so unresolvable is the one word on that list you are unlikely to meet on a create. It is what a delivery failure looks like instead, as errorClass: "dns", and unlike every other refusal on this page it is retried: a name that does not resolve now may resolve in a minute, while a policy refusal will not become allowed.
Every A and AAAA record is resolved, and the endpoint is refused when any of them lands in a non-public range. An IPv4-mapped IPv6 address such as ::ffff:10.0.0.1 is unwrapped before it is classified — that is the oldest way to smuggle a private address past a check that only looks at the v6 form. The ranges refused include 0.0.0.0/8 and :: — “this network” and the unspecified address, which some stacks route to a local interface — alongside 10/8, 172.16/12, 192.168/16, 100.64/10, 127/8, 169.254/16, fc00::/7, fe80::/10, ::1, and the well-known instance-metadata addresses.

It is re-checked on every single delivery

The create-time check proves the destination was public when the drain was saved. DNS is mutable: the same name can answer 203.0.113.9 today and 169.254.169.254 an hour from now, and that is the whole point of a rebinding attack.So the check runs again on every delivery attempt, and the result is never cached and never carried from one request to the next. A drain that was fine yesterday and resolves somewhere private today stops delivering, with errorClass: "ssrf" on the delivery row. It is not a retry — the bytes would be refused again.

Which destination types pin a host

Splunk and Loki are unpinned for the same reason Better Stack is: both are commonly self-hosted on a domain of yours, and an allow-list there would refuse every legitimate drain.

What “verified” actually proves

This is the distinction most worth reading twice.
A vendor preset is reachability-checked, not ownership-verified. No vendor intake can echo a challenge token back, so a healthy preset drain means “the credential opens that door”, not “that door is yours”. Anyone who can create a drain in your organization can point one at a vendor account they control.The control that matters is therefore who holds logs:manage — a sensitive scope that no wildcard grant satisfies — not the verification state. Audit it as you would audit who can add an outbound integration.
Verification is limited to 5 attempts per drain per hour. Changing a drain’s endpoint sends it back to pending_verification and stops delivery until it re-verifies; records buffer meanwhile.

What is masked before anything leaves

Every record body and every string attribute passes a scrubber on its way to the wire. It cannot be turned off — only extended. There is no flag, no drain field, and no argument that removes a built-in rule, and an empty rule set still runs every one of them. The mask is visible on purpose — [redacted:builtin], [redacted:lua-token], [redacted:vendor-key], or [redacted:org:<your rule id>] — so a value that was scrubbed is distinguishable from one that was never there, and the marker says which rule fired. Scrubbing an already-scrubbed string is a no-op and adds no hits.

What is not masked

  • Message content as such. The scrubber masks credential shapes; it is not a PII engine. End-user text is governed by content sources and includeContent instead.
  • Attribute keys. Only values are scanned. Lua’s attribute keys are a closed set and none is secret-bearing.
  • Numbers and booleans. No credential shape can be expressed in one.
  • A credential with no recognisable shape. A password that looks like an ordinary word, printed on its own with no key name, cannot be told from prose. Add a rule of your own.
Scrubbing is not a substitute for not logging secrets. Anything your code prints with console.log is stored as printed, and the scrubber only catches shapes it recognises.

Your own rules

A drain’s scrubRules is a list of { id, pattern, flags? }, compiled once and run after the built-ins. A rule that fails any of these answers 422 DRAIN_SCRUB_RULE_INVALID at save time. The same function validates and compiles, so a rule that saves is exactly a rule that runs.

The patterns that are refused

Four constructs are rejected, because each of them makes a regular expression exponential on the wrong input — and a drain runs your pattern against every string in every record.
  • Backreferences\1, \k<name>.
  • Lookbehind(?<=…), (?<!…). Named groups, (?<name>…), are fine.
  • Nested unbounded quantifiers — a quantified group whose body itself contains *, +, or {n,}: (a+)+, (\s*\w+)*. A bounded outer quantifier is fine: (ab){2,4}, (a+)?b.
  • Unbounded repetition of an alternation(a|a)+, (?:x|xy)*, ([a-z]|\w){3,}. Repeating branches that can match the same text is the other exponential shape; (a|a)+$ needs only 35 characters of input to hang a process for minutes.
The alternation check does not try to work out whether your branches really overlap, so a pattern that is safe in practice is refused too.
Rewrite it one of three ways:
Or split it into two rules — you have twenty.

How a destination credential is held

Three separate secrets, all write-only, all stored encrypted and bound to your organization: last4 is an empty string rather than a shortened value when the stored secret is under 8 characters. A secret is never a command-line flag, because a shell history and a CI log both keep one. --header prompts; --header-from-env and --credentials-from-env name environment variables whose values never reach argv.

Rotating the signing secret

Rotation opens a 24-hour window during which every delivery carries two signatures — the new secret and the previous one — so a receiver can be updated without dropping a batch. --finalize closes the window early; it closes on its own at 24 hours. A second rotation while a window is open answers 409 DRAIN_ROTATION_IN_PROGRESS. The full receiver-side recipe is on Verify signatures.

What a receiver must do

For a generic HTTPS drain, two obligations, and both matter:
  • Verify the signature before parsing the body, and compare in constant time. The recipe and four working implementations are on Verify signatures.
  • Reject a timestamp more than 300 seconds from now, in either direction. That is the replay window, it is inclusive at exactly 300, and the platform never signs a batch it will not send inside it. Widening it past five minutes gives up the replay protection.
And for every drain, whatever the destination:
Deduplicate on records[].id, never on the batch id. Delivery is at-least-once: a retry after a timeout re-sends the same records under a new batchId, so a consumer keyed on the batch will store the same record twice. records[].id is stable across every retry and every producer of that record.

What Lua never stores

  • No request body. A delivery row holds the status code, latency, byte counts, error class, and at most 1 KB of the destination’s response — scrubbed. It has no field that can hold what was sent.
  • No ownership-challenge token. Only its hash. A read of an object-storage drain’s challenge returns tokenHash: "" and a keyPattern telling you where to look — never the token, because a control plane that could hand it back would make the challenge prove nothing.
  • No credential in an error message. Provider errors carry the vendor’s own error code, never the SDK’s message: an SDK message quotes the signed request, and a signed request carries a credential.
  • No secret in a notice. A drain notice is built from an allow-list of fields, so a secret-bearing field added to a drain in future is absent from every notice until somebody deliberately adds it there.

Next steps

Verify signatures

The HMAC recipe, four implementations, and rotation.

Egress allow-listing

The address deliveries leave from.

Delivery guarantees

At-least-once, retry, and what a drop looks like.

Log drains REST

Scopes, refusal codes, and the read model.