> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Protecting your destination

> What Lua refuses to send to, what it re-checks on every delivery, how destination credentials are stored, and what verification actually proves

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:

| Reason            | What it means                                                                                         |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| `scheme`          | Not `https:`. Plain HTTP is refused, and so is every other scheme                                     |
| `host`            | No host, a loopback name such as `localhost`, or a cloud-metadata name                                |
| `private`         | It resolves to a private, loopback, link-local, carrier-grade NAT, metadata, or unspecified address   |
| `unresolvable`    | DNS returned nothing, or did not answer inside the timeout                                            |
| `not_allowlisted` | A perfectly good public HTTPS endpoint — it simply is not one of the hosts this destination type pins |

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>`.

<Note>
  **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.
</Note>

**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

<Warning>
  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.
</Warning>

### Which destination types pin a host

| Type                                                                                                                                                        | Host rule                                                                                    |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| [`http`](/drains/generic-https), [`otlp`](/drains/opentelemetry), [`betterstack`](/drains/better-stack), [`splunk`](/drains/splunk), [`loki`](/drains/loki) | None — the host is yours. Still `https:` and still public, re-checked every send             |
| [`datadog`](/drains/datadog)                                                                                                                                | The six Datadog intake hosts, exactly                                                        |
| [`newrelic`](/drains/new-relic)                                                                                                                             | The three Log API hosts, exactly                                                             |
| [`axiom`](/drains/axiom)                                                                                                                                    | `.axiom.co` — a **suffix** rule, so `api.eu.axiom.co` is admitted and `evil-axiom.co` is not |
| [`sumo-logic`](/drains/sumo-logic)                                                                                                                          | `.sumologic.com`, the same suffix rule                                                       |
| [`s3`](/drains/object-storage)                                                                                                                              | The three providers' own bucket host shapes, matched before any DNS lookup                   |

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.

| Destination                                                                             | Verification                                                                                           | What it proves                            |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| [Generic HTTPS](/drains/generic-https)                                                  | **Token echo** — Lua sends a challenge and your receiver must echo it                                  | You control the receiver                  |
| [Object storage](/drains/object-storage)                                                | **Bucket challenge** — Lua writes `_lua-verify-<token>` under your prefix and the token must come back | You control the bucket                    |
| Every vendor preset — Datadog, Better Stack, Splunk, Loki, Axiom, New Relic, Sumo Logic | **Test post** — one `lua.drain.test` record, and any `2xx` is accepted                                 | The host answers and the credential works |
| [OTLP](/drains/opentelemetry)                                                           | Test post                                                                                              | The same                                  |

<Warning>
  **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](/reference/rest/log-drains#scopes) that no wildcard grant satisfies — not the verification state. Audit it as you would audit who can add an outbound integration.
</Warning>

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.

| Class        | What it catches                                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `builtin`    | PEM private-key blocks; the password in a `scheme://user:password@host` connection string; JSON values under a credential-shaped key (`password`, `api_key`, `client_secret`, `cookie`, …); credential-shaped URL query parameters (`?token=`, `&api_key=`, …); `Authorization: Bearer …` and `Basic …`; JWTs; Lua API keys; AWS access key ids; and a bare assignment such as `password=…` or `token=…`, quoted or not |
| `lua-token`  | Lua-issued handoff codes and scoped keys                                                                                                                                                                                                                                                                                                                                                                                |
| `vendor-key` | Anthropic, OpenAI, Stripe, Slack, GitHub, Twilio, and Google key shapes, each with a distinctive prefix and a length floor                                                                                                                                                                                                                                                                                              |
| `org:<id>`   | Your own rules, below                                                                                                                                                                                                                                                                                                                                                                                                   |

### 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`](/drains/event-schema#content-and-withheld-values) 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.

<Note>
  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.
</Note>

### Your own rules

A drain's `scrubRules` is a list of `{ id, pattern, flags? }`, compiled once and run **after** the built-ins.

| Limit                  | Value                                                  |
| ---------------------- | ------------------------------------------------------ |
| Rules per organization | 20                                                     |
| Pattern length         | 256 characters                                         |
| Rule id                | `^[a-z0-9][a-z0-9._-]{0,63}$`, unique within the drain |
| Flags                  | Any of `i`, `m`, `s`. `g` and `u` are always applied   |
| Time budget            | 5 ms per record, for all of your rules together        |

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.

<Warning>
  **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.

  ```text theme={null}
  (foo|bar)+          ← refused
  ```

  Rewrite it one of three ways:

  ```text theme={null}
  (?:foo|bar)         ← drop the quantifier
  [ab]+               ← a character class, when the branches are single characters
  (foo|bar){1,8}      ← a bounded repetition
  ```

  Or split it into two rules — you have twenty.
</Warning>

## How a destination credential is held

Three separate secrets, all write-only, all stored encrypted and bound to your organization:

| Secret                                                                              | Set by                                                           | Read back as                                                                                             |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| A header value                                                                      | `--header <name>` (prompted) or `--header-from-env <name>=<VAR>` | `headers[].last4` — the name and the last four characters                                                |
| The HMAC signing secret                                                             | Never an input. The platform mints it                            | `secretLast4`, and the value exactly **once**, at `create` and at `rotate-secret`                        |
| An endpoint-embedded credential — [Sumo Logic](/drains/sumo-logic)'s collector code | Split off the URL at create time                                 | `endpointSecretLast4`. The stored `endpoint` is the prefix only                                          |
| An [object-storage](/drains/object-storage) secret access key                       | `--credentials-from-env <KEY_ID_VAR>=<SECRET_VAR>`               | `bucket.credentials.secretLast4`. The access key **id** is shown, because SigV4 sends it in clear anyway |

`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

```bash theme={null}
lua drains rotate-secret drn_9f31a7c04b2e615d8a03cc71 --json | jq -r '.secret'
# deploy the new value alongside the old one, then:
lua drains rotate-secret drn_9f31a7c04b2e615d8a03cc71 --finalize
```

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](/drains/verify-signatures#rotate-a-secret).

## What a receiver must do

For a [generic HTTPS](/drains/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](/drains/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:

<Warning>
  **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.
</Warning>

## 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](/drains/notifications#what-a-notice-never-contains) 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

<Columns cols={2}>
  <Card title="Verify signatures" href="/drains/verify-signatures">The HMAC recipe, four implementations, and rotation.</Card>
  <Card title="Egress allow-listing" href="/drains/egress">The address deliveries leave from.</Card>
  <Card title="Delivery guarantees" href="/drains/delivery-guarantees">At-least-once, retry, and what a drop looks like.</Card>
  <Card title="Log drains REST" href="/reference/rest/log-drains">Scopes, refusal codes, and the read model.</Card>
</Columns>
