Skip to main content

HTTP Signatures

HTTP Signatures provide authentication for ActivityPub federation. When a server sends an activity to another server's inbox, it signs the request to prove authenticity.

Specification

How It Works

1Sender creates signature with private key2Signature included in HTTP Signature header3Receiver fetches sender's public key from actor4Receiver verifies signature5If valid, process request✓ Authenticated

Signature Header Format

Signature: keyId="https://example.com/users/alice#main-key",
algorithm="rsa-sha256",
headers="(request-target) host date digest",
signature="base64EncodedSignature..."

Parameters

ParameterDescription
keyIdURL of the public key
algorithmSigning algorithm (usually rsa-sha256)
headersSpace-separated list of signed headers
signatureBase64-encoded signature

Signed Headers

Minimum Required

(request-target) host date
(request-target) host date digest

Header Meanings

HeaderDescription
(request-target)Method + path (e.g., post /inbox)
hostTarget hostname
dateRequest timestamp
digestSHA-256 hash of body

Creating a Signature

Step 1: Build Signing String

function buildSigningString(method, path, headers) {
const lines = [];

for (const header of headers) {
if (header === '(request-target)') {
lines.push(`(request-target): ${method.toLowerCase()} ${path}`);
} else {
lines.push(`${header}: ${headers[header]}`);
}
}

return lines.join('\n');
}

Example signing string:

(request-target): post /users/bob/inbox
host: server-b.com
date: Sun, 15 Jan 2024 10:30:00 GMT
digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=

Step 2: Create Signature

const crypto = require('crypto');

function createSignature(signingString, privateKey) {
const signer = crypto.createSign('RSA-SHA256');
signer.update(signingString);
return signer.sign(privateKey, 'base64');
}

Step 3: Build Signature Header

function buildSignatureHeader(keyId, signedHeaders, signature) {
return [
`keyId="${keyId}"`,
'algorithm="rsa-sha256"',
`headers="${signedHeaders.join(' ')}"`,
`signature="${signature}"`
].join(',');
}

Complete Signing Function

const crypto = require('crypto');

function signRequest(method, url, body, privateKey, keyId) {
const urlObj = new URL(url);
const date = new Date().toUTCString();

// Create digest of body
const digestHash = crypto.createHash('sha256').update(body).digest('base64');
const digest = `SHA-256=${digestHash}`;

// Headers to sign
const signedHeaders = ['(request-target)', 'host', 'date', 'digest'];

// Build signing string
const signingString = [
`(request-target): ${method.toLowerCase()} ${urlObj.pathname}`,
`host: ${urlObj.host}`,
`date: ${date}`,
`digest: ${digest}`
].join('\n');

// Create signature
const signer = crypto.createSign('RSA-SHA256');
signer.update(signingString);
const signature = signer.sign(privateKey, 'base64');

// Build header
const signatureHeader = [
`keyId="${keyId}"`,
'algorithm="rsa-sha256"',
`headers="${signedHeaders.join(' ')}"`,
`signature="${signature}"`
].join(',');

return {
Date: date,
Digest: digest,
Signature: signatureHeader
};
}

Verifying a Signature

Step 1: Parse Signature Header

function parseSignatureHeader(header) {
const params = {};
const regex = /(\w+)="([^"]+)"/g;
let match;

while ((match = regex.exec(header)) !== null) {
params[match[1]] = match[2];
}

return params;
}

Step 2: Fetch Public Key

async function fetchPublicKey(keyId) {
// Key ID format: https://example.com/users/alice#main-key
const actorUrl = keyId.split('#')[0];

const response = await fetch(actorUrl, {
headers: { 'Accept': 'application/activity+json' }
});

const actor = await response.json();

// Verify key belongs to actor
if (actor.publicKey.id !== keyId) {
throw new Error('Key not found');
}

return actor.publicKey.publicKeyPem;
}

Step 3: Verify Signature

async function verifySignature(req) {
const sig = parseSignatureHeader(req.headers.signature);

// Fetch public key
const publicKey = await fetchPublicKey(sig.keyId);

// Rebuild signing string
const signingString = sig.headers.split(' ').map(header => {
if (header === '(request-target)') {
return `(request-target): ${req.method.toLowerCase()} ${req.path}`;
}
return `${header}: ${req.headers[header.toLowerCase()]}`;
}).join('\n');

// Verify
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signingString);

const valid = verifier.verify(publicKey, sig.signature, 'base64');

if (!valid) {
throw new Error('Invalid signature');
}

return true;
}

Complete Verification Middleware

async function verifyHttpSignature(req, res, next) {
try {
// Check required headers
if (!req.headers.signature) {
return res.status(401).json({ error: 'Missing Signature header' });
}

if (!req.headers.date) {
return res.status(401).json({ error: 'Missing Date header' });
}

// Check date freshness (±5 minutes)
const date = new Date(req.headers.date);
const now = new Date();
const diff = Math.abs(now - date);

if (diff > 5 * 60 * 1000) {
return res.status(401).json({ error: 'Request too old' });
}

// Verify digest if present
if (req.headers.digest) {
const body = JSON.stringify(req.body);
const expectedDigest = `SHA-256=${crypto
.createHash('sha256')
.update(body)
.digest('base64')}`;

if (req.headers.digest !== expectedDigest) {
return res.status(401).json({ error: 'Invalid Digest' });
}
}

// Verify signature
await verifySignature(req);

next();
} catch (error) {
res.status(401).json({ error: error.message });
}
}

// Use middleware
app.post('/inbox', verifyHttpSignature, handleInbox);

Key Management

Generating Keys

const { generateKeyPairSync } = require('crypto');

function generateActorKeys() {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

return { publicKey, privateKey };
}

Including Key in Actor

{
"type": "Person",
"id": "https://example.com/users/alice",
"publicKey": {
"id": "https://example.com/users/alice#main-key",
"owner": "https://example.com/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
}
}

Common Issues

Clock Skew

Servers reject requests with dates too far from current time.

Solution: Use NTP to sync server clocks.

Algorithm Mismatch

Some servers only support specific algorithms.

Solution: Use rsa-sha256 (most compatible).

Key Format

Keys must be in PEM format.

Solution: Verify key encoding is correct.

Path vs Full URL

The (request-target) should use path only, not full URL.

Correct: (request-target): post /inbox Wrong: (request-target): post https://example.com/inbox

Debugging

Log Signing String

console.log('Signing string:', JSON.stringify(signingString));
console.log('Signature:', signature);

Test with curl

# Generate signature manually and test
curl -X POST https://example.com/inbox \
-H "Date: $(date -u +"%a, %d %b %Y %H:%M:%S GMT")" \
-H "Digest: SHA-256=..." \
-H "Signature: ..." \
-H "Content-Type: application/activity+json" \
-d '{"type": "Follow", ...}'

Next Steps