Authentication
AvailableBearer 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-Timestamp— UNIX 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 returns401.X-CtrlApp-Signature— lowercase hex ofHMAC_SHA256(signing_secret, canonical), compared withtimingSafeEqual.
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.