Skip to content

Signing Requests

This guide signs, sends and verifies one request. It adds no rules: those are in Signatures.

The following example is written in TypeScript using the WebCrypto API and the ES2026 Uint8Array base64 methods.

The Request

@bob, from bob.example, wants to sign a request to alice.example. The request is a POST to /.versia/v0.7/inbox, with the following body. It is shown exactly as sent, with no whitespace, because the hash covers the raw bytes.

{"content":"Hello, world!"}

Bob can be found at https://bob.example/.versia/v0.7/entities/User/034tvn0hm4002.

Example Key

The Ed25519 private key of bob.example, encoded in base64 PKCS8, is MC4CAQAwBQYDK2VwBCIEILrNXhbWxC/MhKQDsJOAAF1FH/R+Am5G/eZKnqNum5ro. This key signs the examples in this specification, whichever instance they name. It is public, so never use it on a real instance.

Signing

Here’s how Bob would sign the request:

const content = JSON.stringify({
    content: "Hello, world!",
});

const base64PrivateKey = "MC4CAQAwBQYDK2VwBCIEILrNXhbWxC/MhKQDsJOAAF1FH/R+Am5G/eZKnqNum5ro";
const privateKey = await crypto.subtle.importKey(
    "pkcs8",
    Uint8Array.fromBase64(base64PrivateKey),
    "Ed25519",
    false,
    ["sign"],
);

const timestamp = Math.floor(Date.now() / 1000);
const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(content)
);

const stringToSign =
    `post alice.example /.versia/v0.7/inbox ${timestamp} ${new Uint8Array(digest).toBase64()}`;

const signature = await crypto.subtle.sign(
    "Ed25519",
    privateKey,
    new TextEncoder().encode(stringToSign)
);

const base64Signature = new Uint8Array(signature).toBase64();

Sending

To send the request, Bob would use the following code:

const headers = new Headers();

headers.set("Versia-Signed-By", "bob.example");
headers.set("Versia-Signed-At", String(timestamp));
headers.set("Versia-Signature", base64Signature);
headers.set("Content-Type", "application/vnd.versia+json");

const response = await fetch("https://alice.example/.versia/v0.7/inbox", {
    method: "POST",
    headers,
    body: content,
});

Verifying

alice.example verifies the signature using the public key of bob.example. This code assumes that the key is stored in a variable called publicKey (during real federation, it would be fetched from the instance’s metadata endpoint).

const method = request.method.toLowerCase();
// Alice's own domain, not the Host header
const host = "alice.example";
// Must match the request line. If your URL parser normalizes it, use the raw request target
const url = new URL(request.url);
const path = url.pathname + url.search;
const signature = request.headers.get("Versia-Signature");
const signedAt = request.headers.get("Versia-Signed-At");

// A missing or malformed header is an invalid signature, not a server error
if (!signature || !/^[A-Za-z0-9+/]{86}==$/.test(signature) || !signedAt || !/^\d+$/.test(signedAt)) {
    return new Response("Signature headers are missing or malformed", { status: 401 });
}

// Check if timestamp is within 5 minutes of the current time
if (Math.abs(Date.now() - Number(signedAt) * 1000) > 300_000) {
    return new Response("Timestamp is too old or too new", { status: 401 });
}

const digest = await crypto.subtle.digest(
    "SHA-256",
    await request.arrayBuffer()
);

const stringToVerify =
    `${method} ${host} ${path} ${signedAt} ${new Uint8Array(digest).toBase64()}`;

const isVerified = await crypto.subtle.verify(
    "Ed25519",
    publicKey,
    Uint8Array.fromBase64(signature),
    new TextEncoder().encode(stringToVerify)
);

if (!isVerified) {
    return new Response("Signature verification failed", { status: 401 });
}