Skip to content

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

  1. Read the three X-Thoryn-* headers off the incoming request.
  2. Check X-Thoryn-Timestamp is within ±5 minutes of now — reject otherwise.
  3. Recompute the HMAC over the canonical string and compare in constant time.
  4. Only then parse the body and act on it. Route on X-Thoryn-Hook-Stage if one endpoint serves several stages.

The headers

HeaderValue
X-Thoryn-Signaturesha256=<hex> — lowercase hex of HMAC-SHA256(canonicalString, secret)
X-Thoryn-TimestampUnix time in seconds — the value used in the canonical string
X-Thoryn-Hook-Stagethe 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-Signature prefixes it with sha256=.

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 {}, 200

Replay-window guidance

  • Thoryn stamps X-Thoryn-Timestamp at 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 signingAlgResponse carriesNotes
hmacsecret — the new shared secretShown once; not retrievable afterwards.
ed25519verificationKeys — the public keys now publishedThe 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 409 with errorCode: rotation_in_flight — it would leave three live generations. Wait for the window, or set a shorter overlapWindowSeconds on the first rotation.
  • For an ed25519 hook the JWKS carries both public keys for the window (see below), so a receiver that re-fetches finds either kid.
  • 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), or
  • X-Thoryn-Signature-JWS: <new-jws>,<old-jws> (Ed25519 — each JWS carries its own kid).

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-StageKindRequest/response contract
token.pre-issuanceinlineClaims enrichment at token issuance — see the claims-in-token guide
registration.pre-createinlineSign-up gate (augment or veto) — see the registration & events guide
user.createdeventFire-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.

HeaderValue
X-Thoryn-Signature-JWSThe 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

SymptomLikely cause
Signature never matchesYou signed JSON.stringify(body) instead of the raw received bytes. Capture the raw body before JSON parsing.
Signature never matchesYou included or dropped the sha256= prefix inconsistently. Compare against the value after sha256=, or add the prefix to your computed hex.
Intermittent rejectionsClock skew. The timestamp check is ±5 minutes; sync your server clock via NTP.
Works for one stage, not anotherYou 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 mismatchThe 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/hooks and on POST /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 401 on 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.