Documentation

Source-of-truth docs, references, plans, and product material across Harbor surfaces.

Commercial And Brand

Harbor Callback Receiver Verification

Harbor Node can sign async execution callbacks with an HMAC SHA-256 header pair:

commercial and brandharborcallbackreceiververification
Source: HARBOR_CALLBACK_RECEIVER_VERIFICATION.md

Harbor Callback Receiver Verification

Harbor Node can sign async execution callbacks with an HMAC SHA-256 header pair:

  • timestamp header: defaults to x-harbor-timestamp
  • signature header: defaults to x-harbor-signature

The signature is computed over:

text
${timestamp}.${rawRequestBody}

The transmitted signature format is:

text
sha256=<hex digest>

Minimal Node.js example

Use the raw request body exactly as Harbor sent it. Do not parse and re-serialize JSON before verification.

typescript
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const signingSecret = process.env.HARBOR_CALLBACK_SIGNING_SECRET ?? "replace-me";
const signatureHeaderName = "x-harbor-signature";
const timestampHeaderName = "x-harbor-timestamp";
const maxSkewMs = 5 * 60 * 1000;

app.post(
  "/harbor/executions",
  express.raw({ type: "application/json" }),
  (request, response) => {
    const rawTimestamp = request.header(timestampHeaderName);
    const rawSignature = request.header(signatureHeaderName);

    if (!rawTimestamp || !rawSignature?.startsWith("sha256=")) {
      response.status(401).json({ ok: false, reason: "missing_signature_headers" });
      return;
    }

    const timestampMs = Date.parse(rawTimestamp);

    if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > maxSkewMs) {
      response.status(401).json({ ok: false, reason: "timestamp_out_of_window" });
      return;
    }

    const rawBody = Buffer.isBuffer(request.body) ? request.body : Buffer.from([]);
    const signedPayload = Buffer.concat([Buffer.from(`${rawTimestamp}.`, "utf8"), rawBody]);
    const expected = createHmac("sha256", signingSecret).update(signedPayload).digest("hex");
    const received = rawSignature.slice("sha256=".length);

    const expectedBuffer = Buffer.from(expected, "hex");
    const receivedBuffer = Buffer.from(received, "hex");

    if (
      expectedBuffer.length !== receivedBuffer.length
      || !timingSafeEqual(expectedBuffer, receivedBuffer)
    ) {
      response.status(401).json({ ok: false, reason: "signature_mismatch" });
      return;
    }

    const payload = JSON.parse(rawBody.toString("utf8"));
    response.status(202).json({
      ok: true,
      executionId: payload.execution?.executionId,
      event: payload.event
    });
  }
);

Verification notes

  • Keep the signing secret local to the receiver.
  • Reject stale timestamps to reduce replay risk.
  • Compare digests with timingSafeEqual.
  • If you change Harbor's header names in node settings, update the receiver to match.
  • If you proxy Harbor callbacks through another service, preserve the raw body byte-for-byte until verification finishes.