Product documentation
Actions / Hooks: verifying the request signature
The HMAC-SHA256 request-signature scheme every hook stage uses — plus the optional Ed25519 detached-JWS mode verified against a published JWKS — the canonical string, copy-paste Node and Python verification, the replay window, and X-Thoryn-Hook-Stage routing.
Verifying the hook request signature
Every request Thoryn sends to a tenant hook endpoint — for any stage
(token.pre-issuance, registration.pre-create, user.created, and future
stages) — is signed the same way: an HMAC-SHA256 over a canonical string,
carried in X-Thoryn-Signature, with X-Thoryn-Timestamp for replay defence and
X-Thoryn-Hook-Stage so one endpoint can route across stages. Verify it on every
request before you trust the body.
The signing secret is the per-hook value returned exactly once when you created
the hook (POST /api/v1/hooks). Store it securely; it is never returned again.
POST /api/v1/hooks/{id}/signing/rotate issues fresh material and — with a graceful
overlap — keeps the previous material valid for a grace window (see
Rotating the signing material and
Rotation overlap).
For end users
- Read the three
X-Thoryn-*headers off the incoming request. - Check
X-Thoryn-Timestampis within ±5 minutes of now — reject otherwise. - Recompute the HMAC over the canonical string and compare in constant time.
- Only then parse the body and act on it. Route on
X-Thoryn-Hook-Stageif one endpoint serves several stages.
The headers
| Header | Value |
|---|---|
X-Thoryn-Signature | sha256=<hex> — lowercase hex of HMAC-SHA256(canonicalString, secret) |
X-Thoryn-Timestamp | Unix time in seconds — the value used in the canonical string |
X-Thoryn-Hook-Stage | the stage id (e.g. token.pre-issuance) — lets one URL route across stages |
The canonical string (exact)
Sign the timestamp and the raw request body, joined by a single ASCII dot:
{X-Thoryn-Timestamp}.{raw request body bytes}
- The timestamp is the exact string sent in
X-Thoryn-Timestamp(Unix seconds). - The body is the raw bytes exactly as received — not a re-serialized copy. Re-encoding parsed JSON can reorder keys or change whitespace and will break the HMAC. Capture the raw body before your JSON middleware parses it.
- The MAC output is lowercase hex;
X-Thoryn-Signatureprefixes it withsha256=.
The signer is byte-for-byte identical across the hub, identity-service, and product-api delivery legs, so one verification routine works for every stage.
Copy-paste verification
Node.js (Express)
Capture the raw body — do not verify against JSON.stringify(req.body).
import crypto from "node:crypto";
import express from "express";
const app = express();
// Give the verifier the exact bytes: keep the raw body around.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function verifyThorynSignature(rawBody, headers, secret) {
const timestamp = headers["x-thoryn-timestamp"];
const signature = headers["x-thoryn-signature"]; // "sha256=<hex>"
if (!timestamp || !signature) return false;
// 1. Replay window: reject anything more than 5 minutes off.
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (Number.isNaN(skew) || skew > 300) return false;
// 2. Recompute over "{timestamp}.{rawBody}".
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody) // the raw Buffer — never re-serialized JSON
.digest("hex");
// 3. Constant-time compare.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/thoryn/hook", (req, res) => {
if (!verifyThorynSignature(req.rawBody, req.headers, process.env.HOOK_SECRET)) {
return res.status(401).end();
}
const stage = req.headers["x-thoryn-hook-stage"]; // route across stages
// ... handle req.body for `stage` ...
res.status(200).json({});
});Python (Flask)
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
HOOK_SECRET = "…the secret shown once on create…"
def verify_thoryn_signature(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Thoryn-Timestamp", "")
signature = headers.get("X-Thoryn-Signature", "") # "sha256=<hex>"
if not timestamp or not signature:
return False
# 1. Replay window: reject anything more than 5 minutes off.
try:
if abs(int(time.time()) - int(timestamp)) > 300:
return False
except ValueError:
return False
# 2. Recompute over "{timestamp}.{raw_body}" (bytes — never re-serialized JSON).
signed = f"{timestamp}.".encode("utf-8") + raw_body
expected = "sha256=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# 3. Constant-time compare.
return hmac.compare_digest(signature, expected)
@app.post("/thoryn/hook")
def hook():
if not verify_thoryn_signature(request.get_data(), request.headers, HOOK_SECRET):
abort(401)
stage = request.headers.get("X-Thoryn-Hook-Stage") # route across stages
# ... handle request.get_json() for `stage` ...
return {}, 200Replay-window guidance
- Thoryn stamps
X-Thoryn-Timestampat send time and signs it into the canonical string, so an attacker cannot alter it without invalidating the signature. - Reject any request whose timestamp is more than ±5 minutes (300 s) from your clock. This bounds how long a captured request stays replayable.
- For stronger protection, cache the
(timestamp, signature)pair for the width of the window and reject an exact repeat — de-dupe in a shared store (e.g. Redis) if your endpoint runs multiple replicas, so a replay cannot hop to a different node. - Keep your server clock in sync (NTP). A drifting clock is the most common cause of spurious rejections.
Rotating the signing material
Rotate a hook's signing secret or key at any time — you never have to delete and re-create the hook:
POST /api/v1/hooks/{id}/signing/rotate Authorization: Bearer <tenant:hooks.write>
Content-Type: application/json
{ "overlapWindowSeconds": 86400 } // optional; default 24h, clamped to [60, 604800]
The response returns the new material once, exactly as create does:
Hook signingAlg | Response carries | Notes |
|---|---|---|
hmac | secret — the new shared secret | Shown once; not retrievable afterwards. |
ed25519 | verificationKeys — the public keys now published | The new key plus the retired one, for the window. The private key is never returned. |
Both shapes carry previousExpiresAt: the instant the previous material stops validating. Adopt the
new material before then.
- The new material is active immediately; the previous secret/key keeps verifying until
previousExpiresAt, so you can roll it with zero failed verifications. - One rotation at a time. Starting a second while the overlap is still open returns
409witherrorCode: rotation_in_flight— it would leave three live generations. Wait for the window, or set a shorteroverlapWindowSecondson the first rotation. - For an
ed25519hook the JWKS carries both public keys for the window (see below), so a receiver that re-fetches finds eitherkid. - Every rotation writes an audit row (
hook.signing.rotated) visible in your tenant audit log; the row records the stage, the algorithm, the window, and the public key ids — never the material.
Rotation overlap: accept multiple signatures
When a hook's signing secret or key is rotated with a graceful overlap, the new material signs immediately and the previous material keeps validating for a grace window — so you can roll the new secret/key with zero failed verifications. During that window Thoryn dual-signs: the signature header carries the current and the previous signature, comma-separated.
X-Thoryn-Signature: sha256=<new-hex>,sha256=<old-hex>(HMAC), orX-Thoryn-Signature-JWS: <new-jws>,<old-jws>(Ed25519 — each JWS carries its ownkid).
Make your verifier tolerant of more than one signature: split the header on ,, verify each
candidate, and accept if any one matches (still inside the ±5-minute replay window). Outside a
rotation the header is a single value, exactly as shown above, so this changes nothing for the steady
state. Once the window closes only the current signature is sent, and the retired secret/key stops
validating.
// HMAC — accept-any during a rotation overlap.
const candidates = (headers["x-thoryn-signature"] || "").split(",");
const ok = candidates.some((sig) => {
const expected = "sha256=" + crypto
.createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
const a = Buffer.from(sig.trim()); const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});For Ed25519, split X-Thoryn-Signature-JWS on , and run the single-JWS check below against each part
— a receiver that has cached the JWKS with both the current and previous kid verifies whichever
matches.
Routing across stages with X-Thoryn-Hook-Stage
One endpoint can serve several stages. Branch on X-Thoryn-Hook-Stage after
verifying the signature — never before, and never trust the stage field in the
body over the header. The stage ids are stable wire values:
X-Thoryn-Hook-Stage | Kind | Request/response contract |
|---|---|---|
token.pre-issuance | inline | Claims enrichment at token issuance — see the claims-in-token guide |
registration.pre-create | inline | Sign-up gate (augment or veto) — see the registration & events guide |
user.created | event | Fire-and-forget user-created notification — see the registration & events guide |
Asymmetric signing (Ed25519 detached-JWS)
A hook can opt into an asymmetric signature instead of the shared HMAC secret,
so your receiver verifies with a public key it never has to keep secret. Set
signingAlg to ed25519 when you create the hook (POST /api/v1/hooks). It is
available on every stage Thoryn ships today — the inline token.pre-issuance
and registration.pre-create gates as well as the user.created event.
signingAlg is per hook and opt-in: omit it and the hook keeps the
HMAC scheme described above, unchanged. It is fixed for the life of a hook —
switching schemes is a DELETE plus a fresh POST /api/v1/hooks. A stage whose
signing leg does not yet emit a JWS rejects ed25519 at create time with
400 asymmetric_not_supported_for_stage, so a hook never advertises a signature
it will not receive.
An ed25519 hook signs the request as an Ed25519 detached JWS (RFC 8037 / 7515)
over the same canonical string as the HMAC scheme — {timestamp}.{raw body} —
carried in a new header. X-Thoryn-Timestamp and X-Thoryn-Hook-Stage are sent
exactly as before; the replay window is unchanged.
| Header | Value |
|---|---|
X-Thoryn-Signature-JWS | The detached JWS base64url(header)..base64url(signature) (the middle payload segment is empty) |
You do not receive a secret on create. Instead the create response returns the
public key(s) in verificationKeys, and Thoryn publishes them at a public JWKS URL:
GET /api/v1/hooks/{id}/jwks -> { "keys": [ <OKP Ed25519 JWK>, ... ] }
Fetch that JWKS (no auth needed — it is public key material), cache it, and select
the key whose kid matches the kid in the JWS protected header.
Verify in Node.js
import crypto from "node:crypto";
// Fetch once and cache; refresh when you see an unknown `kid`.
async function thorynJwks(baseUrl, hookId) {
const res = await fetch(`${baseUrl}/api/v1/hooks/${hookId}/jwks`);
return (await res.json()).keys;
}
function verifyThorynJws(rawBody, headers, jwks) {
const timestamp = headers["x-thoryn-timestamp"];
const jws = headers["x-thoryn-signature-jws"]; // "<b64url header>..<b64url sig>"
if (!timestamp || !jws) return false;
// 1. Replay window: reject anything more than 5 minutes off.
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (Number.isNaN(skew) || skew > 300) return false;
const [headerB64, emptyPayload, sigB64] = jws.split(".");
if (emptyPayload !== "") return false; // detached JWS: payload segment is empty
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8"));
const jwk = jwks.find(
(k) => k.kid === header.kid && k.kty === "OKP" && k.crv === "Ed25519",
);
if (!jwk) return false;
// 2. Rebuild the signing input: headerB64 + "." + base64url("{timestamp}." + rawBody).
const payload = Buffer.concat([Buffer.from(timestamp + "."), rawBody]);
const signingInput = Buffer.from(headerB64 + "." + payload.toString("base64url"));
// 3. Ed25519 verify (algorithm is null for Ed25519 in Node).
const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" });
return crypto.verify(null, signingInput, publicKey, Buffer.from(sigB64, "base64url"));
}When a hook's key is rotated with a graceful overlap the request is dual-signed for the grace
window — X-Thoryn-Signature-JWS carries the new and the previous detached JWS, comma-separated,
each with its own kid. Split on , and accept if either verifies (see
Rotation overlap above). For the same window the JWKS
keys array carries both the current and the recently-retired public key, so a receiver that
re-fetches finds either kid — and a delivery already signed with the outgoing key still verifies.
When the window closes the retired key drops out of the document and stops verifying, so refresh your
cached JWKS on an unknown kid rather than pinning one.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature never matches | You signed JSON.stringify(body) instead of the raw received bytes. Capture the raw body before JSON parsing. |
| Signature never matches | You included or dropped the sha256= prefix inconsistently. Compare against the value after sha256=, or add the prefix to your computed hex. |
| Intermittent rejections | Clock skew. The timestamp check is ±5 minutes; sync your server clock via NTP. |
| Works for one stage, not another | You keyed the secret per URL but hooks have per-hook secrets. Each hook (each stage) has its own secret from its own create call. |
| Hex case mismatch | The MAC is lowercase hex. Normalise before comparing, and use a constant-time compare. |
Security notes
- Per-hook secret, shown once. The signing secret is returned only on
POST /api/v1/hooksand onPOST /api/v1/hooks/{id}/signing/rotate, and is stored at Thoryn only in Vault-encrypted form. If you lose it, rotate — see Rotating the signing material; you do not need to delete and re-create the hook. - Always constant-time compare. Use
crypto.timingSafeEqual/hmac.compare_digest, never==, to avoid a timing side-channel on the signature. - Verify before you parse. Treat the body as untrusted until the signature checks
out; reject with
401on any mismatch or a stale timestamp. - HTTPS only. Hook URLs must be HTTPS and are re-validated against the SSRF guard at invocation, not just at registration.
- HMAC by default, Ed25519 optional. The default scheme is a shared HMAC secret. Per-hook you can instead choose the asymmetric Ed25519 detached-JWS mode above, so your receiver verifies with a Thoryn-published public key and never custodies a shared secret.
- Tolerate multiple signatures during a rotation. A graceful secret/key rotation dual-signs for a grace window, so the signature header may carry two comma-separated values (current + previous). Verify each and accept if any matches — see Rotation overlap.