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

# Signing and verification

> Every request we make to your backend is signed. Verifying it is the same work in both integration modes.

When an agent calls an endpoint you host, the request carries a `Patchwork-Signature` header computed over your workspace **request secret**. If the connection uses minted egress, it also carries a short-lived token for the [subject](/guides/subjects).

Two credentials, two questions:

| Credential | Answers |
| - | - |
| The signature | "Is this really Patchwork?" |
| The subject token | "Is this a real grant from a real user?" |

They must never collapse into one, and neither substitutes for the other. This page covers the signature. For where it sits among the other secrets — which key points in which direction, and which ones you need — see [The trust model](/guides/trust-model).

## The signature

```
Patchwork-Signature: t=1756558800,v2=9ad30e71…
```

A header carries one timestamp and one or more signature values. Each value has a **label** that says what it covers. **Patchwork sends `v2`.** During a secret rotation it sends one `v2` per live secret, so a header can carry several.

Each value is `HMAC-SHA256` over your request secret.

### `v2`

```
v2.{t}.{METHOD}.{path}?{query}.{base64(sha256(body))}
```

The same, with the query string included, and a literal `v2.` in front. Two rules matter:

* **The query is signed exactly as it arrived.** No sorting and no re-encoding. `?b=2&a=1` and `?a=1&b=2` are different signatures, and so are `%20` and `+`. Use the raw query string your server received, the way you use the raw body.
* **No query means no `?`.** If the request has no query string, the target is just the path.

The `v2.` prefix is there because without it the two labels would cover identical bytes for a request with no query, and one signature could be replayed as the other.

### `v1`, retired

An earlier label, `v1`, covered the same payload **without** the query string:

```
{t}.{METHOD}.{path}.{base64(sha256(body))}
```

Patchwork no longer sends it. It was retired because a GET tool's arguments travel in the query, so a `v1` signature did not cover them and a request whose arguments were changed in flight still verified.

You do not need to implement it. If you already accept it, you can leave that code in place — nothing emits a `v1` value, and nobody can produce one without your request secret.

### Unknown labels

Ignore a label you do not recognise rather than rejecting the header. That is what lets a new label be added without breaking you, and it is how `v2` was introduced.

## Verifying

```js theme={null}
import crypto from "crypto";

function verifyPatchwork(req, res, next) {
  const header = req.get("Patchwork-Signature");
  if (!header) return res.status(401).end();

  // A header carries one `t` and several values. Collect every `v2`: during a
  // rotation there is one per live secret, so keeping only the last would
  // compare the previous secret's value against your current one.
  const pairs = header.split(",").map((pair) => pair.split("="));
  const t = pairs.find(([k]) => k === "t")?.[1];
  const candidates = pairs.filter(([k]) => k === "v2").map(([, v]) => v);
  if (!t || candidates.length === 0) return res.status(401).end();

  // Reject replays.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(401).end();

  // The query exactly as it arrived, and the raw body.
  const query = req.originalUrl.split("?")[1] ?? "";
  const target = query ? `${req.path}?${query}` : req.path;

  const digest = crypto.createHash("sha256").update(req.rawBody ?? "").digest("base64");
  const payload = `v2.${t}.${req.method}.${target}.${digest}`;

  // Try every secret you hold, against every value in the header.
  const ok = [CURRENT_SECRET, PREVIOUS_SECRET].filter(Boolean).some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
    return candidates.some(
      (value) =>
        // timingSafeEqual throws when the lengths differ, which would be a 500.
        value.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected)),
    );
  });

  if (!ok) return res.status(401).end();
  next();
}
```

Six details that break implementations:

* **Use the raw body.** Signing happens over bytes. Re-serialising parsed JSON changes whitespace and the digest will not match.
* **Use the raw query.** Same reason. A parsed-and-rebuilt query string will have been sorted or re-encoded by your framework, and the digest will not match.
* **Compare in constant time.** `===` on an HMAC leaks timing.
* **Collect every value, not one.** A header is a list, not a map. During a rotation Patchwork sends one value per live secret, so `t=…,v2=…,v2=…` is normal. Parsing it into an object keeps only the last and the overlap silently stops verifying.
* **Verify against your current *and* previous secret.** For 24 hours after you rotate, we sign with both, so a backend that has not yet deployed the new value keeps working. Accept if any value matches either secret.
* **Check the length before a constant-time compare.** Node's `timingSafeEqual` throws when the buffers differ in length, so a short or truncated value becomes a 500 instead of a 401.

## Then the subject token

On a `minted` connection the request also carries a JWT for the subject. Verify it with your own public key, read `sub`, and hand off to your normal authorization:

```js theme={null}
const claims = jwt.verify(bearer(req), PUBLIC_KEY, {
  algorithms: ["RS256"],
  audience: "your-api",
});
req.user = { id: claims.sub };
```

Your existing per-user checks then run unchanged. Patchwork never widens what that user could already do.

## The health endpoint

Pressing **Test** on a connection posts a signed nonce to `/patchwork/up`. Echo it with a proof only computable with the shared secret:

```js theme={null}
app.post("/patchwork/up", verifyPatchwork, (req, res) => {
  const proof = crypto
    .createHmac("sha256", REQUEST_SECRET)
    .update(`patchwork-up.v1:${req.body.nonce}`)
    .digest("hex");
  res.json({ nonce: req.body.nonce, proof });
});
```

Returning a bare `ok` will report `reachable`, not `verified` — it cannot fake the proof. Only `verified` means the trust is wired correctly.

## Webhooks use the same scheme

[Webhook deliveries](/guides/events#verifying-a-delivery) are signed identically, but keyed with the **webhook endpoint's own secret** rather than the workspace request secret. Same verification code, different key.

A delivery goes to the URL you registered and carries no query string, so its `v2` target is just the path. During a secret rotation it carries one `v2` per live secret, so a header can hold two values. Accept the request if any value matches any of your secrets.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.