Stridee
Stridee Docs

Signing requests

Requests are authenticated with an Ed25519 signature over the request itself — RFC 9421 HTTP Message Signatures. There is no API key.

There is no API key. Every request carries an Ed25519 signature over the request itself, made with a private key that never leaves your machine — so a leaked log, proxy or backup holds nothing that can be replayed as you. (A compromised machine still can: the key is on it.)

Getting a key

Keys → Call the API → Add, or let stridee login register one for your machine. To bring your own:

Shell
openssl genpkey -algorithm ed25519 -out stridee-sign.pem
openssl pkey -in stridee-sign.pem -pubout

It has to be Ed25519 — not the X25519 key your deliveries are sealed to. The console shows its id; that is the keyid every request names, and it is how we know which account you are.

The headers

A signed request
POST /v1/connect HTTP/1.1
Host: api.stridee.com
Content-Digest: sha-256=:zeuXewdQlhgzXOqle0t2/j7Jxy9QEC903PurkiKOxvs=:
Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=1770124811;keyid="5a8f31d6-0c94-4b27-a3e5-71fd2809bc4e";nonce="g1UIt9b3k_FkSpsX2KVCGA";alg="ed25519"
Signature: sig1=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQ…:
Parameter
createdUnix seconds. Must be within 5 minutes of our clock.
keyidYour key's id from the console. This is what identifies the account.
nonceFresh per request. We refuse a repeat within 5 minutes — created limits how long a captured request is useful, the nonce is what stops it being replayed.
alged25519. Optional, and never used to select anything — the key decides.

What you must cover

@method and @target-uri always, plus content-digest whenever there is a body — and nothing else. RFC 9421 lets the signer choose what a signature covers, so we insist on exactly these: a request missing one, or covering anything more, is refused.

The signature base

Text
"@method": POST
"@target-uri": https://api.stridee.com/v1/connect
"content-digest": sha-256=:zeuXewdQlhgzXOqle0t2/j7Jxy9QEC903PurkiKOxvs=:
"@signature-params": ("@method" "@target-uri" "content-digest");created=1770124811;keyid="5a8f31d6-0c94-4b27-a3e5-71fd2809bc4e";nonce="g1UIt9b3k_FkSpsX2KVCGA";alg="ed25519"

One line per covered component, each newline-terminated, then @signature-params last with no trailing newline. Sign those bytes with Ed25519, base64 the result (standard alphabet), and wrap it in colons.

The three things that go wrong:

  • @target-uri is the absolute URL including the query string, and we rebuild it as https://api.stridee.com plus your path and query — never from your Host header. Signing one URL and calling another is the most common first failure.
  • No trailing newline after @signature-params.
  • Content-Digest is over the exact bytes you send. Serialize once, digest that, send that.

Signing one

import { createPrivateKey, createHash, randomBytes, sign } from 'node:crypto';

const key = createPrivateKey(process.env.STRIDEE_SIGNING_KEY);

export function signRequest({ method, url, body }) {
  const covered = ['@method', '@target-uri'];
  const lines = [`"@method": ${method.toUpperCase()}`, `"@target-uri": ${url}`];
  const headers = {};

  if (body?.length) {
    const digest = `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
    headers['content-digest'] = digest;
    covered.push('content-digest');
    lines.push(`"content-digest": ${digest}`);
  }

  const params =
    `;created=${Math.floor(Date.now() / 1000)}` +
    `;keyid="${process.env.STRIDEE_SIGNING_KEY_ID}"` +
    `;nonce="${randomBytes(16).toString('base64url')}"` +
    `;alg="ed25519"`;

  const inner = `(${covered.map((c) => `"${c}"`).join(' ')})`;
  lines.push(`"@signature-params": ${inner}${params}`);

  return {
    ...headers,
    'signature-input': `sig1=${inner}${params}`,
    signature: `sig1=:${sign(null, Buffer.from(lines.join('\n')), key).toString('base64')}:`,
  };
}

There's a runnable version in node-example/sign-request.mjs.

Check it works

Shell
GET /v1/whoami

It reads nothing and only tests the signature, so a 200 means your client is right:

JSON
{ "account_id": "9f1c7d20-5b84-4a6e-9c3f-1d0e8a25b743" }

Point a new client at it first — anywhere else, every mistake looks like the same 401.

When it fails

Each is a 401 whose body says which check failed.

does not cover @target-uriyour covered list is missing a required component
is Ns from our clockthe signing machine's time is off — the message says by how much
nonce has already been usedreusing a nonce, or retrying without minting a new one
Content-Digest does not matchthe body changed between digesting and sending
No live signing key <id>wrong keyid, or the key was deleted
is an encryption key, not a signing keyyou used your webhook key's id
was never confirmedconfirm it in the console — its private half may not exist
does not verifyalmost always @target-uri: it must be https://api.stridee.com exactly, with no trailing slash before your path

Rotating

Register the next key, deploy it, delete the old one. A signature names its own keyid, so both work in between. An account can hold up to 10 signing keys.

Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.