---
title: Signature format
description: The Sigil-Signature wire format — header grammar, signed bytes, and stability guarantees.
url: https://pr-1-10d18e06d3bf.thally.app/signature-format
---

# Signature format

The Sigil-Signature wire format — header grammar, signed bytes, and stability guarantees.

A signed request carries one HTTP header. Its name is `Sigil-Signature`,
exported as the constant `HEADER_NAME`.

```text
Sigil-Signature: t=1767225600,v1=6b1f…0a3c
```

## Header grammar

The value is a comma-separated list of `key=value` elements. Whitespace
around an element is ignored.

| Key  | Required | Repeats | Value                                       |
| ---- | -------- | ------- | ------------------------------------------- |
| `t`  | yes      | no      | Unix time in whole, non-negative seconds    |
| `v1` | yes      | yes     | 64 lower-case hex characters (HMAC-SHA256)  |

### Timestamp rules

`t` must be a whole, non-negative number of seconds. Leading zeros, a
leading `+`, decimals, and exponent forms are all rejected — `Number()`
accepts them, and two verifiers that disagreed about what `t=1.5e3` meant
would disagree about whether a request was fresh.

### Signature rules

A `v1` value must be exactly 64 lower-case hex characters. Upper-case is
rejected rather than folded, so a digest has exactly one valid encoding.

### Unknown keys

Elements with any key other than `t` or `v1` are **ignored, not rejected**.
This allows a future `v2` scheme to ship alongside `v1` — an old verifier
skips what it does not recognise and keeps checking the `v1` it does.

### Signature limit

At most `MAX_SIGNATURES_PER_HEADER` — **8** — `v1` elements may appear in
one header. More than one is only meaningful during
[key rotation](/key-rotation). The ceiling exists because each element costs
the verifier an HMAC computation, and an unbounded list would let a caller
choose how much work to charge the receiver.

## Parsing and formatting

```ts
import { parseHeader, formatHeader } from '@sigil/core';

const parsed = parseHeader('t=1767225600,v1=' + 'a'.repeat(64));
// { timestamp: 1767225600, signatures: ['aaaa…'] }

const wire = formatHeader(parsed);
// "t=1767225600,v1=aaaa…"
```

`parseHeader` returns a `ParsedHeader` with a numeric `timestamp` and an
array of hex `signatures` in the order they appeared. `formatHeader` renders
it back to wire format.

## Signed bytes

The HMAC-SHA256 digest covers the timestamp, a single ASCII dot, then the
raw body:

```text
HMAC-SHA256(secret, "<t>." + body)
```

`signingPayload(timestamp, body)` returns those bytes for inspection. A
string body is encoded as UTF-8; a `Uint8Array` is used as-is.

The timestamp is inside the signed material on purpose. If it travelled
beside an unbound signature, anyone could replay a captured body under a
fresh timestamp and it would still verify.

## Stability

`SIGNATURE_VERSION` is `v1`. The prefix, the dot separator, the digest
algorithm, and the hex encoding are all part of the wire contract — changing
any of them breaks every deployed receiver and requires a new version prefix
rather than an edit to this one.