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 });
}