Skip to main content
Deliveries to a generic HTTPS drain are signed with HMAC-SHA256, so your receiver can prove a batch came from Lua and has not been altered or replayed. Verify every request before you parse it.
Signing applies to http drains only. OTLP, Datadog, and Better Stack deliveries carry no X-Lua-Signature header — those destinations authenticate the sender with the API key or token you configured.
X-Lua-Signature is the signature header to verify, and now the only one there is. For one release, a drain created by the workflow SIEM webhook migration also carried X-Lua-Signature-Legacy and X-Lua-Timestamp, the retired webhook’s own scheme, so such a receiver could move across without a gap. Both have now been withdrawn, on every drain: a receiver still verifying the retired pair is failing verification today, not at some later date. Treat that scheme as something to diff an old implementation against, never as a second supported one.Note the unit if you are still moving: the drain timestamp is inside X-Lua-Signature as t= and is whole seconds, where the retired X-Lua-Timestamp carried milliseconds. Passing one to a verifier written for the other produces a signature that can never match.

The header

During a rotation window the header carries two v1 values. The active secret’s signature is always first. Accept a request when any v1 matches any secret you hold.

What is signed

Three rules decide whether your implementation works:
Sign over the body after gunzip. Every delivery arrives with Content-Encoding: gzip, and the signature covers the bytes before compression. Frameworks that transparently inflate request bodies make this easy to get wrong in either direction — read the raw bytes yourself and decompress explicitly.
  • The separator is a literal . between the timestamp and the body. The timestamp is the decimal integer from t, with no padding.
  • Reject a header that is not in canonical form, rather than coercing it. t is 0 or a digit string with no leading zero, no sign, no fractional part and no 0x prefix; each v1 is exactly 64 lowercase hex characters. Lua sends nothing else, and a lenient parser is one an attacker can steer.
  • Compare in constant time, and compare bytes. Use timingSafeEqual, hmac.compare_digest, or hmac.Equal. A plain == on the hex string leaks how much of your secret an attacker has guessed.
  • Reject a timestamp more than 5 minutes from now, in either direction. The tolerance is 300 seconds and it is inclusive — exactly 300 seconds of skew still verifies. That is the replay window; the platform never signs a batch it will not send within it.
  • Bound the decompression, and answer 413. You have to inflate before you can verify, so the gunzip runs on a body nobody has authenticated yet. See below.

Bound the decompression

A receiver that inflates without a cap can be stopped by a request the size of this paragraph. Because the signature covers the uncompressed body, decompression necessarily happens before the HMAC can refuse anything — on bytes from an unauthenticated caller. A kilobyte of gzipped zeros expands to a gigabyte, and your process is gone before the signature is ever checked.
Two caps close it. Both answer 413. Content-Length is a claim, not a fact — a sender can understate it, omit it, or use chunked transfer encoding — so both checks are needed. The two caps do different jobs. 2 MiB is the admission limit. 8 MiB is not a promise to accept 8 MiB bodies: a body comfortably under 2 MiB can still expand past 8 MiB, which is exactly what a bomb is, and that is what the second cap catches. Raise them together or not at all — lifting only the inflated cap admits nothing new. Why these numbers. A signed delivery is always a generic HTTPS drain, and those batches are capped at 500 records / 1 MiB uncompressed. gzip grows even incompressible input by about 0.04%, so a full batch arrives just over 1 MiB on the wire: 2 MiB admits every real delivery with a factor of two to spare. 8 MiB is eight times the batch ceiling — nothing we send reaches it, and it bounds what a stranger can make your receiver allocate. Pick different numbers if your process wants them; this is your policy, not ours. A 413 is terminal and is recorded on the delivery, so a cap set too low shows up in lua drains status rather than failing silently. Use the primitive that refuses to allocate, not one that inflates and then measures: The receivers below do all of this. So does the Node receiver on the Generic HTTPS page.

Test vector

Check your implementation against this before you wire it to a real drain. The body is a single line with no trailing newline.
The same value from a shell, which is also the quickest way to sign a replay of your own:

Verifiers

Each of these accepts a list of secrets — the active one and, during a rotation, the previous one — and each checks every v1 against every secret without returning early, so its timing says nothing about which one matched: the request is accepted when any v1 matches any secret you hold. All three reject a non-canonical t and a v1 that is not 64 lowercase hex characters before doing any work, which is the behaviour the platform’s own reference verifiers implement.

Rotate a secret

Rotation overlaps two secrets so there is no window where deliveries fail. The platform signs with both and your receiver accepts either.
1

Start the rotation

Output
From this moment every delivery carries two v1= values: the new secret’s first, the previous secret’s second. The window is 24 hours.
2

Roll the new secret out

Put the new value where your verifier reads the active secret, and move the old value to where it reads the previous one. The verifiers above take a list, so both are accepted throughout.Deploy, then confirm with a test delivery:
3

Close the window

Deliveries drop back to one v1 value, signed with the new secret. Remove the old value from your receiver. If you do nothing, the window closes on its own after 24 hours and has the same effect.
Starting a second rotation while a window is open is refused with DRAIN_ROTATION_IN_PROGRESS. Finalize the first one, or pass --finalize to close it and open a new one in the same step.

If it isn’t working

Almost always the compressed body. Compute the HMAC over the bytes after gunzip, not over what arrived. Check by logging the byte length you are signing: it should match the batch’s JSON length, not the smaller compressed length.
A rotation is open and your receiver takes only one secret, matching the first v1 it finds. The active secret’s signature is first, so a receiver still holding only the previous secret fails every request. Accept a list of secrets, and check every v1 against every one of them.
Your host’s clock has drifted. The tolerance is 5 minutes in either direction, which is generous; if you are failing inside it, run NTP. Do not widen the window past 5 minutes — that is the replay protection.
Your size caps are below what the drain sends. A generic HTTPS batch is capped at 500 records / 1 MiB uncompressed, so the recommended 2 MiB compressed / 8 MiB inflated leaves a wide margin — if you are hitting it with real deliveries, one of your caps is set in the wrong unit or is guarding the wrong body. Raise the cap rather than removing it: 413 is terminal, so every batch that hits it is dropped, and lua drains status <id> shows the drain moving toward failing.
It cannot be read back; it is shown once at creation and once per rotation. Run lua drains rotate-secret <id> to mint a new one and deploy it. Because rotation overlaps, nothing is lost while you do it — but you will not be able to verify with the old value, so finalize as soon as the new one is live.

Next steps

Generic HTTPS

The full request contract and ownership verification.

Event schema

What is inside the body you just verified.

Delivery guarantees

At-least-once, retry, and drop semantics.

Security and data

What the platform scrubs and keeps.