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. Requests carry an Ed25519 signature over the request itself, made with a private key that never leaves your machine.

An API key is a bearer token: whoever holds the bytes is you. That makes every place the bytes can rest — a log line, a proxy, a database backup, a CI variable, a support ticket — a place your account can be taken over from. A signature never travels, so there is nothing in any of those places to steal.

Worth being exact about what that does and doesn't buy. It defends against credential exposure: leaked logs, leaked backups, a proxy that records headers, a replayed request. It does not defend against a compromised host — your private key is on that machine, and an attacker who is on it can sign whatever they like.

Getting a key

Keys → Call the API → Add. Generate in the browser or 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 webhooks are encrypted to. Those are different curves for different operations and neither can do the other's job — a PEM or JWK naming the wrong one is refused when you register it.

The console shows a key id. That is the keyid every request names, and it is what tells us which account you are.

The headers

A signed request
POST /v1/activities HTTP/1.1
Host: api.stridee.fit
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 remember it for 5 minutes and refuse a repeat.
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.

This is not advisory. RFC 9421 lets the signer choose what a signature covers, so a verifier that accepts whatever the request claims to have covered accepts a signature that covers nothing — and then the method, the path and the body can be anything. We check the list before we check the signature, and a request that leaves one of them out is refused with a sentence saying which.

The other side of that: we only accept those three. A signature covering authorization or @query-param is refused rather than partly verified.

The signature base

Text
"@method": POST
"@target-uri": https://api.stridee.fit/v1/activities
"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.

Three things to get right, and they are the three that go wrong:

  • @target-uri is the absolute URL including the query string. We rebuild it as https://api.stridee.fit plus the path and query — from our own configuration, never from the Host header you send, because a signature base built from caller-supplied values is one a caller can steer. 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. A framework that re-encodes the body between your digest and the wire will produce a mismatch.

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 changes nothing, and the only thing it tests is the signature. A 200 means your client is right — not that this one call happened to be:

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

Point a new client at it before anything else. Against a real endpoint, a clock problem, a stale key, a wrong @target-uri and a re-encoded body all look like the same 401.

Replay

Every signed request needs a fresh nonce. We store it for five minutes and refuse a second request carrying the same one.

The window and the nonce do different jobs and you need both. created bounds how long a captured request stays useful; it cannot be made tight enough to stop a replay without rejecting clients whose clocks are merely mediocre. The nonce is what actually stops one.

When it fails

Everything below is a 401 with a sentence saying 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: check scheme, host and query

Rotating

Register the next key, deploy it, delete the old one. Both are live in between, and there is no cutover — a signature names its own keyid, so requests signed with either verify for as long as both exist.

Up to 10 signing keys live on an account, which is what makes the overlap possible.

Something wrong or missing on this page? Tell us in Discord.