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

# Verify signatures

> The X-Lua-Signature header format, what exactly is signed, and constant-time verifiers in Node, Python, and Go that survive a secret rotation

Deliveries to a [generic HTTPS](/drains/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.

<Note>
  Signing applies to `http` drains only. [OTLP](/drains/opentelemetry), [Datadog](/drains/datadog), and [Better Stack](/drains/better-stack) deliveries carry no `X-Lua-Signature` header — those destinations authenticate the sender with the API key or token you configured.
</Note>

## The header

```text theme={null}
X-Lua-Signature: t=1789217999,v1=3516b2d0b82681c2584d962b18cdc2ef52597213c09438eb7e612f913c4a3361
```

| Part | Meaning                                                   |
| ---- | --------------------------------------------------------- |
| `t`  | Unix seconds when the batch was signed                    |
| `v1` | Lowercase hex HMAC-SHA256 of the signed string, version 1 |

During a [rotation window](#rotate-a-secret) 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.

```text theme={null}
X-Lua-Signature: t=1789217999,v1=<signed with the new secret>,v1=<signed with the previous secret>
```

## What is signed

```text theme={null}
signed string = "<t>" + "." + <uncompressed body>
signature     = lowercase_hex(HMAC_SHA256(secret, signed string))
```

Three rules decide whether your implementation works:

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

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

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

```text theme={null}
secret: whsec_ExampleSecretDoNotUse_0123456789
t:      1789217999
body:   {"schemaUrl":"https://docs.heylua.ai/schemas/logs/1.0","batchId":"01JBW8N2Q4Z6H8Y0KX3R9T5V7M","resource":{"service.name":"support-agent","service.namespace":"org_4f2c9a1b","service.instance.id":"agent_demo","deployment.environment.name":"production","gen_ai.agent.id":"agent_demo","lua.org.id":"org_4f2c9a1b","lua.agent.id":"agent_demo"},"records":[]}

v1:     3516b2d0b82681c2584d962b18cdc2ef52597213c09438eb7e612f913c4a3361
```

The same value from a shell, which is also the quickest way to sign a replay of your own:

```bash theme={null}
T=1789217999
BODY='{"schemaUrl":"https://docs.heylua.ai/schemas/logs/1.0","batchId":"01JBW8N2Q4Z6H8Y0KX3R9T5V7M","resource":{"service.name":"support-agent","service.namespace":"org_4f2c9a1b","service.instance.id":"agent_demo","deployment.environment.name":"production","gen_ai.agent.id":"agent_demo","lua.org.id":"org_4f2c9a1b","lua.agent.id":"agent_demo"},"records":[]}'

printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac 'whsec_ExampleSecretDoNotUse_0123456789' \
  | awk '{print $NF}'
```

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

<CodeGroup>
  ```js Node.js theme={null}
  import express from 'express';
  import { createHmac, timingSafeEqual } from 'node:crypto';
  import { gunzipSync } from 'node:zlib';

  const TOLERANCE_SECONDS = 300;
  const CANONICAL_T = /^(0|[1-9][0-9]*)$/;
  const LOWER_HEX_64 = /^[0-9a-f]{64}$/;
  const SECRETS = [
    process.env.LUA_DRAIN_SECRET,
    process.env.LUA_DRAIN_SECRET_PREVIOUS,
  ].filter(Boolean);

  export function verifySignature(header, rawBody, secrets, nowSeconds = Math.floor(Date.now() / 1000)) {
    if (!header || secrets.length === 0) return false;

    let t = null;
    const candidates = [];
    for (const piece of header.split(',')) {
      const eq = piece.indexOf('=');
      if (eq < 0) return false;
      const key = piece.slice(0, eq).trim();
      const value = piece.slice(eq + 1).trim();
      // Reject, never coerce: Number() would happily take '01789217999',
      // '1789217999.5' and '0x6aa54ccd', and none of those is a `t` Lua sends.
      if (key === 'v1') {
        if (!LOWER_HEX_64.test(value)) return false;
        candidates.push(value);
      } else if (key === 't') {
        if (!CANONICAL_T.test(value)) return false;
        t = Number(value);
      }
    }
    if (t === null || !Number.isSafeInteger(t) || candidates.length === 0) return false;
    if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false; // inclusive at 300

    const base = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);

    let ok = false;
    for (const secret of secrets) {
      const expected = createHmac('sha256', secret).update(base).digest();
      for (const candidate of candidates) {
        const given = Buffer.from(candidate, 'hex');
        if (given.length === expected.length && timingSafeEqual(given, expected)) ok = true;
      }
    }
    return ok;
  }

  const app = express();

  // inflate:false keeps the compressed bytes so we decompress them ourselves.
  app.post('/lua', express.raw({ type: '*/*', limit: '4mb', inflate: false }), (req, res) => {
    const rawBody = req.get('content-encoding') === 'gzip' ? gunzipSync(req.body) : req.body;

    if (!verifySignature(req.get('X-Lua-Signature'), rawBody, SECRETS)) {
      return res.status(401).json({ error: 'bad signature' });
    }

    const token = req.get('X-Lua-Verify');
    if (token) res.set('X-Lua-Verify', token);

    const batch = JSON.parse(rawBody.toString('utf8'));
    for (const record of batch.records) store(batch.resource, record);

    res.status(204).end();
  });

  function store(resource, record) {
    console.log(resource['lua.agent.id'], record.id, record.eventName, record.severityText);
  }

  app.listen(8080);
  ```

  ```python Python theme={null}
  import gzip
  import hashlib
  import hmac
  import json
  import os
  import re
  import time

  from flask import Flask, request, Response

  TOLERANCE_SECONDS = 300
  CANONICAL_T = re.compile(r"\A(0|[1-9][0-9]*)\Z")
  LOWER_HEX_64 = re.compile(r"\A[0-9a-f]{64}\Z")
  SECRETS = [
      s for s in (os.environ.get("LUA_DRAIN_SECRET"), os.environ.get("LUA_DRAIN_SECRET_PREVIOUS")) if s
  ]


  def verify_signature(header, raw_body, secrets, now=None):
      if not header or not secrets:
          return False

      timestamp = None
      candidates = []
      for piece in header.split(","):
          key, sep, value = piece.partition("=")
          if not sep:
              return False
          key, value = key.strip(), value.strip()
          # Reject, never coerce: int() accepts "01789217999", "+1789217999" and
          # surrounding whitespace, and none of those is a `t` Lua sends.
          if key == "v1":
              if not LOWER_HEX_64.match(value):
                  return False
              candidates.append(bytes.fromhex(value))
          elif key == "t":
              if not CANONICAL_T.match(value):
                  return False
              timestamp = value
      if timestamp is None or not candidates:
          return False

      t = int(timestamp)
      if abs(int(now if now is not None else time.time()) - t) > TOLERANCE_SECONDS:  # inclusive at 300
          return False

      base = f"{t}.".encode("utf-8") + raw_body

      ok = False
      for secret in secrets:
          expected = hmac.new(secret.encode("utf-8"), base, hashlib.sha256).digest()
          for candidate in candidates:
              # compare_digest is constant time, over bytes; no early return, so
              # timing says nothing about which secret or which v1 matched.
              if hmac.compare_digest(expected, candidate):
                  ok = True
      return ok


  app = Flask(__name__)


  @app.post("/lua")
  def receive():
      raw_body = request.get_data()  # Werkzeug does not inflate request bodies for us
      if request.headers.get("Content-Encoding") == "gzip":
          raw_body = gzip.decompress(raw_body)

      if not verify_signature(request.headers.get("X-Lua-Signature", ""), raw_body, SECRETS):
          return Response('{"error":"bad signature"}', status=401, mimetype="application/json")

      response = Response(status=204)
      token = request.headers.get("X-Lua-Verify")
      if token:
          response.headers["X-Lua-Verify"] = token

      batch = json.loads(raw_body)
      for record in batch["records"]:
          store(batch["resource"], record)

      return response


  def store(resource, record):
      print(resource["lua.agent.id"], record["id"], record["eventName"], record["severityText"])
  ```

  ```go Go theme={null}
  package main

  import (
  	"bytes"
  	"compress/gzip"
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"encoding/json"
  	"fmt"
  	"io"
  	"log"
  	"net/http"
  	"os"
  	"regexp"
  	"strconv"
  	"strings"
  	"time"
  )

  const toleranceSeconds int64 = 300

  var (
  	canonicalT = regexp.MustCompile(`^(0|[1-9][0-9]*)$`)
  	lowerHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
  	secrets    = loadSecrets()
  )

  func loadSecrets() []string {
  	var out []string
  	for _, name := range []string{"LUA_DRAIN_SECRET", "LUA_DRAIN_SECRET_PREVIOUS"} {
  		if v := os.Getenv(name); v != "" {
  			out = append(out, v)
  		}
  	}
  	return out
  }

  // VerifySignature checks X-Lua-Signature against the UNCOMPRESSED body.
  func VerifySignature(header string, rawBody []byte, keys []string, now time.Time) bool {
  	if header == "" || len(keys) == 0 {
  		return false
  	}

  	var ts int64
  	haveTS := false
  	var candidates []string
  	for _, piece := range strings.Split(header, ",") {
  		key, value, found := strings.Cut(piece, "=")
  		if !found {
  			return false
  		}
  		key, value = strings.TrimSpace(key), strings.TrimSpace(value)
  		// Reject, never coerce: ParseInt accepts "01789217999" and "+1789217999",
  		// and hex.DecodeString accepts uppercase — neither is what Lua sends.
  		switch key {
  		case "t":
  			if !canonicalT.MatchString(value) {
  				return false
  			}
  			n, err := strconv.ParseInt(value, 10, 64)
  			if err != nil {
  				return false
  			}
  			ts, haveTS = n, true
  		case "v1":
  			if !lowerHex64.MatchString(value) {
  				return false
  			}
  			candidates = append(candidates, value)
  		}
  	}
  	if !haveTS || len(candidates) == 0 {
  		return false
  	}
  	if delta := now.Unix() - ts; delta > toleranceSeconds || delta < -toleranceSeconds { // inclusive at 300
  		return false
  	}

  	base := append([]byte(strconv.FormatInt(ts, 10)+"."), rawBody...)

  	ok := false
  	for _, key := range keys {
  		mac := hmac.New(sha256.New, []byte(key))
  		mac.Write(base)
  		expected := mac.Sum(nil)
  		for _, candidate := range candidates {
  			given, err := hex.DecodeString(candidate)
  			if err != nil {
  				continue
  			}
  			// hmac.Equal is constant time; no early return, so timing says nothing.
  			if hmac.Equal(given, expected) {
  				ok = true
  			}
  		}
  	}
  	return ok
  }

  type batch struct {
  	BatchID  string            `json:"batchId"`
  	Resource map[string]any    `json:"resource"`
  	Records  []json.RawMessage `json:"records"`
  }

  func receive(w http.ResponseWriter, r *http.Request) {
  	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20))
  	if err != nil {
  		http.Error(w, "too large", http.StatusRequestEntityTooLarge)
  		return
  	}

  	rawBody := body
  	if r.Header.Get("Content-Encoding") == "gzip" {
  		zr, gzErr := gzip.NewReader(bytes.NewReader(body))
  		if gzErr != nil {
  			http.Error(w, "bad gzip", http.StatusBadRequest)
  			return
  		}
  		defer zr.Close()
  		if rawBody, err = io.ReadAll(zr); err != nil {
  			http.Error(w, "bad gzip", http.StatusBadRequest)
  			return
  		}
  	}

  	if !VerifySignature(r.Header.Get("X-Lua-Signature"), rawBody, secrets, time.Now()) {
  		http.Error(w, "bad signature", http.StatusUnauthorized)
  		return
  	}

  	if token := r.Header.Get("X-Lua-Verify"); token != "" {
  		w.Header().Set("X-Lua-Verify", token)
  	}

  	var b batch
  	if err := json.Unmarshal(rawBody, &b); err != nil {
  		http.Error(w, "bad json", http.StatusBadRequest)
  		return
  	}
  	for _, record := range b.Records {
  		store(b.Resource, record)
  	}

  	w.WriteHeader(http.StatusNoContent)
  }

  func store(resource map[string]any, record json.RawMessage) {
  	var r struct {
  		ID           string `json:"id"`
  		EventName    string `json:"eventName"`
  		SeverityText string `json:"severityText"`
  	}
  	if err := json.Unmarshal(record, &r); err != nil {
  		return
  	}
  	fmt.Println(resource["lua.agent.id"], r.ID, r.EventName, r.SeverityText)
  }

  func main() {
  	http.HandleFunc("/lua", receive)
  	log.Fatal(http.ListenAndServe(":8080", nil))
  }
  ```
</CodeGroup>

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

<Steps>
  <Step title="Start the rotation">
    ```bash theme={null}
    lua drains rotate-secret drn_9f31a7c04b2e615d8a03cc71
    ```

    ```text Output theme={null}
    ✔ Rotation started. The previous secret stays valid until 2026-09-22T09:14:02Z.

    New signing secret (shown once — store it now):

      whsec_5Nd8kT2aQ7wR1yE4uM0pZ3xV6bJ9cL8f
    ```

    From this moment every delivery carries two `v1=` values: the new secret's first, the previous secret's second. The window is 24 hours.
  </Step>

  <Step title="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:

    ```bash theme={null}
    lua drains test drn_9f31a7c04b2e615d8a03cc71
    ```
  </Step>

  <Step title="Close the window">
    ```bash theme={null}
    lua drains rotate-secret drn_9f31a7c04b2e615d8a03cc71 --finalize
    ```

    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.
  </Step>
</Steps>

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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="Generic HTTPS" href="/drains/generic-https">The full request contract and ownership verification.</Card>
  <Card title="Event schema" href="/drains/event-schema">What is inside the body you just verified.</Card>
  <Card title="Delivery guarantees" href="/drains/delivery-guarantees">At-least-once, retry, and drop semantics.</Card>
  <Card title="Security and data" href="/concepts/security-and-data">What the platform scrubs and keeps.</Card>
</Columns>
