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.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
- The separator is a literal
.between the timestamp and the body. The timestamp is the decimal integer fromt, with no padding. - Reject a header that is not in canonical form, rather than coercing it.
tis0or a digit string with no leading zero, no sign, no fractional part and no0xprefix; eachv1is 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, orhmac.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
Two caps close it. Both answer413.
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.Verifiers
Each of these accepts a list of secrets — the active one and, during a rotation, the previous one — and each checks everyv1 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
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
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.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
Every signature mismatches
Every signature mismatches
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.
It matched yesterday and fails today
It matched yesterday and fails today
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.Intermittent skew failures
Intermittent skew failures
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.
My receiver is answering 413
My receiver is answering 413
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.I lost the secret
I lost the secret
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.

