Signed requests

How an app verifies that a call, or a browser arriving on its install page, really comes from Spunto.

An app shares one secret with Spunto. It is used two ways, domain-separated so neither can be passed off as the other:

  • Request signatures — on every server-to-server call Spunto makes: /status, /oauth/complete, /capabilities/*.
  • Launch tokens — on the browser routes that start a flow: installPath, grantPath.

Both are HMAC-SHA256, hex-encoded. Reject anything that does not verify, and never with a message that says how it failed beyond "unauthorized".

Each call carries two headers:

HeaderValue
x-spunto-timestampMilliseconds since the epoch, when Spunto sent it
x-spunto-signaturehex(HMAC_SHA256(secret, "{timestamp}.{METHOD}.{path}.{body}"))
  • METHOD is upper-case — always POST today.
  • path is the path relative to your base URL, as Spunto requested it: /capabilities/git/cloneCredential, without query string. Method and path are inside the signature so a signed body cannot be replayed on another endpoint that happens to accept the same shape.
  • body is the raw request body, byte for byte. Verify before parsing: re-serialising the JSON and signing that is the classic way to build a check that passes locally and fails on the wire.

Refuse a timestamp more than 5 minutes away from your clock, so a captured call cannot be replayed later.

import { createHmac, timingSafeEqual } from "node:crypto"
 
const MAX_SKEW_MS = 5 * 60 * 1000
 
export function verifySpuntoRequest(req: {
  secret: string
  method: string
  path: string
  rawBody: string
  signature?: string
  timestamp?: string
}): boolean {
  // An empty secret would accept any signature computed with "".
  if (!req.secret || !req.signature || !req.timestamp) return false
  const sentAt = Number(req.timestamp)
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > MAX_SKEW_MS) return false
 
  const expected = createHmac("sha256", req.secret)
    .update(`${req.timestamp}.${req.method.toUpperCase()}.${req.path}.${req.rawBody}`)
    .digest("hex")
  const a = Buffer.from(expected)
  const b = Buffer.from(req.signature)
  // Constant-time, and length-checked first: timingSafeEqual throws on a mismatch.
  return a.length === b.length && timingSafeEqual(a, b)
}

With Hono, for instance:

app.post("/capabilities/:capability/:operation", async (c) => {
  const raw = await c.req.text()
  const ok = verifySpuntoRequest({
    secret: process.env.SPUNTO_SIGNING_SECRET!,
    method: c.req.method,
    path: c.req.path,
    rawBody: raw,
    signature: c.req.header("x-spunto-signature"),
    timestamp: c.req.header("x-spunto-timestamp"),
  })
  if (!ok) return c.json({ error: "Unauthorized" }, 401)
 
  const { orgId, userId, input } = JSON.parse(raw)
  // …
})

Note

If your app sits behind a proxy that mounts it under a prefix (/acme), Spunto still calls the base URL you registered, and signs the path relative to it. Make sure path is what your app sees after the prefix is stripped — or register the base URL with the prefix included.

When a member clicks Connect (or Authorize), their browser arrives on your installPath (or grantPath) with ?launch=<token>. The token is Spunto's signed statement: this user of this organization may start this flow, and must come back here.

<payload>.<mac>
 
payload = base64url(JSON.stringify({ org, user, return, purpose, exp }))
mac     = hex(HMAC_SHA256(secret, "spunto-launch.v1." + payload))
Field
orgThe organization's id — opaque, store it as is
userThe id of the member who started the flow
returnWhere to send the browser when you are done, with ?completion=… appended
purposeinstall or grant — check it matches the route
expExpiry, in epoch milliseconds — ten minutes after it was signed
export function verifyLaunch(secret: string, token: string | undefined, purpose: "install" | "grant") {
  if (!secret || !token) return null
  const dot = token.lastIndexOf(".")
  if (dot <= 0) return null
  const payload = token.slice(0, dot)
  const expected = Buffer.from(
    createHmac("sha256", secret).update(`spunto-launch.v1.${payload}`).digest("hex"),
  )
  const given = Buffer.from(token.slice(dot + 1))
  if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null
 
  const launch = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
  if (launch.purpose !== purpose || typeof launch.exp !== "number" || launch.exp < Date.now()) return null
  if (!launch.org || !launch.user || !launch.return) return null
  return launch as { org: string; user: string; return: string; purpose: string; exp: number }
}

Keep the verified launch through your own flow (in a signed cookie, or your OAuth state), and check return once more before redirecting to it: it should be on the Spunto origin you were registered with. The signature already vouches for it — this second fence keeps a leaked secret from turning your app into an open redirector.

A launch token only says who started the flow. Who finishes it is confirmed by Spunto in POST /oauth/complete → Installing.

Settings → Developer apps → New secret mints a new one and shows it once. Spunto signs with it at once, so deploy your app accepting both the old and the new secret first, regenerate, then drop the old one.