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:
| Header | Value |
|---|---|
x-spunto-timestamp | Milliseconds since the epoch, when Spunto sent it |
x-spunto-signature | hex(HMAC_SHA256(secret, "{timestamp}.{METHOD}.{path}.{body}")) |
METHODis upper-case — alwaysPOSTtoday.pathis 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.bodyis 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)
// …
})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 | |
|---|---|
org | The organization's id — opaque, store it as is |
user | The id of the member who started the flow |
return | Where to send the browser when you are done, with ?completion=… appended |
purpose | install or grant — check it matches the route |
exp | Expiry, 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.