Verifying Oakstack signatures
Every request Oakstack sends to your app, whether a cron job call (clock) or a forwarded webhook (hook), carries an Oakstack-Signature header. Verify it to be sure the request really came from Oakstack and wasn't altered.
Each job and each webhook endpoint has its own signingSecret (it starts with oks_). You get it when you create the job or endpoint, and can view it in the dashboard. Store it as an environment variable, such as OAKSTACK_SIGNING_SECRET.
With the SDK (recommended)
import { verifySignature } from "oakstack";
export async function POST(req: Request) {
const body = await req.text(); // read the raw body BEFORE parsing it
const ok = await verifySignature(
process.env.OAKSTACK_SIGNING_SECRET!,
req.headers.get("oakstack-signature"),
body,
);
if (!ok) return new Response("bad signature", { status: 401 });
const data = JSON.parse(body);
// ... do the work
return Response.json({ ok: true });
}
verifySignature(secret, header, body, { toleranceSeconds }) accepts the body as a string, Uint8Array, or ArrayBuffer. It returns false (never throws) for a missing, malformed, wrong, or stale signature. The default tolerance is 5 minutes.
For binary bodies, pass the bytes: await verifySignature(secret, header, new Uint8Array(await req.arrayBuffer())).
The scheme
Oakstack-Signature: t=1791208931,v1=5f2b0c...
t: Unix time in seconds when Oakstack signed the request.v1: lowercase hex HMAC-SHA256, keyed with your signing secret, oft, then a., then the raw body bytes.
It's the same scheme Stripe uses. Reject the request if t is more than 5 minutes from now, which stops old requests from being replayed.
Without the SDK
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody /* string or Buffer */) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(expected, given);
}
Python:
import hashlib, hmac, time
def verify(secret: str, header: str | None, raw_body: bytes) -> bool:
if not header:
return False
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Common mistakes
- Verifying a parsed body.
JSON.stringify(JSON.parse(body))can differ from the original bytes. Always verify the raw body. - Framework body parsing. In Express, use
express.raw({ type: "*/*" })on the webhook route. In Next.js route handlers, callreq.text()orreq.arrayBuffer()once and reuse the result. - Wrong secret. Each job and endpoint has its own secret. A webhook forwarded through Oakhook is signed with the endpoint's secret, not the original sender's.
- Stripe, GitHub, and similar senders. Oakhook forwards their original signature headers unchanged, so keep verifying those with the sender's own secret as well. Note that a replayed event carries a fresh
Oakstack-Signaturebut the sender's original, older signature.