Vibefed kb-fediverse-http-signatures
git clone https://github.com/reiver/vibefed
T=$(mktemp -d) && git clone --depth=1 https://github.com/reiver/vibefed "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/kb-fediverse-http-signatures" ~/.claude/skills/reiver-vibefed-kb-fediverse-http-signatures && rm -rf "$T"
skills/kb-fediverse-http-signatures/SKILL.mdFediverse HTTP Signatures — Complete Reference
Overview
ActivityPub does not specify an authentication mechanism in its core spec. In practice, the Fediverse has converged on HTTP Signatures (draft-cavage-12, formally
draft-cavage-http-signatures) for server-to-server authentication.
When one server POSTs an activity to another server's inbox, it signs the
request so the receiving server can verify the sender's identity.
The spec being used is
draft-cavage-http-signatures-12 — an expired IETF
Internet Draft, not an RFC. It has been superseded by RFC 9421, but the
Fediverse has not yet migrated to RFC 9421 at any meaningful scale. Treat
cavage-12 as the working standard for all current implementations.
1. The Public Key Model
Each actor (user) has an asymmetric keypair. The private key stays on the server; the public key is published in the actor document under the
publicKey property, using the LD Security Vocabulary v1:
{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "type": "Person", "id": "https://example.com/users/alice", "inbox": "https://example.com/users/alice/inbox", "publicKey": { "id": "https://example.com/users/alice#main-key", "owner": "https://example.com/users/alice", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----" } }
Key points:
may be a single object, a single id string, or an arraypublicKey- The key object's
is theid
used in thekeyId
headerSignature
(orowner
) links back to the actor for verificationcontroller- The key is RSA by default; Ed25519 is more modern but not yet widely supported across the Fediverse
- Generate 2048-bit RSA minimum; 4096-bit is common
- The PEM encoding may be PKCS-1 (
) or PKCS-8/SPKI (-----BEGIN RSA PUBLIC KEY-----
); crypto libraries generally auto-detect the format-----BEGIN PUBLIC KEY-----
2. How to Sign a Request
The standard set of headers to sign for a POST (inbox delivery):
(request-target) host date digest content-type
For a GET request, omit
digest and content-type.
Step-by-step signing
1. Generate the
header (POST only)Digest
SHA-256=<base64(sha256(request_body))>
For an empty body, the SHA-256 of an empty string is always:
SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
Use standard base64 with padding. Do NOT use URL-safe base64. Do NOT use the variant that inserts newlines every 60 characters (e.g. Ruby's
Base64.encode64 — use Base64.strict_encode64 instead).
2. Construct the signing string
Each header becomes one line:
headername: value.
Header names must be lowercased.
Lines are joined with \n (ASCII newline, 0x0A).
There must be NO trailing newline after the last line.
The
(request-target) pseudo-header is constructed as:
(request-target): post /users/alice/inbox
i.e.
method_lowercase + " " + path_and_query.
Example signing string for a POST:
(request-target): post /users/alice/inbox host: example.com date: Mon, 01 Jan 2024 12:00:00 GMT digest: SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= content-type: application/activity+json
3. Sign the string
Sign the signing string with the actor's RSA private key using RSASSA-PKCS1-v1_5 with SHA-256 (i.e.
rsa-sha256).
Base64-encode the resulting bytes (standard base64 with padding).
4. Set the
headerSignature
Signature: keyId="https://example.com/users/alice#main-key", algorithm="hs2019", headers="(request-target) host date digest content-type", signature="<base64-encoded-signature>"
Use
hs2019 as the algorithm value — it is a placeholder meaning
"determine the algorithm from the key metadata." Do not use
rsa-sha256 as the algorithm string in new implementations (see quirks).
Use the
Signature header, not Authorization: Signature ....
3. How to Verify a Signature
1. Extract the
headerSignature
Parse the
keyId, headers, algorithm, and signature parameters.
2. Resolve the public key (see Section 4 below)
3. Reconstruct the signing string
Using the same
headers list from the signature and the actual request
values, rebuild the signing string using the same rules as signing:
lowercased header names, \n-separated, no trailing newline.
4. Verify the Digest (if present and it was in the signed headers)
Compute SHA-256 of the request body and compare to the
Digest header.
If they don't match, reject — the body was tampered with.
5. Check the
headerDate
Compare to current time. Reject if the skew is too large. A window of approximately ±1 hour is the community consensus (see quirks for implementation-specific values).
6. Verify the cryptographic signature
Use the public key and the reconstructed signing string to verify the base64-decoded
signature value. If verification fails with a cached key,
re-fetch the key from the origin (the actor may have rotated their key)
and try once more before rejecting.
7. Return 401 if verification fails (when a signature is required).
4. Resolving a keyId
to a Public Key
keyIdThe
keyId is a URL. Resolving it to an actual public key requires care
because two patterns exist in the wild:
Pattern A — Fragment URI (Mastodon style)
keyId = "https://example.com/users/alice#main-key"
- Strip the fragment: fetch
https://example.com/users/alice - The response is the actor document
- Find
in the actor; if it's an array, find the entry whosepublicKey
matches the fullid
including the fragmentkeyId - Verify the key's
matches the originalid
exactlykeyId
Pattern B — Path URI (GoToSocial style)
keyId = "https://example.com/users/alice/main-key"
- Fetch the URL directly
- The response is a raw Key object (not a full actor), containing
and anpublicKeyPem
(orowner
) propertycontroller - Follow
to fetch the full actor and verify the key is authorizedowner
In both cases: always verify that the key's
id matches the keyId from
the Signature header, and that the key's owner/controller matches
the expected actor. Never trust a key that doesn't pass this check.
5. Authorized Fetch (Secure Mode)
Some servers require HTTP Signatures on GET requests as well as POST requests. This is called "authorized fetch" or "secure mode" (Mastodon's term). In this mode:
- Fetching an actor, object, or collection requires a signed GET
- The signing actor is typically the instance actor (see Section 6)
- Servers use this to block data access from defederated instances
- GoToSocial requires signed GETs in all modes
- Mastodon only requires them when secure mode is explicitly enabled
If you're building an implementation and encounter 401 responses to unsigned GETs, the remote server has authorized fetch enabled.
6. Instance Actors
Most servers maintain a special instance-level actor (separate from any user) used to sign GET requests on behalf of the server itself. This is used for fetching remote objects without attributing the fetch to a specific user.
The instance actor typically has a well-known URL such as:
https://example.com/actorhttps://example.com/users/instanceactor
When fetching a remote object to verify it (e.g. during signature verification of an incoming activity), you should use the instance actor's key to sign the outbound GET. Remote servers generally do not attempt to verify the signature on requests made to resolve a
keyId for the first time.
7. Key Rotation
If an actor rotates their keypair, the old public key is no longer valid. Implementations must handle this:
- Verification fails with the cached key
- Re-fetch the actor document from the source (bypass cache)
- Retry verification with the new key
- If it succeeds, update your local cache
Without this retry logic, federation will break every time a user's key is rotated.
8. Interoperability Quirks
This section documents known bugs, deviations, and incompatibilities across Fediverse implementations. These are real-world issues that have caused federation failures.
8.1 Query String in (request-target)
(request-target)Mastodon historically omitted query strings from
(request-target), signing
only the path. GoToSocial included query parameters, which broke paged
Collection fetches.
Both now use a double-attempt strategy:
- Signing: sign with query params; if remote returns 401, retry without
- Verification: verify with query params; if it fails, retry without
If implementing signature verification for paged collections, support both.
8.2 hs2019
Algorithm Interpretation
hs2019hs2019 is a placeholder meaning "determine algorithm from key metadata."
Different implementations interpret it inconsistently:
| Implementation | Interpretation of |
|---|---|
| Mastodon | RSA-SHA256 |
| PeerTube | RSA-SHA512 |
| GoToSocial | Signs as RSA-SHA256; verifies RSA-SHA256, RSA-SHA512, Ed25519 in order |
| Lemmy | Sends string |
Recommendation: When verifying
hs2019, try RSA-SHA256 first, then
RSA-SHA512, then Ed25519. When signing, use RSA-SHA256.
8.3 (created)
and (expires)
Pseudo-Headers
(created)(expires)Mastodon rejects
(created) and (expires) unless the algorithm is
hs2019. Sending algorithm="rsa-sha256" with (created) in the headers
list produces:
"Invalid pseudo-header (created) for rsa-sha256"
Older Pleroma versions do not support
(created) at all. GoToSocial
v0.16.0-rc1 temporarily switched to (created) instead of Date, breaking
federation with Akkoma/Pleroma and Bookwyrm — it was reverted.
Recommendation: Use
Date rather than (created) for maximum
compatibility.
8.4 URL Encoding in (request-target)
(request-target)Pleroma does not URL-decode the request-target path before constructing the signing string. Mastodon does URL-decode it. If an inbox path contains percent-encoded characters (e.g.
%40), one side signs the encoded
form and the other verifies against the decoded form — silent failure.
Security note: Percent-encoded newlines (
%0A) in a path could inject
fake header lines into the signing string if the path is decoded before
signing string construction.
Recommendation: Use the path as-is on the wire without decoding. Be aware that web frameworks (Rails, ASP.NET) may auto-decode before your application sees the path.
8.5 Signature
Header vs Authorization
Header
SignatureAuthorizationBoth are defined in cavage. In practice, virtually all Fediverse implementations use only the
Signature header. Mastodon does not fall
back to Authorization: Signature ....
Recommendation: Always use the
Signature header.
8.6 Clock Skew Tolerance
Tolerance windows vary widely across implementations:
| Implementation | Tolerance window |
|---|---|
| Mastodon | ±1 hour (CLOCK_SKEW_MARGIN), max 12 hours (EXPIRATION_WINDOW_LIMIT) |
| Pleroma/Akkoma | Up to 2 hours old, max 40 minutes in the future |
| SWICG recommendation | ~1 hour plus a few minutes buffer |
| Some implementations | As tight as 30 seconds |
If your server's clock is significantly wrong, or a remote server's clock is wrong, signatures will fail. NTP synchronisation is essential.
8.7 keyId
Format Divergence
keyId| Implementation | format | Dereferencing |
|---|---|---|
| Mastodon | Fragment URI: | Returns full actor; key identified by fragment |
| GoToSocial | Path URI: | Returns key stub with back-link |
Receivers must handle both. When the
keyId contains a fragment, strip it
before fetching. When the fetched document is a raw Key (not an actor),
follow owner/controller to the actor.
If
publicKey is an array in the actor, match keyId against each key's
id to find the right one.
8.8 Digest
Header
DigestMastodon only accepts SHA-256. Sending
SHA-512=... alone will be
rejected. Mastodon validates that the decoded digest is exactly 32 bytes.
Case sensitivity: Mastodon lowercases the algorithm name when parsing and checks for
sha-256. The canonical format in the wild is uppercase
SHA-256=. Mixed case is accepted by Mastodon but may not be by others.
Empty body: A POST with an empty body MUST include a Digest of the empty string:
SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
RFC 9530 rename: RFC 9530 renames
Digest to Content-Digest. Mastodon
4.5+ accepts both. Most implementations still send the old Digest header.
Send Digest for compatibility; accept both.
8.9 Base64 Encoding
Use standard base64 with padding — not URL-safe base64.
In Ruby, use
Base64.strict_encode64, NOT Base64.encode64 — the latter
inserts newlines every 60 characters per RFC 2045, which breaks verification
on the receiving side.
8.10 Multi-Value Header Concatenation
The spec requires multiple values for the same header to be concatenated with
, (comma-space). Pixelfed historically did not comma-separate values,
causing verification failures against GoToSocial and Mastodon in secure mode.
Fixed in Pixelfed PR #4504.
8.11 Trailing Newline in Signing String
The signing string must not include a trailing
\n after the last line.
This is a common silent failure — the signature is generated with a trailing
newline, which doesn't match the signature generated without one during
verification. Multiple SocialHub discussions document this as one of the most
frequent implementation bugs.
8.12 Unsigned GET Requests
Pixelfed did not sign GET requests until 2021, breaking federation with any server running authorized fetch / secure mode. GoToSocial requires signed GETs in all modes.
Recommendation: Sign GET requests. Use the instance actor for GET requests not attributable to a specific user.
8.13 PeerTube Signature Header Prefix Bug (Fixed)
PeerTube v2.1.1 prefixed the
Signature header value with the literal
string "Signature ", producing Signature: Signature keyId=.... This
was caused by an older version of the http-signature npm library. Fixed
in PeerTube v2.2. Mentioned here because old PeerTube instances may still
be in the wild.
8.14 Misskey Digest/Host Validation Bug (Fixed — CVE-2023-49079)
Misskey prior to v2023.11.1 did not validate the
Digest or Host
headers — only the cryptographic signature was checked. This allowed
attackers to craft requests with valid signatures but spoofed bodies,
impersonating any remote user. CVSS 9.3 Critical.
Implication for implementors: Always validate
Digest and Host as
part of signature verification, not just the cryptographic signature itself.
8.15 Reverse Proxy / Host Header Issues
When a reverse proxy rewrites the
Host header, the signed host value
won't match what the application sees. In nginx, use proxy_set_header Host $host.
A documented case: Ghost on AWS behind CloudFront had federation break because CloudFront sends
CloudFront-Forwarded-Proto instead of the
standard X-Forwarded-Proto, causing the app to generate HTTP URLs instead
of HTTPS, breaking actor ID matching and signature verification.
8.16 IDN / Punycode in Host Headers
Instances with non-ASCII domain names (e.g., Cyrillic) store the punycode version internally but may receive the Unicode version in incoming requests. If the local server compares against punycode and the incoming
host header
is Unicode (or vice versa), signature verification of the host header fails.
Recommendation: Use ASCII/punycode in all URLs and
Host headers.
8.17 HTTP/1.1 Line Folding
The cavage-12 canonicalization algorithm does not account for HTTP/1.1 obs-fold (continuation lines starting with whitespace). While line folding is deprecated, it could still appear from legacy clients or proxies. Most implementations do not handle it.
8.18 Shared Library Landscape
Very few implementations share HTTP signature code:
| Library | Used by |
|---|---|
(npm) | PeerTube, Misskey |
(Elixir) | Pleroma, Mobilizon |
| Everyone else | Custom implementations |
This is why the same spec ambiguity can be interpreted differently by 10+ independent codebases — there is no shared reference implementation to enforce consistency.
9. Debugging Checklist
When signatures fail, check in this order:
- Clock skew — is the
header within tolerance? Check NTP on both servers.Date - Trailing newline — is the signing string missing a trailing
, or does it have one it shouldn't?\n - Header name case — are all header names lowercased in the signing string?
construction — is it(request-target)
? Does path include or exclude query string? (Try both.)method_lowercase + " " + path- Digest mismatch — does the
header match the actual body? Empty body is not the same as no body.Digest - Base64 encoding — is the signature standard base64 with padding and no embedded newlines?
resolution — does the fetched key'skeyId
match theid
exactly (including fragment)?keyId- Key rotation — is the cached key stale? Try re-fetching the actor.
- Reverse proxy — is the
header being rewritten before the application sees it?Host - Algorithm mismatch — if remote is PeerTube, try RSA-SHA512 in addition to RSA-SHA256.
10. Minimum Required Headers (Consensus)
| Request type | Required in signed headers |
|---|---|
| POST (inbox delivery) | , , , , |
| GET (authorized fetch) | , , |
Most Fediverse software will reject POST requests without a signed
Digest,
and GET requests without a signed (request-target), in order to prevent
replay attacks.