Skip to content

Request Signing & Webhook Auth

Two related middleware ship with Slingshot for authenticating inbound requests with HMAC. requireSignedRequest() enforces a canonical Slingshot signing scheme over the entire request, useful for M2M traffic and partner integrations you control. webhookAuth() is the flexible counterpart for vendor-specific schemes — Stripe, GitHub, Twilio — that all sign their bodies slightly differently.

requireSignedRequest() builds a canonical string of METHOD\nPATH\nQUERY\nTIMESTAMP\nBODY, verifies the HMAC against your signing secret, and rejects requests whose timestamp is outside a tolerance window (default 5 minutes) to prevent replay. It activates only when security.signing.requestSigning is configured; otherwise it is a pass-through.

webhookAuth() is configured per-route. You point it at the signature header, the timestamp header, the algorithm, and an optional prefix to strip — vendor-specific knobs. It HMACs the raw request body and uses timing-safe comparison.

app.config.ts
import { defineApp } from '@lastshotlabs/slingshot';
export default defineApp({
security: {
signing: {
secret: process.env.PARTNER_HMAC_SECRET!,
requestSigning: { tolerance: 5 * 60 * 1000 },
},
},
});

Then mount the middleware on routes that should reject unsigned traffic:

src/partners/package.ts
import { z } from 'zod';
import { definePackage, domain, requireSignedRequest, route } from '@lastshotlabs/slingshot';
export const partnersPackage = definePackage({
name: 'partners',
middleware: { signed: requireSignedRequest() },
domains: [
domain({
name: 'sync',
basePath: '/partners',
routes: [
route.post({
path: '/orders',
middleware: ['signed'],
request: { body: z.object({ orderId: z.string() }) },
handler: async ({ body, respond }) => respond.json({ ok: true, ...body }),
}),
],
}),
],
});

Clients sign the canonical string with the same secret and send X-Signature plus X-Timestamp. Header names are configurable, but the defaults match HEADER_SIGNATURE and HEADER_TIMESTAMP.

Each vendor signs a little differently. Configure webhookAuth() to match:

src/webhooks/package.ts
import { definePackage, domain, route, webhookAuth } from '@lastshotlabs/slingshot';
export const webhooksPackage = definePackage({
name: 'webhooks',
middleware: {
githubWebhook: webhookAuth({
secret: process.env.GITHUB_WEBHOOK_SECRET!,
header: 'x-hub-signature-256',
prefix: 'sha256=',
algorithm: 'sha256',
timestamp: { header: 'x-github-delivery', tolerance: 5 * 60 * 1000 },
}),
},
domains: [
domain({
name: 'github',
basePath: '/webhooks/github',
routes: [
route.post({
path: '/',
middleware: ['githubWebhook'],
handler: async ({ respond }) => respond.json({ received: true }),
}),
],
}),
],
});

secret can be a function (c) => string | Promise<string> for per-tenant secret lookup.

If you need to sign or verify outside of middleware — e.g. to call a partner — use the exported helpers:

import { hmacSign, hmacVerify } from '@lastshotlabs/slingshot';
const payload = { invoiceId: 'inv_123', amount: 9900 };
const body = JSON.stringify(payload);
const sig = hmacSign(body, process.env.PARTNER_HMAC_SECRET!);
const ok = hmacVerify(body, sig, process.env.PARTNER_HMAC_SECRET!);

hmacVerify accepts a string[] of secrets so you can rotate keys: put the new secret first, keep the old one until in-flight signatures expire, then drop it.