Authentication

Available

Bearer publishable keys always; optional HMAC-SHA-256 signatures for server-to-server clients.

Bearer key

Every request carries a publishable key:

http
Authorization: Bearer <PUBLISHABLE_KEY>

HMAC signatures (server-to-server only)

A key marked "require signed requests" in the dashboard enforces the three headers below on every call. The verifier lives at src/lib/sdk-signature.server.ts and is the source of truth.

  • X-CtrlApp-TimestampUNIX seconds (integer as decimal string). Rejected if it differs from server time by more than 300 seconds.
  • X-CtrlApp-Nonce — random string, 16..128 chars. Recorded for 10 minutes; a repeat within that window is a replay and returns 401.
  • X-CtrlApp-Signature — lowercase hex of HMAC_SHA256(signing_secret, canonical), compared with timingSafeEqual.

Canonical string

text
canonical = ts + "." + nonce + "." + METHOD + "." + canonicalPath + "." + bodyHash

canonicalPath = pathname                       // e.g. "/api/public/sdk/v1/session"
             or pathname + "?" + sortedQuery   // query entries sorted by key, joined with &
bodyHash      = lowercase hex of SHA256(rawBody)  // empty body still hashes ""
METHOD        = uppercase HTTP verb (GET, POST, ...)

Signed GETs are pinned to their endpoint because the query is inside the canonical string.

Reference implementation (Node.js)

ts· sign.ts
import { createHash, createHmac, randomBytes } from "node:crypto";

export function signCtrlAppRequest(opts: {
  method: string;                       // "GET" | "POST" | ...
  url: string;                          // e.g. "https://ctrlapp.krdcode.com/api/public/sdk/v1/session"
  rawBody?: string;                     // "" for GET
  signingSecret: string;
}) {
  const u = new URL(opts.url);
  const sorted = [...u.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
  const query = sorted.map(([k, v]) => `${k}=${v}`).join("&");
  const canonicalPath = query ? `${u.pathname}?${query}` : u.pathname;

  const ts    = Math.floor(Date.now() / 1000).toString();     // UNIX seconds
  const nonce = randomBytes(16).toString("hex");              // 32 chars, in [16,128]
  const bodyHash = createHash("sha256")
    .update(opts.rawBody ?? "", "utf8")
    .digest("hex");
  const canonical = `${ts}.${nonce}.${opts.method.toUpperCase()}.${canonicalPath}.${bodyHash}`;
  const signature = createHmac("sha256", opts.signingSecret).update(canonical).digest("hex");

  return {
    "X-CtrlApp-Timestamp": ts,
    "X-CtrlApp-Nonce": nonce,
    "X-CtrlApp-Signature": signature,
  };
}

Failure modes

  • 401 "Signed request required for this key" — key requires signing but headers absent.
  • 401 "Incomplete signature headers" — one or two of the three headers missing.
  • 401 "Invalid timestamp" — not a finite number.
  • 401 "Timestamp skew too large" — outside +/- 300 s.
  • 401 "Nonce length out of range" — length not in [16, 128].
  • 401 "Invalid signature" — HMAC mismatch or wrong hex.
  • 401 "Nonce already used" — replay within the 10-minute TTL.
  • 503 "Signature store unavailable" — nonce persistence failed; retry with backoff.
Security
Never enable signing in a mobile or browser SDK. The signing secret cannot be kept secret in a client binary. Use the publishable key alone in clients.
Back to top