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...

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