Hello! I'm Hong Minhee (洪 民憙), an open source software engineer in my late 30s, living in Seoul, Korea. I'm bisexual and non-binary (they/them), and an enthusiastic advocate of free/open source software and the fediverse.
I work full-time on @fedify, an ActivityPub server framework in TypeScript, funded by @sovtechfund. I'm also the creator of @hollo, a single-user ActivityPub microblog; @botkit, an ActivityPub bot framework; Hackers' Pub, a fediverse platform for software developers; and LogTape, a logging library for JavaScript and TypeScript.
I have a long interest in East Asian languages (CJK) and Unicode. I post mostly in English here, though occasionally in Japanese or in mixed-script Korean (國漢文混用體), a traditional writing style that interleaves Chinese characters with the native Korean alphabet. Wanting to write in that style was actually one of the reasons I joined the fediverse. Feel free to talk to me in English, Korean, Japanese, or even Literary Chinese!
安寧하세요! 저는 서울에 살고 있는 30代 後半의 오픈 소스 소프트웨어 엔지니어 洪民憙입니다. 兩性愛者(bisexual)이자 논바이너리(non-binary)이며, 自由·오픈 소스 소프트웨어(F/OSS)와 聯合宇宙(fediverse)의 熱烈한 支持者이기도 합니다.
STF(@sovtechfund)의 支援을 받아 TypeScript用 ActivityPub 서버 프레임워크 @fedify 開發에 專業으로 任하고 있습니다. 그 外에도 싱글 유저用 ActivityPub 마이크로블로그 @hollo, ActivityPub 봇 프레임워크 @botkit, 소프트웨어 開發者를 위한 聯合宇宙 플랫폼 Hackers' Pub, JavaScript·TypeScript用 로깅 라이브러리 LogTape 等의 製作者이기도 합니다.
東아시아 言語(이른바 CJK)와 Unicode에도 關心이 많습니다. 이 計定에서는 主로 英語로 포스팅하지만, 때때로 日本語나 國漢文混用體 韓國語로도 씁니다. 聯合宇宙에 오게 된 動機 中 하나가 바로 國漢文混用體로 글을 쓰고 싶었기 때문이기도 하고요. 韓國語, 英語, 日本語, 아니면 漢文으로도 말을 걸어주세요!
First big release after I joined as a maintainer probably. Patch releases, feature implementations, bug fixes, and lots of code reviews. Thanks, every contributors!!
Fedify is a TypeScript framework for building ActivityPub servers. It implements federation details such as HTTP Signatures, JSON-LD processing, WebFinger, inbox and outbox routing, and activity delivery.
Fedify 2.4.0 is a minor release with an unusually long changelog. The main addition is support for FEP-ef61 portable objects: a Fedify server can now host actors whose identity is a DID rather than a domain, serve and verify their signed objects, and consume the portable objects of other servers. The release also adds an API for application-defined background tasks, the ActivityPub Media Upload extension, a hook that reports how every inbox request was authenticated and handled, and 410 Gone tombstones for deleted objects. Several defaults change to limit what a hostile peer can make Fedify do. Four new packages ship with this version: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, and @fedify/pglite.
Most applications can upgrade without code changes, but a few of the new defaults are behavior changes. They are listed in the Upgrading section at the end.
FEP-ef61 portable objects
An ActivityPub object's ID is an HTTP(S) URL on its server's domain. If that server shuts down, its IDs stop resolving, and an actor that moves elsewhere gets a new ID that everyone has to follow again. FEP-ef61, written by @silverpill of Mitra, proposes IDs that do not name a server at all. A portable ID is an ap: URI whose authority is a DID:
The same object can be stored on several servers, called gateways, each of which serves it under /.well-known/apgateway/. Because no server owns the ID, a portable actor, activity, or object is authenticated only by its FEP-8b32 Object Integrity Proof, made with a key of the DID in its ID. A portable actor lists its gateways in a gateways property, and other servers deliver to its inbox through them.
Fedify 2.4.0 implements a profile of FEP-ef61 for gateways and consumers of portable objects. It makes two deliberate choices where the FEP or the specifications it rests on leave questions open, and it leaves out a few parts of the proposal; both are described below. The new Portable objects chapter of the manual walks through running portable actors from start to finish and then describes the profile as a whole, so that other implementers can tell what to expect from a Fedify server. The request came from @anotherdoesnm in #288, which tracked the work from a roadmap to the final pieces.
Portable objects are a foundation for nomadic identity, not nomadic identity itself. Fedify serves and verifies portable objects and forwards activities between gateways, but it does not copy actors or posts between servers, rotate keys, or move an actor to another DID. Full nomadic identity is still open in #413.
Hosting a portable actor
A portable actor starts with an Ed25519 key pair. Its DID is made from the public key with exportDidKey(), and whoever holds the private key can act as the actor, so store it with the actor and keep it safe:
import { generateCryptoKeyPair } from "@fedify/fedify";import { exportDidKey } from "@fedify/vocab-runtime";const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");const did = await exportDidKey(publicKey); // did:key:z6Mk...// The verification method that the actor's proofs refer to:const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);
The actor dispatcher returns a portable actor as it would an ordinary one. Context.getPortableActorUri() and its siblings for inboxes, outboxes, collections, and objects build portable IDs from the dispatcher's paths and the DID, and signObject() signs the actor with the DID's key:
federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = await findUser(identifier); if (user == null) return null; // A request through the gateway endpoint names the DID in its path, // e.g., GET /.well-known/apgateway/did:key:z6Mk.../users/alice: if ( ctx.portableRequest != null && ctx.portableRequest.authority !== user.did ) { return null; } // This server's gateway keys for the actor (see below): const keys = await ctx.getActorKeyPairs(identifier); return await signObject( new Person({ id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice preferredUsername: user.username, inbox: ctx.getPortableInboxUri(identifier, user.did), outbox: ctx.getPortableOutboxUri(identifier, user.did), followers: ctx.getPortableFollowersUri(identifier, user.did), gateways: [new URL("https://example.com")], publicKeys: keys.map((key) => key.cryptographicKey), assertionMethods: keys.map((key) => key.multikey), }), user.didKeyPair.privateKey, new URL(`${user.did}#${user.did.slice("did:key:".length)}`), ); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = await findUser(identifier); // This server's own key pair for the actor, not the DID's: return user == null ? [] : [user.gatewayKeyPair]; }) .mapPortableActorId(async (ctx, identifier) => { const user = await findUser(identifier); return user == null ? null : ctx.getPortableActorUri(identifier, user.did); });
With this dispatcher, Fedify serves the actor at its compatible identifier, https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice, which is how other servers retrieve it. It serves it through WebFinger as @alice@example.com, since the handle's domain comes from the first gateway in gateways, and at its ordinary URL as before. On the gateway endpoint, Fedify serves a document only if its ID is the requested portable ID and it carries a proof made with a key of that DID.
The key pairs dispatcher and the new ActorCallbackSetters.mapPortableActorId() give the actor gateway keys. These are the server's own keys for the actor, and they sign only the HTTP requests the server makes on the actor's behalf, such as deliveries. They never sign the actor's activities or objects; only the DID's key does that. The actor document lists them under assertionMethods, as FEP-521a describes, so that other servers can verify the HTTP Signatures. See Dispatching the actor and Gateway keys of portable actors for the details.
Object and collection dispatchers serve portable objects and collections through the gateway endpoint in the same way, routed by the path after the DID. Two rules protect the actor's data. A non-public portable object is served only if the dispatcher has an authorization predicate, because FEP-ef61 forbids a gateway to show it to anyone outside its audience; the new RequestContext.isSignedByAudience() helps to write one. Without a predicate, a request for such an object gets 404 Not Found, as though the gateway did not store it. A deleted object is served as a signed Tombstone with 410 Gone. Resources that portable objects refer to by SHA-256 hashlinks, such as GET /.well-known/apgateway/hl:zQm..., are served by the new Federatable.setHashlinkMediaDispatcher().
Sending and receiving
A portable actor's activities have to be signed by its DID, which Fedify's key pairs dispatcher does not know about. Sign them with signObject() before sending, and pass normalizeExistingProofs: true so that the activity goes out in the form its proof covers. An activity that embeds a portable object, such as the Note of a Create, is signed inside out:
This example depends on a fix to signObject() that is useful outside FEP-ef61 as well. Previously, a signed object assigned to a typed parent was rebuilt under the parent's JSON-LD context when the parent was serialized. The child kept its proofValue but lost the document its proof covered, so it no longer verified on its own. signObject() now captures the exact document it signed, and nested serialization embeds that document verbatim. The captured document is a snapshot: clone() does not carry it, so sign a clone again if it has to be embedded. The design is in #1044 and the fix in #1051.
Context.sendActivity() refuses a portable activity that is unsigned, signed by the wrong key, or carries a Linked Data Signature, and throws a TypeError before anything is queued. It also refuses a few shapes of compound document that Fedify's own inboxes would reject, such as a received signed Follow embedded in an Accept; refer to such an object by its ID instead. Deliveries to portable inboxes go through the recipient's gateways, or else the @gateway hints on the inbox, trying at most five of them until one accepts. Before this release, they failed with UrlError: Unsupported protocol: ap+ef61:. The rules are in Delivering to portable actors and Producing a compound document.
On the receiving side, a portable inbox is reached as POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox and dispatched to the same inbox listeners as the ordinary inbox. Context.parseUri() gains a portable option, and the parsed result has the DID in a new authority property to check against the one you store. Fedify accepts an activity of a portable actor, or one with a portable ID, only if it has a valid proof by that DID; neither an HTTP Signature nor a Linked Data Signature authenticates it. Each portable object embedded in the activity is verified against its own proof before dispatch. An accepted activity that carries its own proof is forwarded to the actor's other gateways at most once, as FEP-ef61 recommends. Forwarding needs a key–value store that supports cas(), and the FederationOptions.portableInboxForwarding option tunes it. See Portable inboxes and Forwarding to other gateways.
Consuming portable objects
Applications that never host a portable actor still meet portable objects from other servers. Context.lookupObject() resolves portable IDs, compatible identifiers, and the fediverse handles of portable actors, and returns portable objects only if they verify:
const actor = await ctx.lookupObject("@alice@example.com");// A portable ID without @gateway hints needs gateways to ask:const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", { gateways: ["https://example.com"],});
Property accessors fetch portable references through gateways as well, and they verify what they fetch with the new verifyPortableObject option. Context has a matching verifyPortableObject property, so passing a context as the options, as in await create.getObject(ctx), is enough. Activities received in inboxes use this verifier by default, and objects fetched through their accessors inherit it, so a chained call such as (await create.getObject(ctx))?.getAttribution() verifies each portable object along the way. A lookup asks at most five gateways, falling back to the next when one fails or serves something that does not verify. Unsigned collections are accepted only from a gateway that the owner lists, and only for an actor's inbox, outbox, followers, following, and liked, as FEP-ef61 allows. fetchPortableMedia() retrieves the media of a portable object and returns it only if it matches the object's digestMultibase. See Dereferencing portable references and Default verifiers.
Compatible identifiers are no longer trusted by origin
Software that does not understand ap: URIs can refer to a portable object by a compatible identifier, an HTTP(S) URL under a gateway's /.well-known/apgateway/ path such as https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor. Some implementations, tootik among them, use compatible identifiers as the IDs of their portable actors.
Earlier versions of Fedify treated these URLs as ordinary web URLs and trusted them by the server that served them. Since anyone can serve a compatible identifier for any DID, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor could act as Alice without a proof by Alice's DID. Fedify 2.4.0 treats a compatible identifier as the portable object it stands for, as FEP-ef61 requires. Inboxes reject an activity from an actor whose ID is a compatible identifier with 401 Unauthorized unless it has a valid Object Integrity Proof by the actor's DID, and there is no option to turn this off. Actors that publish compatible identifiers without signing them are no longer accepted, and neither are those whose DIDs use key types that Fedify cannot verify yet. The change was tracked in #1093 and made in #1105.
Your own portable actors may still use compatible identifiers as their IDs, for software that rejects ap: IDs. Build them with toCompatibleEf61Id() before signing, and Fedify will treat them as portable wherever it serves or sends them. It also warns if one is malformed or does not point at the actor's first gateway. See Compatible identifiers as actor IDs.
Where Fedify's profile differs from the FEP
Two choices in Fedify's profile differ from the current FEP text or rest on semantics that are not settled yet. Fedify keeps both for the 2.x series, and either may change in Fedify 3.0.
The first is the scheme. FEP-ef61 recommends ap: and canonicalizes ap+ef61: to it, while also warning that the recommendation may change to ap+ef61:, since these IDs are meant only for portable objects. Fedify accepts both, compares them as equal, and serializes ap+ef61:. Parsing a document normalizes its portable IRIs to ap+ef61:, so re-serializing an object that another implementation signed with ap: IDs breaks its proof. Keep the received JSON of such documents, and compare portable IDs with arePortableUrisEqual() rather than as strings. The discussion is in #826 and #828, and the reasoning is in Canonical ap+ef61: scheme.
The second concerns compound documents. When a signed Note sits inside a signed Create, the verifier has to know which part of the JSON each proof covers, and neither FEP-8b32 nor Verifiable Credential Data Integrity defines that yet; the question is open in w3c/vc-data-integrity#350. Fedify uses an interim map-local profile, worked out in #938. Each JSON map with a proof is its own secured document, each embedded portable object needs its own proof and its own @context, and proof sets and chains are not supported. The profile authenticates the exact JSON values each proof covers; it does not promise that a child's JSON-LD expansion inside its parent matches its expansion on its own. @silverpill, @dmitrizagidulin, and @by_caballero reviewed the proposal in that thread, and @silverpill reproduced its test vectors independently. See Map-local compound proofs.
What is not implemented
Fedify does not implement FEP-ae97, the companion proposal for client-side signing. There are no endpoints for registering actors, submitting client-signed activities to a portable outbox, or gateway discovery, and no media upload and deletion endpoints under /.well-known/apgateway-media. Your own outbox listeners can still relay activities that a client signed, unchanged. Fedify also does not copy or reconcile actors, objects, or collections between gateways, so running an actor on several gateways means keeping their data in sync yourself. There is no workflow for rotating a DID's key or migrating an actor to another DID. Fedify resolves verification methods only for did:key DIDs with Ed25519 keys; other DID methods verify only if your document loader resolves them. Gateways must be plain origins, since the arbitrary gateway paths that FEP-ef61 discusses are not supported. The full list is in Not supported.
Interoperability
The profile was tested against tootik v0.25.4 and Mitra v5.10.0 in #1202. Fedify verifies tootik's portable actors, activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on activities that a client signed. Both accept the portable activities that Fedify sends. Two fixes came out of this testing. Fedify now reads a gateways list that tootik leaves unmapped in its JSON-LD context. It also serializes gateways as bare origins, because Mitra rejects a trailing slash. Fedify has not yet verified an actor or activity that Mitra signed itself, and Mastodon's handling of portable actors has not been tested. The FEP-ef61 interoperability section of FEDERATION.md records what was tested and how.
Tooling
fedify lookup looks up portable objects by their ap: and ap+ef61: IDs, compatible identifiers, and the handles of portable actors, and it verifies their proofs. When no gateway returns an acceptable object, it says why. A new --gateway option on fedify lookup, fedify inbox, and fedify webfinger names the gateways to ask. fedify inbox --follow follows portable actors, and fedify webfinger accepts portable actor IDs. See Looking up portable objects in the CLI manual.
The mock federation and contexts in @fedify/testing support portable IDs, verification against fixture document loaders, gateway requests, and hashlink media, so tests can exercise portable objects without a live gateway. @fedify/lint's actor URI rules accept the portable IDs that the getPortable*Uri() methods build. @fedify/next now passes hashlink media requests to Fedify, a problem reported in #1149 and fixed in #1170; to have Next.js run the middleware for them, add { source: "/.well-known/apgateway/:path*" } to your matcher.
Background tasks
Fedify already runs activity delivery and inbox processing on queue workers, but applications usually have jobs of their own: digest emails, timeline rebuilds, link previews. Until now, those jobs could not use Fedify's queues, serialization, or retries. Fedify 2.4.0 opens the same machinery to application-defined tasks.
A task is defined once with defineTask() and enqueued from any Context with enqueueTask() or enqueueTaskMany(). Every task takes a Standard Schema, such as a Zod or Valibot schema, which types its payload. Fedify validates the payload when it is enqueued and again when a worker picks it up; the second check catches payloads from an earlier deployment that no longer match the schema:
import { createExponentialBackoffPolicy } from "@fedify/fedify";import { z } from "zod";const sendDigest = federation.defineTask("sendDigest", { schema: z.object({ userId: z.string(), since: z.date() }), handler: async (ctx, data) => { // data is typed as { userId: string; since: Date } await sendEmail(data.userId, await buildDigest(data.userId, data.since)); }, retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),});// Anywhere with a Context:await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, { delay: { minutes: 30 }, deduplicationKey: "digest:alice",});
Fedify serializes payloads with devalue, so Date, Map, Set, URL, bigint, Temporal values, circular references, and Activity Vocabulary objects all survive the trip through any message queue backend. A Note in a payload comes back as a Note. Failed handlers are retried with exponential backoff by default, and queues with native retries handle retries themselves. Tasks fall back to the outbox queue unless you give them their own. Use the new task slot of the queue option or a per-task queue for that, and startQueue(ctxData, { queue: "task" }) runs a task-only worker. Setting taskQueueResolution: "strict" turns the fallback into an error.
A deduplicationKey deduplicates enqueues. The feature came from @julian's point in #206 that recurring tasks are hard to manage when you cannot tell whether one is already queued. A queue that declares nativeDeduplication enforces the key itself; otherwise, Fedify keeps a best-effort marker in the key–value store through cas(). Each task runs in a fedify.task span, and the queue metrics from 2.3.0 now include task queues. The Background tasks chapter covers routing, retries, deduplication, and the limitations; cron-style scheduling, result backends, and priorities are out of scope for now.
ChanHaeng Lee (@2chanhaeng) implemented the API: the core in #803, deduplication in #806, and observability in #812, following the plans in #797, #798, and #799. The work was merged in #923, closing #206, which had been open since February 2025.
Media uploads
Fedify 2.2.0 added client-to-server outbox listeners, but a C2S client also has to upload images and video before it can post them. That traffic does not go through POST /outbox. Fedify 2.4.0 supports the ActivityPub Media Upload extension, a SocialCG proposal last updated in 2017 that never reached W3C Recommendation status.
setMediaUploader() registers a multipart/form-data endpoint. Its callback receives the uploaded file and the object shell the client posted along with it. If the callback returns the created object, Fedify responds with 201 Created; if it returns a URL where the object will appear once processing finishes, Fedify responds with 202 Accepted:
The actor dispatcher advertises the endpoint itself by setting endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }), the same way it places its inbox and outbox URIs. Without an authorize() hook, anyone who finds the endpoint can upload to it. For that reason, Fedify logs a warning when an uploader is registered without one, and when the returned object's URI does not match an object dispatcher or the actor does not advertise the endpoint. Four new @fedify/lint rules catch the same mistakes before they run. Fedify reads the upload into memory and sets no size limit of its own, so cap the request size at your reverse proxy. Storing the file and serving the object back are up to the application. See the Media upload chapter; the work is in #754 and #927.
Inbox request reports
Fedify decides several things about every inbox delivery: which signature mechanisms applied, which keys they tried, whether the actor owned the key, and what happened to the activity. Before this release, applications saw only part of that. A Linked Data Signature by a key the actor did not own was counted the same as a bad signature, and the key that verified a request was never handed to the application. The request in #1191 came from an effort to log these decisions for a debugging tool.
The new onRequestFinished() callback on inbox listeners receives an InboxRequestReport for every delivery to an inbox route, including rejected requests and requests that fail before verification:
Each HTTP Signature, Linked Data Signature, or Object Integrity Proof check appears as an attempt with the actual keys it tried, including a stale cached key and its refreshed replacement. The report also carries the final authentication decision, such as actorKeyMismatch or proofPolicy, and the response or exception that ended the request. Fedify awaits the callback whether or not the trace is sampled. Errors it throws are logged without changing the response, and enqueued means the queue accepted the activity, not that a worker later processed it. Mock federations in @fedify/testing accept the same callback. See Observing inbox requests; the implementation is in #1201.
Tombstones for deleted objects
Actor dispatchers have been able to return a Tombstone since 2.2.0, but object dispatchers could only return null. A deleted post was answered with 404 Not Found, as if it had never existed, so applications that wanted 410 Gone, as ActivityPub recommends, needed their own route in front of Fedify.
Object dispatchers may now return a Tombstone, which Fedify serves with 410 Gone and the serialized tombstone after applying the authorization predicate:
federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", async (ctx, values) => { const post = await getPost(values.identifier, values.id); if (post == null) return null; if (post.deletedAt != null) { return new Tombstone({ id: ctx.getObjectUri(Note, values), formerType: Note, deleted: post.deletedAt, }); } return new Note({ id: ctx.getObjectUri(Note, values), content: post.content }); },);
The return type of ObjectDispatcher widens to TObject | Tombstone | null. RequestContext.getObject() still returns null for a tombstone unless you pass { tombstone: "passthrough" } or ask for the Tombstone or Object class itself. Dispatchers registered for Tombstone or Object that already returned tombstones now respond with 410 rather than 200. See Deleted objects; the change was proposed in #1112 and made in #1117.
Limits on what a remote peer can make Fedify do
Much of what Fedify fetches is chosen by the sender of an incoming request. Key IDs in signatures are one example, and the servers behind them can be slow on purpose. Several changes in 2.4.0 bound that work.
As #1130 points out, a request may carry several RFC 9421 signatures, and each one can make Fedify fetch a key from a URL of the sender's choosing. Fedify now verifies at most the first three, in Signature-Input order, and ignores the rest. A signature counts toward the limit even if it fails before its key is fetched, and a key named by several signatures is looked up once. The new FederationOptions.maxHttpSignatures option, added with the limit in #1166, changes it.
The built-in document loaders now give each call ten seconds by default, a limit proposed in #1131 and added in #1169. The time limit covers every redirect, alternate document, double-knocking retry, and the response body. A timed-out call throws a FetchError whose cause is a DOMException named "TimeoutError". Error responses are read in full before the loader throws, up to 1 MiB, so that a server cannot send a 404 header and then stall the body. FederationOptions.documentLoaderTimeout changes the limit or turns it off with null, and custom document loaders are not affected. Since #913, getDocumentLoader() also rejects HTML responses that do not advertise an ActivityPub alternate with a FetchError, rather than failing inside JSON.parse(), which #912 found cluttering error trackers.
Cached public keys and remembered HTTP Message Signatures specs used to live forever in the key–value store. #1017 reports a production Redis instance holding 194,424 public-key entries, about 165 MiB, for actors that had mostly stopped federating. Keys now expire after 30 days and specs after 90, configurable through FederationOptions.publicKeyTtl and FederationOptions.httpMessageSignaturesSpecTtl. Entries written by earlier versions have no expiry until they are next written. The Clearing legacy cache entries section shows how to remove them for Redis, PostgreSQL, and other stores. Heewon Chae (@heeoneie) implemented this in #1027, their first contribution to Fedify.
Finally, verifyProof() now authenticates every option of an Ed25519 JCS proof except proofValue, including expires, domain, challenge, and nonce. It also rejects expired or malformed ones. Previously, a field such as expires could be added to an already signed proof without invalidating it. Jiwon Kwon (@z9mb1) spotted this in review of #968. Callers can now require a domain and challenge through VerifyProofOptions to prevent cross-domain or replayed use.
New packages
@fedify/adonisjs
@fedify/adonisjs integrates Fedify with AdonisJS. Running node ace add @fedify/adonisjs registers a service provider that owns the federation's lifecycle and a server middleware that mounts it. It also scaffolds config/fedify.ts, start/federation.ts, and app/federation/main.ts. Controllers get a ctx.federation request context:
// app/federation/main.tsimport federation from "@fedify/adonisjs/services/builder";import { Person } from "@fedify/vocab";federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));
The middleware sits first in AdonisJS's server stack rather than its router stack. That way Fedify sees paths the router does not know, such as /.well-known/webfinger, and reads request bodies before the body parser consumes them. The package targets Node.js and is published to npm only. See the AdonisJS section of the integration guide.
Samuel Brinkmann (@sabrinkmann) contributed the package in #1006, closing #139, and built it on a prototype that Emelia Smith (@thisismissem) attached to that issue.
GoToSocial interaction controls, FEP-044f quote authorization, and FEP-7aa9 featured collections share a request-and-authorization flow. A server asks to reply to, quote, or feature a post, and the author's server approves according to the post's interactionPolicy. @fedify/interaction-controls implements that flow for like, reply, announce, quote, and feature interactions. Each has a helper, such as likeInteraction or quoteInteraction, that creates and verifies requests and authorizations, evaluates policies, and recognizes interactions that arrived without a request:
import { likeInteraction } from "@fedify/interaction-controls";// When a LikeRequest arrives:const verified = await likeInteraction.verifyRequest(ctx, { request });if (verified.verified) { const decision = await likeInteraction.evaluatePolicy(ctx, { subject: note, requester: request.actorId!, }); if (decision.result === "automatic") { // or "manual" or "denied" const authorization = likeInteraction.createAuthorization({ id: new URL("https://example.com/authorizations/1"), attributedTo: owner, interactingObject: like, interactionTarget: note, }); // ... }}
The package does not install inbox listeners or store anything; the application decides where requests and authorizations live and when a human needs to approve. Verification fails closed. Without a policy, likes, replies, and announces are approved automatically, while quotes and features are denied. @fedify/vocab gains the FEP-7aa9 types this needs: FeaturedCollection, FeaturedItem, FeatureRequest, and FeatureAuthorization, along with actors' featuredCollections and InteractionPolicy.canFeature. Samuel Brinkmann asked for them in #810 once Mastodon 4.6 shipped the FEP. See the Interaction controls chapter; the work is in #914 and #929.
@fedify/netlify lets a Fedify application run on Netlify Functions without an external database or queue. NetlifyMessageQueue submits jobs as Netlify Async Workloads events, and createNetlifyQueueHandler() turns a Function into their consumer, with delayed delivery, native retries, and per-key FIFO ordering. NetlifyBlobsKvStore is a key–value store on Netlify Blobs with expiration, prefix listing, and compare-and-swap:
import { createNetlifyQueueHandler, NetlifyBlobsKvStore, NetlifyMessageQueue,} from "@fedify/netlify";import { AsyncWorkloadsClient } from "@netlify/async-workloads";import { getStore } from "@netlify/blobs";export const kv = new NetlifyBlobsKvStore( getStore({ name: "fedify", consistency: "strong" }),);export const queue = new NetlifyMessageQueue({ client: new AsyncWorkloadsClient(), orderingKv: kv,});// netlify/functions/fedify-queue.tsexport default createNetlifyQueueHandler({ queue, maxRetries: 4, federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),});
The key–value store exists because of cost. #1010 describes a static blog whose ActivityPub traffic kept a Netlify Database from ever idling, which used most of the account's monthly credits. Jiwon Kwon contributed NetlifyBlobsKvStore in #1029, and the queue landed in #934. See Netlify Functions in the deployment guide.
@fedify/pglite provides PgliteKvStore, a KvStore on an embedded PGlite database that you create and pass in. It uses the same schema as PostgresKvStore. It is meant for a single PGlite instance in a single runtime isolate, so it does not include a message queue: PGlite does not share data between processes.
import { PGlite } from "@electric-sql/pglite";import { PgliteKvStore } from "@fedify/pglite";const federation = createFederation({ kv: new PgliteKvStore(new PGlite("./data/fedify")),});
ChanHaeng Lee contributed the package in #1020, together with testKvStore() in @fedify/testing. testKvStore() is a conformance suite for KvStore implementations, the counterpart to testMessageQueue(). See PgliteKvStore.
Portable inbox forwarding, task deduplication, Netlify queue ordering, and the delivery circuit breaker all need an atomic compare-and-swap on the key–value store. PostgresKvStore now implements cas(), added in #934, and so does RedisKvStore, requested in #1163 and added in #1167. The Redis version uses a single-key Lua script, which works on standalone Redis and Redis Cluster. Before this, the circuit breaker fell back to racy updates on Redis.
Both come with conditions. RedisKvStore needs Redis to permit EVAL, and custom codecs must encode equal values identically. PostgresKvStore now creates logged tables by default, because losing sequence state in crash recovery could block queued events for good. It migrates existing unlogged tables when it initializes. That one-time migration rewrites the table and locks it exclusively, so schedule the upgrade accordingly if the table is large or busy; unlogged: true keeps the old behavior.
fedify init and the CLI
fedify init can now scaffold a SvelteKit project, contributed by Jang Hanarae (@menele) in #971. Jang Hanarae also added a test task to generated projects in #990. It starts the app, waits for it to be ready, and checks that it resolves a local actor, which gives new projects a smoke test to run after scaffolding and after later changes. Before generating anything, fedify init now checks that the selected Deno, Bun, or Node.js meets Fedify's minimum version, or a framework's higher one, such as Astro's Node.js 22.12. It fails with a clear error in non-interactive mode, and in interactive mode it disables the affected package managers. Lee Jeongmin (@userjmmm) contributed the check in #981. Astro projects now use Astro 7, and since #936@fedify/astro is tested against Astro 5, 6, and 7 with real builds on Node.js, Deno, and Bun, as #931 asked.
fedify tunnel gains fedify.com.es, a tunneling service run by the Fedify project that needs no account; the CLI pins its SSH host key and refuses a mismatched server before exposing a local port. localhost.run is removed in the same change, #940, because the service no longer exists. fedify lookup now times out each request after ten seconds when -T/--timeout is not given, and the option now also bounds the wait for a DNS lookup. The CLI moves to Optique 1.3.2 in #1197, which handles attached values like --timeout=30 consistently, gives better suggestions for mistyped options, and no longer hides errors for known options behind positional arguments before --.
Lint rules
@fedify/lint gains several rules beyond the media upload ones mentioned above. actor-preferred-username-required, requested in #895 and added in #1022, warns when an actor dispatcher returns an actor without preferredUsername. outbox-listener-delivery-not-awaited, from #1057 and #1067, reports an outbox listener that calls ctx.sendActivity() or ctx.forwardActivity() and drops the promise. On Cloudflare Workers, such an activity may never leave, because pending work is discarded once the response returns. await, return, Promise.all(), Promise.allSettled(), and waitUntil() count as handling the promise, while void, Promise.race(), and Promise.any() are accepted as a deliberate choice not to wait. The rule is a warning in the ESLint recommended configuration and an error in strict. Oxlint users enable it by name, and it is not available in Deno Lint.
In #1050, which resolves #900, the existing outbox-listener-delivery-required rule began following control flow instead of scanning the listener's source as text. It reports a listener whose only delivery calls sit in a dead branch, after an unconditional return or throw, or in a function that is never used, and it stays quiet when it cannot tell. Jae-Hyuk-Jang (@jhyuk) contributed this change and both new rules. archie (@ArchieTansaria) then taught both delivery rules about statically false loops and loop binding patterns in #1088. See the linting guide.
Other changes
@fedify/vocab supports the FEP-22cd draft for translations. A Translation records a translated version's language, translators, source object, and source review time, and the new Object.translations property attaches them to a post without creating separate posts. See Translation metadata. (#1037, #1038)
@fedify/vocab-tools gains an extraContext property option, which adds a JSON-LD context only when its terms are used, and a trustEmbeddedObjects type option. (#1037, #1038)
Vocabulary fetch and parse failures that suppressError: true handles are now logged as warnings rather than errors, so that they no longer show up as application errors. Contributed by Jae Hui Hong (@axz0612). (#933, #1035)
A queue whose enqueueMany() makes separate sends can set MessageQueue.atomicEnqueueMany to false. Fedify then rejects a multi-message batch governed by one deduplicationKey up front, since a partial send followed by a retry could enqueue duplicates. (#930, #934)
CommonJS builds that use Temporal bundle temporal-polyfill instead of requiring @js-temporal/polyfill at runtime, and type declarations rely on the standard esnext.temporal lib. (#823, #925)
The document loaders' Accept header now includes the ActivityStreams profile on the JSON-LD media type, as ActivityPub and FEP-ef61 gateways require. (#834, #1077)
The relay documentation now uses the canonical actor and shared inbox URIs, separates Mastodon-style and LitePub-style subscriptions, and explains what remains the application's responsibility. Contributed by Jiwon Kwon. (#899, #996)
Bug fixes
Fixed outbound delivery circuit breaker transitions on key–value stores that compare encoded values. Existing half-open states can recover after switching to a CAS-capable store. (#1163, #1167)
Fixed fedify lookup --recurse reporting a timeout or other network failure as a possibly private object; it now reports the actual cause. (#1156, #1199)
Fixed the -p/--allow-private-address option of fedify webfinger being ignored. (#1156, #1199)
Fixed outbox-listener-delivery-required and outbox-listener-delivery-not-awaited losing track of an object's functions when a nested helper assigns another property of that object. (#1125, #1140)
Update the other @fedify/* packages you use to 2.4.0 at the same time. To update the CLI, run npm install -g @fedify/cli@2.4.0, or see the installation instructions for other package managers.
Most applications need no code changes, but these changes in behavior may affect you:
Activities from actors whose IDs are FEP-ef61 compatible identifiers are rejected with 401 Unauthorized unless they carry a valid Object Integrity Proof by the actor's DID. Keys at compatible identifiers, and keys at ordinary URLs that claim a portable owner, are no longer trusted by origin.
Property accessors given a Context as their options, such as await create.getObject(ctx), now verify the portable objects they dereference, including references to compatible identifiers from objects with ordinary HTTP(S) IDs.
Your dispatchers may be called for requests to /.well-known/apgateway/, and Context.sendActivity() may reject an activity that embeds others' portable objects. Next.js applications need the matcher shown above to serve hashlink media.
Only the first three RFC 9421 signatures of a request are verified. Set maxHttpSignatures if you need more.
The built-in document loaders time out after ten seconds, and fedify lookup does too.
Cached public keys expire after 30 days and HTTP Message Signatures specs after 90 days.
Object dispatchers registered for Tombstone or Object that return tombstones now respond with 410 Gone instead of 200 OK.
PostgresKvStore migrates existing unlogged tables to logged tables on initialization, which locks the table.
RedisKvStore needs EVAL permission for cas().
Queue workers from older versions deliver queued activities for portable inboxes only through the first gateway. Upgrade workers before the servers that enqueue deliveries.
Custom implementations of the Context and RequestContext interfaces need the new getPortable*Uri() methods and isSignedByAudience().
fedify tunnel no longer supports localhost.run.
Acknowledgments
Thanks to this release's contributors:
Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, and the request for FEP-7aa9 vocabulary
Jang Hanarae (@menele): SvelteKit support and the test task in fedify init
Lee Jeongmin (@userjmmm): runtime version verification in fedify init
Jae-Hyuk-Jang (@jhyuk): the actor-preferred-username-required and outbox-listener-delivery-not-awaited lint rules, and control-flow analysis for outbox-listener-delivery-required
archie (@ArchieTansaria): loop handling in the outbox delivery lint rules
Jae Hui Hong (@axz0612): warning-level logging for suppressed vocabulary failures
Heewon Chae (@heeoneie): expiring public key and HTTP Message Signatures spec caches
ChanHaeng Lee (@2chanhaeng) and Jiwon Kwon (@z9mb1) joined as maintainers after 2.3.0. ChanHaeng built the background task API and @fedify/pglite, Jiwon built NetlifyBlobsKvStore and rewrote the relay documentation, and both reviewed much of the rest of this release.
The FEP-ef61 work owes a lot to people outside the project. @anotherdoesnm opened the request in #288. @silverpill, the author of FEP-ef61 and FEP-ae97, clarified the gateway trust model, reviewed the compound proof profile, and checked its test vectors. @dmitrizagidulin and @by_caballero weighed in on the profile's JSON-LD questions in #938. Testing against tootik and Mitra led to two of the interoperability fixes in this release.
そのほか、アプリケーション定義のバックグラウンドタスク、ActivityPub Media Upload拡張、インボックスリクエストのレポート、削除されたオブジェクトに対する410 Goneレスポンスが加わり、新しいパッケージも4つ(AdonisJS、Netlify、PGlite、interaction controls)登場しました。
Fedify is a TypeScript framework for building ActivityPub servers. It implements federation details such as HTTP Signatures, JSON-LD processing, WebFinger, inbox and outbox routing, and activity delivery.
Fedify 2.4.0 is a minor release with an unusually long changelog. The main addition is support for FEP-ef61 portable objects: a Fedify server can now host actors whose identity is a DID rather than a domain, serve and verify their signed objects, and consume the portable objects of other servers. The release also adds an API for application-defined background tasks, the ActivityPub Media Upload extension, a hook that reports how every inbox request was authenticated and handled, and 410 Gone tombstones for deleted objects. Several defaults change to limit what a hostile peer can make Fedify do. Four new packages ship with this version: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, and @fedify/pglite.
Most applications can upgrade without code changes, but a few of the new defaults are behavior changes. They are listed in the Upgrading section at the end.
FEP-ef61 portable objects
An ActivityPub object's ID is an HTTP(S) URL on its server's domain. If that server shuts down, its IDs stop resolving, and an actor that moves elsewhere gets a new ID that everyone has to follow again. FEP-ef61, written by @silverpill of Mitra, proposes IDs that do not name a server at all. A portable ID is an ap: URI whose authority is a DID:
The same object can be stored on several servers, called gateways, each of which serves it under /.well-known/apgateway/. Because no server owns the ID, a portable actor, activity, or object is authenticated only by its FEP-8b32 Object Integrity Proof, made with a key of the DID in its ID. A portable actor lists its gateways in a gateways property, and other servers deliver to its inbox through them.
Fedify 2.4.0 implements a profile of FEP-ef61 for gateways and consumers of portable objects. It makes two deliberate choices where the FEP or the specifications it rests on leave questions open, and it leaves out a few parts of the proposal; both are described below. The new Portable objects chapter of the manual walks through running portable actors from start to finish and then describes the profile as a whole, so that other implementers can tell what to expect from a Fedify server. The request came from @anotherdoesnm in #288, which tracked the work from a roadmap to the final pieces.
Portable objects are a foundation for nomadic identity, not nomadic identity itself. Fedify serves and verifies portable objects and forwards activities between gateways, but it does not copy actors or posts between servers, rotate keys, or move an actor to another DID. Full nomadic identity is still open in #413.
Hosting a portable actor
A portable actor starts with an Ed25519 key pair. Its DID is made from the public key with exportDidKey(), and whoever holds the private key can act as the actor, so store it with the actor and keep it safe:
import { generateCryptoKeyPair } from "@fedify/fedify";import { exportDidKey } from "@fedify/vocab-runtime";const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");const did = await exportDidKey(publicKey); // did:key:z6Mk...// The verification method that the actor's proofs refer to:const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);
The actor dispatcher returns a portable actor as it would an ordinary one. Context.getPortableActorUri() and its siblings for inboxes, outboxes, collections, and objects build portable IDs from the dispatcher's paths and the DID, and signObject() signs the actor with the DID's key:
federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = await findUser(identifier); if (user == null) return null; // A request through the gateway endpoint names the DID in its path, // e.g., GET /.well-known/apgateway/did:key:z6Mk.../users/alice: if ( ctx.portableRequest != null && ctx.portableRequest.authority !== user.did ) { return null; } // This server's gateway keys for the actor (see below): const keys = await ctx.getActorKeyPairs(identifier); return await signObject( new Person({ id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice preferredUsername: user.username, inbox: ctx.getPortableInboxUri(identifier, user.did), outbox: ctx.getPortableOutboxUri(identifier, user.did), followers: ctx.getPortableFollowersUri(identifier, user.did), gateways: [new URL("https://example.com")], publicKeys: keys.map((key) => key.cryptographicKey), assertionMethods: keys.map((key) => key.multikey), }), user.didKeyPair.privateKey, new URL(`${user.did}#${user.did.slice("did:key:".length)}`), ); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = await findUser(identifier); // This server's own key pair for the actor, not the DID's: return user == null ? [] : [user.gatewayKeyPair]; }) .mapPortableActorId(async (ctx, identifier) => { const user = await findUser(identifier); return user == null ? null : ctx.getPortableActorUri(identifier, user.did); });
With this dispatcher, Fedify serves the actor at its compatible identifier, https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice, which is how other servers retrieve it. It serves it through WebFinger as @alice@example.com, since the handle's domain comes from the first gateway in gateways, and at its ordinary URL as before. On the gateway endpoint, Fedify serves a document only if its ID is the requested portable ID and it carries a proof made with a key of that DID.
The key pairs dispatcher and the new ActorCallbackSetters.mapPortableActorId() give the actor gateway keys. These are the server's own keys for the actor, and they sign only the HTTP requests the server makes on the actor's behalf, such as deliveries. They never sign the actor's activities or objects; only the DID's key does that. The actor document lists them under assertionMethods, as FEP-521a describes, so that other servers can verify the HTTP Signatures. See Dispatching the actor and Gateway keys of portable actors for the details.
Object and collection dispatchers serve portable objects and collections through the gateway endpoint in the same way, routed by the path after the DID. Two rules protect the actor's data. A non-public portable object is served only if the dispatcher has an authorization predicate, because FEP-ef61 forbids a gateway to show it to anyone outside its audience; the new RequestContext.isSignedByAudience() helps to write one. Without a predicate, a request for such an object gets 404 Not Found, as though the gateway did not store it. A deleted object is served as a signed Tombstone with 410 Gone. Resources that portable objects refer to by SHA-256 hashlinks, such as GET /.well-known/apgateway/hl:zQm..., are served by the new Federatable.setHashlinkMediaDispatcher().
Sending and receiving
A portable actor's activities have to be signed by its DID, which Fedify's key pairs dispatcher does not know about. Sign them with signObject() before sending, and pass normalizeExistingProofs: true so that the activity goes out in the form its proof covers. An activity that embeds a portable object, such as the Note of a Create, is signed inside out:
This example depends on a fix to signObject() that is useful outside FEP-ef61 as well. Previously, a signed object assigned to a typed parent was rebuilt under the parent's JSON-LD context when the parent was serialized. The child kept its proofValue but lost the document its proof covered, so it no longer verified on its own. signObject() now captures the exact document it signed, and nested serialization embeds that document verbatim. The captured document is a snapshot: clone() does not carry it, so sign a clone again if it has to be embedded. The design is in #1044 and the fix in #1051.
Context.sendActivity() refuses a portable activity that is unsigned, signed by the wrong key, or carries a Linked Data Signature, and throws a TypeError before anything is queued. It also refuses a few shapes of compound document that Fedify's own inboxes would reject, such as a received signed Follow embedded in an Accept; refer to such an object by its ID instead. Deliveries to portable inboxes go through the recipient's gateways, or else the @gateway hints on the inbox, trying at most five of them until one accepts. Before this release, they failed with UrlError: Unsupported protocol: ap+ef61:. The rules are in Delivering to portable actors and Producing a compound document.
On the receiving side, a portable inbox is reached as POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox and dispatched to the same inbox listeners as the ordinary inbox. Context.parseUri() gains a portable option, and the parsed result has the DID in a new authority property to check against the one you store. Fedify accepts an activity of a portable actor, or one with a portable ID, only if it has a valid proof by that DID; neither an HTTP Signature nor a Linked Data Signature authenticates it. Each portable object embedded in the activity is verified against its own proof before dispatch. An accepted activity that carries its own proof is forwarded to the actor's other gateways at most once, as FEP-ef61 recommends. Forwarding needs a key–value store that supports cas(), and the FederationOptions.portableInboxForwarding option tunes it. See Portable inboxes and Forwarding to other gateways.
Consuming portable objects
Applications that never host a portable actor still meet portable objects from other servers. Context.lookupObject() resolves portable IDs, compatible identifiers, and the fediverse handles of portable actors, and returns portable objects only if they verify:
const actor = await ctx.lookupObject("@alice@example.com");// A portable ID without @gateway hints needs gateways to ask:const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", { gateways: ["https://example.com"],});
Property accessors fetch portable references through gateways as well, and they verify what they fetch with the new verifyPortableObject option. Context has a matching verifyPortableObject property, so passing a context as the options, as in await create.getObject(ctx), is enough. Activities received in inboxes use this verifier by default, and objects fetched through their accessors inherit it, so a chained call such as (await create.getObject(ctx))?.getAttribution() verifies each portable object along the way. A lookup asks at most five gateways, falling back to the next when one fails or serves something that does not verify. Unsigned collections are accepted only from a gateway that the owner lists, and only for an actor's inbox, outbox, followers, following, and liked, as FEP-ef61 allows. fetchPortableMedia() retrieves the media of a portable object and returns it only if it matches the object's digestMultibase. See Dereferencing portable references and Default verifiers.
Compatible identifiers are no longer trusted by origin
Software that does not understand ap: URIs can refer to a portable object by a compatible identifier, an HTTP(S) URL under a gateway's /.well-known/apgateway/ path such as https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor. Some implementations, tootik among them, use compatible identifiers as the IDs of their portable actors.
Earlier versions of Fedify treated these URLs as ordinary web URLs and trusted them by the server that served them. Since anyone can serve a compatible identifier for any DID, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor could act as Alice without a proof by Alice's DID. Fedify 2.4.0 treats a compatible identifier as the portable object it stands for, as FEP-ef61 requires. Inboxes reject an activity from an actor whose ID is a compatible identifier with 401 Unauthorized unless it has a valid Object Integrity Proof by the actor's DID, and there is no option to turn this off. Actors that publish compatible identifiers without signing them are no longer accepted, and neither are those whose DIDs use key types that Fedify cannot verify yet. The change was tracked in #1093 and made in #1105.
Your own portable actors may still use compatible identifiers as their IDs, for software that rejects ap: IDs. Build them with toCompatibleEf61Id() before signing, and Fedify will treat them as portable wherever it serves or sends them. It also warns if one is malformed or does not point at the actor's first gateway. See Compatible identifiers as actor IDs.
Where Fedify's profile differs from the FEP
Two choices in Fedify's profile differ from the current FEP text or rest on semantics that are not settled yet. Fedify keeps both for the 2.x series, and either may change in Fedify 3.0.
The first is the scheme. FEP-ef61 recommends ap: and canonicalizes ap+ef61: to it, while also warning that the recommendation may change to ap+ef61:, since these IDs are meant only for portable objects. Fedify accepts both, compares them as equal, and serializes ap+ef61:. Parsing a document normalizes its portable IRIs to ap+ef61:, so re-serializing an object that another implementation signed with ap: IDs breaks its proof. Keep the received JSON of such documents, and compare portable IDs with arePortableUrisEqual() rather than as strings. The discussion is in #826 and #828, and the reasoning is in Canonical ap+ef61: scheme.
The second concerns compound documents. When a signed Note sits inside a signed Create, the verifier has to know which part of the JSON each proof covers, and neither FEP-8b32 nor Verifiable Credential Data Integrity defines that yet; the question is open in w3c/vc-data-integrity#350. Fedify uses an interim map-local profile, worked out in #938. Each JSON map with a proof is its own secured document, each embedded portable object needs its own proof and its own @context, and proof sets and chains are not supported. The profile authenticates the exact JSON values each proof covers; it does not promise that a child's JSON-LD expansion inside its parent matches its expansion on its own. @silverpill, @dmitrizagidulin, and @by_caballero reviewed the proposal in that thread, and @silverpill reproduced its test vectors independently. See Map-local compound proofs.
What is not implemented
Fedify does not implement FEP-ae97, the companion proposal for client-side signing. There are no endpoints for registering actors, submitting client-signed activities to a portable outbox, or gateway discovery, and no media upload and deletion endpoints under /.well-known/apgateway-media. Your own outbox listeners can still relay activities that a client signed, unchanged. Fedify also does not copy or reconcile actors, objects, or collections between gateways, so running an actor on several gateways means keeping their data in sync yourself. There is no workflow for rotating a DID's key or migrating an actor to another DID. Fedify resolves verification methods only for did:key DIDs with Ed25519 keys; other DID methods verify only if your document loader resolves them. Gateways must be plain origins, since the arbitrary gateway paths that FEP-ef61 discusses are not supported. The full list is in Not supported.
Interoperability
The profile was tested against tootik v0.25.4 and Mitra v5.10.0 in #1202. Fedify verifies tootik's portable actors, activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on activities that a client signed. Both accept the portable activities that Fedify sends. Two fixes came out of this testing. Fedify now reads a gateways list that tootik leaves unmapped in its JSON-LD context. It also serializes gateways as bare origins, because Mitra rejects a trailing slash. Fedify has not yet verified an actor or activity that Mitra signed itself, and Mastodon's handling of portable actors has not been tested. The FEP-ef61 interoperability section of FEDERATION.md records what was tested and how.
Tooling
fedify lookup looks up portable objects by their ap: and ap+ef61: IDs, compatible identifiers, and the handles of portable actors, and it verifies their proofs. When no gateway returns an acceptable object, it says why. A new --gateway option on fedify lookup, fedify inbox, and fedify webfinger names the gateways to ask. fedify inbox --follow follows portable actors, and fedify webfinger accepts portable actor IDs. See Looking up portable objects in the CLI manual.
The mock federation and contexts in @fedify/testing support portable IDs, verification against fixture document loaders, gateway requests, and hashlink media, so tests can exercise portable objects without a live gateway. @fedify/lint's actor URI rules accept the portable IDs that the getPortable*Uri() methods build. @fedify/next now passes hashlink media requests to Fedify, a problem reported in #1149 and fixed in #1170; to have Next.js run the middleware for them, add { source: "/.well-known/apgateway/:path*" } to your matcher.
Background tasks
Fedify already runs activity delivery and inbox processing on queue workers, but applications usually have jobs of their own: digest emails, timeline rebuilds, link previews. Until now, those jobs could not use Fedify's queues, serialization, or retries. Fedify 2.4.0 opens the same machinery to application-defined tasks.
A task is defined once with defineTask() and enqueued from any Context with enqueueTask() or enqueueTaskMany(). Every task takes a Standard Schema, such as a Zod or Valibot schema, which types its payload. Fedify validates the payload when it is enqueued and again when a worker picks it up; the second check catches payloads from an earlier deployment that no longer match the schema:
import { createExponentialBackoffPolicy } from "@fedify/fedify";import { z } from "zod";const sendDigest = federation.defineTask("sendDigest", { schema: z.object({ userId: z.string(), since: z.date() }), handler: async (ctx, data) => { // data is typed as { userId: string; since: Date } await sendEmail(data.userId, await buildDigest(data.userId, data.since)); }, retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),});// Anywhere with a Context:await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, { delay: { minutes: 30 }, deduplicationKey: "digest:alice",});
Fedify serializes payloads with devalue, so Date, Map, Set, URL, bigint, Temporal values, circular references, and Activity Vocabulary objects all survive the trip through any message queue backend. A Note in a payload comes back as a Note. Failed handlers are retried with exponential backoff by default, and queues with native retries handle retries themselves. Tasks fall back to the outbox queue unless you give them their own. Use the new task slot of the queue option or a per-task queue for that, and startQueue(ctxData, { queue: "task" }) runs a task-only worker. Setting taskQueueResolution: "strict" turns the fallback into an error.
A deduplicationKey deduplicates enqueues. The feature came from @julian's point in #206 that recurring tasks are hard to manage when you cannot tell whether one is already queued. A queue that declares nativeDeduplication enforces the key itself; otherwise, Fedify keeps a best-effort marker in the key–value store through cas(). Each task runs in a fedify.task span, and the queue metrics from 2.3.0 now include task queues. The Background tasks chapter covers routing, retries, deduplication, and the limitations; cron-style scheduling, result backends, and priorities are out of scope for now.
ChanHaeng Lee (@2chanhaeng) implemented the API: the core in #803, deduplication in #806, and observability in #812, following the plans in #797, #798, and #799. The work was merged in #923, closing #206, which had been open since February 2025.
Media uploads
Fedify 2.2.0 added client-to-server outbox listeners, but a C2S client also has to upload images and video before it can post them. That traffic does not go through POST /outbox. Fedify 2.4.0 supports the ActivityPub Media Upload extension, a SocialCG proposal last updated in 2017 that never reached W3C Recommendation status.
setMediaUploader() registers a multipart/form-data endpoint. Its callback receives the uploaded file and the object shell the client posted along with it. If the callback returns the created object, Fedify responds with 201 Created; if it returns a URL where the object will appear once processing finishes, Fedify responds with 202 Accepted:
The actor dispatcher advertises the endpoint itself by setting endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }), the same way it places its inbox and outbox URIs. Without an authorize() hook, anyone who finds the endpoint can upload to it. For that reason, Fedify logs a warning when an uploader is registered without one, and when the returned object's URI does not match an object dispatcher or the actor does not advertise the endpoint. Four new @fedify/lint rules catch the same mistakes before they run. Fedify reads the upload into memory and sets no size limit of its own, so cap the request size at your reverse proxy. Storing the file and serving the object back are up to the application. See the Media upload chapter; the work is in #754 and #927.
Inbox request reports
Fedify decides several things about every inbox delivery: which signature mechanisms applied, which keys they tried, whether the actor owned the key, and what happened to the activity. Before this release, applications saw only part of that. A Linked Data Signature by a key the actor did not own was counted the same as a bad signature, and the key that verified a request was never handed to the application. The request in #1191 came from an effort to log these decisions for a debugging tool.
The new onRequestFinished() callback on inbox listeners receives an InboxRequestReport for every delivery to an inbox route, including rejected requests and requests that fail before verification:
Each HTTP Signature, Linked Data Signature, or Object Integrity Proof check appears as an attempt with the actual keys it tried, including a stale cached key and its refreshed replacement. The report also carries the final authentication decision, such as actorKeyMismatch or proofPolicy, and the response or exception that ended the request. Fedify awaits the callback whether or not the trace is sampled. Errors it throws are logged without changing the response, and enqueued means the queue accepted the activity, not that a worker later processed it. Mock federations in @fedify/testing accept the same callback. See Observing inbox requests; the implementation is in #1201.
Tombstones for deleted objects
Actor dispatchers have been able to return a Tombstone since 2.2.0, but object dispatchers could only return null. A deleted post was answered with 404 Not Found, as if it had never existed, so applications that wanted 410 Gone, as ActivityPub recommends, needed their own route in front of Fedify.
Object dispatchers may now return a Tombstone, which Fedify serves with 410 Gone and the serialized tombstone after applying the authorization predicate:
federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", async (ctx, values) => { const post = await getPost(values.identifier, values.id); if (post == null) return null; if (post.deletedAt != null) { return new Tombstone({ id: ctx.getObjectUri(Note, values), formerType: Note, deleted: post.deletedAt, }); } return new Note({ id: ctx.getObjectUri(Note, values), content: post.content }); },);
The return type of ObjectDispatcher widens to TObject | Tombstone | null. RequestContext.getObject() still returns null for a tombstone unless you pass { tombstone: "passthrough" } or ask for the Tombstone or Object class itself. Dispatchers registered for Tombstone or Object that already returned tombstones now respond with 410 rather than 200. See Deleted objects; the change was proposed in #1112 and made in #1117.
Limits on what a remote peer can make Fedify do
Much of what Fedify fetches is chosen by the sender of an incoming request. Key IDs in signatures are one example, and the servers behind them can be slow on purpose. Several changes in 2.4.0 bound that work.
As #1130 points out, a request may carry several RFC 9421 signatures, and each one can make Fedify fetch a key from a URL of the sender's choosing. Fedify now verifies at most the first three, in Signature-Input order, and ignores the rest. A signature counts toward the limit even if it fails before its key is fetched, and a key named by several signatures is looked up once. The new FederationOptions.maxHttpSignatures option, added with the limit in #1166, changes it.
The built-in document loaders now give each call ten seconds by default, a limit proposed in #1131 and added in #1169. The time limit covers every redirect, alternate document, double-knocking retry, and the response body. A timed-out call throws a FetchError whose cause is a DOMException named "TimeoutError". Error responses are read in full before the loader throws, up to 1 MiB, so that a server cannot send a 404 header and then stall the body. FederationOptions.documentLoaderTimeout changes the limit or turns it off with null, and custom document loaders are not affected. Since #913, getDocumentLoader() also rejects HTML responses that do not advertise an ActivityPub alternate with a FetchError, rather than failing inside JSON.parse(), which #912 found cluttering error trackers.
Cached public keys and remembered HTTP Message Signatures specs used to live forever in the key–value store. #1017 reports a production Redis instance holding 194,424 public-key entries, about 165 MiB, for actors that had mostly stopped federating. Keys now expire after 30 days and specs after 90, configurable through FederationOptions.publicKeyTtl and FederationOptions.httpMessageSignaturesSpecTtl. Entries written by earlier versions have no expiry until they are next written. The Clearing legacy cache entries section shows how to remove them for Redis, PostgreSQL, and other stores. Heewon Chae (@heeoneie) implemented this in #1027, their first contribution to Fedify.
Finally, verifyProof() now authenticates every option of an Ed25519 JCS proof except proofValue, including expires, domain, challenge, and nonce. It also rejects expired or malformed ones. Previously, a field such as expires could be added to an already signed proof without invalidating it. Jiwon Kwon (@z9mb1) spotted this in review of #968. Callers can now require a domain and challenge through VerifyProofOptions to prevent cross-domain or replayed use.
New packages
@fedify/adonisjs
@fedify/adonisjs integrates Fedify with AdonisJS. Running node ace add @fedify/adonisjs registers a service provider that owns the federation's lifecycle and a server middleware that mounts it. It also scaffolds config/fedify.ts, start/federation.ts, and app/federation/main.ts. Controllers get a ctx.federation request context:
// app/federation/main.tsimport federation from "@fedify/adonisjs/services/builder";import { Person } from "@fedify/vocab";federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));
The middleware sits first in AdonisJS's server stack rather than its router stack. That way Fedify sees paths the router does not know, such as /.well-known/webfinger, and reads request bodies before the body parser consumes them. The package targets Node.js and is published to npm only. See the AdonisJS section of the integration guide.
Samuel Brinkmann (@sabrinkmann) contributed the package in #1006, closing #139, and built it on a prototype that Emelia Smith (@thisismissem) attached to that issue.
GoToSocial interaction controls, FEP-044f quote authorization, and FEP-7aa9 featured collections share a request-and-authorization flow. A server asks to reply to, quote, or feature a post, and the author's server approves according to the post's interactionPolicy. @fedify/interaction-controls implements that flow for like, reply, announce, quote, and feature interactions. Each has a helper, such as likeInteraction or quoteInteraction, that creates and verifies requests and authorizations, evaluates policies, and recognizes interactions that arrived without a request:
import { likeInteraction } from "@fedify/interaction-controls";// When a LikeRequest arrives:const verified = await likeInteraction.verifyRequest(ctx, { request });if (verified.verified) { const decision = await likeInteraction.evaluatePolicy(ctx, { subject: note, requester: request.actorId!, }); if (decision.result === "automatic") { // or "manual" or "denied" const authorization = likeInteraction.createAuthorization({ id: new URL("https://example.com/authorizations/1"), attributedTo: owner, interactingObject: like, interactionTarget: note, }); // ... }}
The package does not install inbox listeners or store anything; the application decides where requests and authorizations live and when a human needs to approve. Verification fails closed. Without a policy, likes, replies, and announces are approved automatically, while quotes and features are denied. @fedify/vocab gains the FEP-7aa9 types this needs: FeaturedCollection, FeaturedItem, FeatureRequest, and FeatureAuthorization, along with actors' featuredCollections and InteractionPolicy.canFeature. Samuel Brinkmann asked for them in #810 once Mastodon 4.6 shipped the FEP. See the Interaction controls chapter; the work is in #914 and #929.
@fedify/netlify lets a Fedify application run on Netlify Functions without an external database or queue. NetlifyMessageQueue submits jobs as Netlify Async Workloads events, and createNetlifyQueueHandler() turns a Function into their consumer, with delayed delivery, native retries, and per-key FIFO ordering. NetlifyBlobsKvStore is a key–value store on Netlify Blobs with expiration, prefix listing, and compare-and-swap:
import { createNetlifyQueueHandler, NetlifyBlobsKvStore, NetlifyMessageQueue,} from "@fedify/netlify";import { AsyncWorkloadsClient } from "@netlify/async-workloads";import { getStore } from "@netlify/blobs";export const kv = new NetlifyBlobsKvStore( getStore({ name: "fedify", consistency: "strong" }),);export const queue = new NetlifyMessageQueue({ client: new AsyncWorkloadsClient(), orderingKv: kv,});// netlify/functions/fedify-queue.tsexport default createNetlifyQueueHandler({ queue, maxRetries: 4, federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),});
The key–value store exists because of cost. #1010 describes a static blog whose ActivityPub traffic kept a Netlify Database from ever idling, which used most of the account's monthly credits. Jiwon Kwon contributed NetlifyBlobsKvStore in #1029, and the queue landed in #934. See Netlify Functions in the deployment guide.
@fedify/pglite provides PgliteKvStore, a KvStore on an embedded PGlite database that you create and pass in. It uses the same schema as PostgresKvStore. It is meant for a single PGlite instance in a single runtime isolate, so it does not include a message queue: PGlite does not share data between processes.
import { PGlite } from "@electric-sql/pglite";import { PgliteKvStore } from "@fedify/pglite";const federation = createFederation({ kv: new PgliteKvStore(new PGlite("./data/fedify")),});
ChanHaeng Lee contributed the package in #1020, together with testKvStore() in @fedify/testing. testKvStore() is a conformance suite for KvStore implementations, the counterpart to testMessageQueue(). See PgliteKvStore.
Portable inbox forwarding, task deduplication, Netlify queue ordering, and the delivery circuit breaker all need an atomic compare-and-swap on the key–value store. PostgresKvStore now implements cas(), added in #934, and so does RedisKvStore, requested in #1163 and added in #1167. The Redis version uses a single-key Lua script, which works on standalone Redis and Redis Cluster. Before this, the circuit breaker fell back to racy updates on Redis.
Both come with conditions. RedisKvStore needs Redis to permit EVAL, and custom codecs must encode equal values identically. PostgresKvStore now creates logged tables by default, because losing sequence state in crash recovery could block queued events for good. It migrates existing unlogged tables when it initializes. That one-time migration rewrites the table and locks it exclusively, so schedule the upgrade accordingly if the table is large or busy; unlogged: true keeps the old behavior.
fedify init and the CLI
fedify init can now scaffold a SvelteKit project, contributed by Jang Hanarae (@menele) in #971. Jang Hanarae also added a test task to generated projects in #990. It starts the app, waits for it to be ready, and checks that it resolves a local actor, which gives new projects a smoke test to run after scaffolding and after later changes. Before generating anything, fedify init now checks that the selected Deno, Bun, or Node.js meets Fedify's minimum version, or a framework's higher one, such as Astro's Node.js 22.12. It fails with a clear error in non-interactive mode, and in interactive mode it disables the affected package managers. Lee Jeongmin (@userjmmm) contributed the check in #981. Astro projects now use Astro 7, and since #936@fedify/astro is tested against Astro 5, 6, and 7 with real builds on Node.js, Deno, and Bun, as #931 asked.
fedify tunnel gains fedify.com.es, a tunneling service run by the Fedify project that needs no account; the CLI pins its SSH host key and refuses a mismatched server before exposing a local port. localhost.run is removed in the same change, #940, because the service no longer exists. fedify lookup now times out each request after ten seconds when -T/--timeout is not given, and the option now also bounds the wait for a DNS lookup. The CLI moves to Optique 1.3.2 in #1197, which handles attached values like --timeout=30 consistently, gives better suggestions for mistyped options, and no longer hides errors for known options behind positional arguments before --.
Lint rules
@fedify/lint gains several rules beyond the media upload ones mentioned above. actor-preferred-username-required, requested in #895 and added in #1022, warns when an actor dispatcher returns an actor without preferredUsername. outbox-listener-delivery-not-awaited, from #1057 and #1067, reports an outbox listener that calls ctx.sendActivity() or ctx.forwardActivity() and drops the promise. On Cloudflare Workers, such an activity may never leave, because pending work is discarded once the response returns. await, return, Promise.all(), Promise.allSettled(), and waitUntil() count as handling the promise, while void, Promise.race(), and Promise.any() are accepted as a deliberate choice not to wait. The rule is a warning in the ESLint recommended configuration and an error in strict. Oxlint users enable it by name, and it is not available in Deno Lint.
In #1050, which resolves #900, the existing outbox-listener-delivery-required rule began following control flow instead of scanning the listener's source as text. It reports a listener whose only delivery calls sit in a dead branch, after an unconditional return or throw, or in a function that is never used, and it stays quiet when it cannot tell. Jae-Hyuk-Jang (@jhyuk) contributed this change and both new rules. archie (@ArchieTansaria) then taught both delivery rules about statically false loops and loop binding patterns in #1088. See the linting guide.
Other changes
@fedify/vocab supports the FEP-22cd draft for translations. A Translation records a translated version's language, translators, source object, and source review time, and the new Object.translations property attaches them to a post without creating separate posts. See Translation metadata. (#1037, #1038)
@fedify/vocab-tools gains an extraContext property option, which adds a JSON-LD context only when its terms are used, and a trustEmbeddedObjects type option. (#1037, #1038)
Vocabulary fetch and parse failures that suppressError: true handles are now logged as warnings rather than errors, so that they no longer show up as application errors. Contributed by Jae Hui Hong (@axz0612). (#933, #1035)
A queue whose enqueueMany() makes separate sends can set MessageQueue.atomicEnqueueMany to false. Fedify then rejects a multi-message batch governed by one deduplicationKey up front, since a partial send followed by a retry could enqueue duplicates. (#930, #934)
CommonJS builds that use Temporal bundle temporal-polyfill instead of requiring @js-temporal/polyfill at runtime, and type declarations rely on the standard esnext.temporal lib. (#823, #925)
The document loaders' Accept header now includes the ActivityStreams profile on the JSON-LD media type, as ActivityPub and FEP-ef61 gateways require. (#834, #1077)
The relay documentation now uses the canonical actor and shared inbox URIs, separates Mastodon-style and LitePub-style subscriptions, and explains what remains the application's responsibility. Contributed by Jiwon Kwon. (#899, #996)
Bug fixes
Fixed outbound delivery circuit breaker transitions on key–value stores that compare encoded values. Existing half-open states can recover after switching to a CAS-capable store. (#1163, #1167)
Fixed fedify lookup --recurse reporting a timeout or other network failure as a possibly private object; it now reports the actual cause. (#1156, #1199)
Fixed the -p/--allow-private-address option of fedify webfinger being ignored. (#1156, #1199)
Fixed outbox-listener-delivery-required and outbox-listener-delivery-not-awaited losing track of an object's functions when a nested helper assigns another property of that object. (#1125, #1140)
Update the other @fedify/* packages you use to 2.4.0 at the same time. To update the CLI, run npm install -g @fedify/cli@2.4.0, or see the installation instructions for other package managers.
Most applications need no code changes, but these changes in behavior may affect you:
Activities from actors whose IDs are FEP-ef61 compatible identifiers are rejected with 401 Unauthorized unless they carry a valid Object Integrity Proof by the actor's DID. Keys at compatible identifiers, and keys at ordinary URLs that claim a portable owner, are no longer trusted by origin.
Property accessors given a Context as their options, such as await create.getObject(ctx), now verify the portable objects they dereference, including references to compatible identifiers from objects with ordinary HTTP(S) IDs.
Your dispatchers may be called for requests to /.well-known/apgateway/, and Context.sendActivity() may reject an activity that embeds others' portable objects. Next.js applications need the matcher shown above to serve hashlink media.
Only the first three RFC 9421 signatures of a request are verified. Set maxHttpSignatures if you need more.
The built-in document loaders time out after ten seconds, and fedify lookup does too.
Cached public keys expire after 30 days and HTTP Message Signatures specs after 90 days.
Object dispatchers registered for Tombstone or Object that return tombstones now respond with 410 Gone instead of 200 OK.
PostgresKvStore migrates existing unlogged tables to logged tables on initialization, which locks the table.
RedisKvStore needs EVAL permission for cas().
Queue workers from older versions deliver queued activities for portable inboxes only through the first gateway. Upgrade workers before the servers that enqueue deliveries.
Custom implementations of the Context and RequestContext interfaces need the new getPortable*Uri() methods and isSignedByAudience().
fedify tunnel no longer supports localhost.run.
Acknowledgments
Thanks to this release's contributors:
Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, and the request for FEP-7aa9 vocabulary
Jang Hanarae (@menele): SvelteKit support and the test task in fedify init
Lee Jeongmin (@userjmmm): runtime version verification in fedify init
Jae-Hyuk-Jang (@jhyuk): the actor-preferred-username-required and outbox-listener-delivery-not-awaited lint rules, and control-flow analysis for outbox-listener-delivery-required
archie (@ArchieTansaria): loop handling in the outbox delivery lint rules
Jae Hui Hong (@axz0612): warning-level logging for suppressed vocabulary failures
Heewon Chae (@heeoneie): expiring public key and HTTP Message Signatures spec caches
ChanHaeng Lee (@2chanhaeng) and Jiwon Kwon (@z9mb1) joined as maintainers after 2.3.0. ChanHaeng built the background task API and @fedify/pglite, Jiwon built NetlifyBlobsKvStore and rewrote the relay documentation, and both reviewed much of the rest of this release.
The FEP-ef61 work owes a lot to people outside the project. @anotherdoesnm opened the request in #288. @silverpill, the author of FEP-ef61 and FEP-ae97, clarified the gateway trust model, reviewed the compound proof profile, and checked its test vectors. @dmitrizagidulin and @by_caballero weighed in on the profile's JSON-LD questions in #938. Testing against tootik and Mitra led to two of the interoperability fixes in this release.
이번 릴리스의 하이라이트는 FEP-ef61 포터블 客體 支援입니다. 이제 Fedify 서버에서 도메인 代身 DID를 身元으로 삼는 액터를 호스팅하고, 그 署名된 客體를 /.well-known/apgateway/로 서빙하고, 다른 서버의 포터블 客體를 檢證할 수 있습니다. tootik, Mitra와 相互運用 테스트를 했고, Fedify의 具顯이 FEP와 어디가 다르고 무엇을 아직 具顯하지 않았는지도 文書에 整理해 두었습니다.
그 밖에 애플리케이션 定義 백그라운드 태스크, ActivityPub Media Upload 擴張, 인박스 要請 리포트, 削除된 客體에 對한 410 Gone 應答이 들어갔고, 새 패키지도 네 個(AdonisJS, Netlify, PGlite, interaction controls)나 追加되었습니다.
基本값이 바뀐 部分이 있으니, 特히 FEP-ef61 互換 識別子를 쓰는 소프트웨어와 聯合하고 있다면 업그레이드 前에 該當 節을 꼭 읽어 주세요.
Fedify is a TypeScript framework for building ActivityPub servers. It implements federation details such as HTTP Signatures, JSON-LD processing, WebFinger, inbox and outbox routing, and activity delivery.
Fedify 2.4.0 is a minor release with an unusually long changelog. The main addition is support for FEP-ef61 portable objects: a Fedify server can now host actors whose identity is a DID rather than a domain, serve and verify their signed objects, and consume the portable objects of other servers. The release also adds an API for application-defined background tasks, the ActivityPub Media Upload extension, a hook that reports how every inbox request was authenticated and handled, and 410 Gone tombstones for deleted objects. Several defaults change to limit what a hostile peer can make Fedify do. Four new packages ship with this version: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, and @fedify/pglite.
Most applications can upgrade without code changes, but a few of the new defaults are behavior changes. They are listed in the Upgrading section at the end.
FEP-ef61 portable objects
An ActivityPub object's ID is an HTTP(S) URL on its server's domain. If that server shuts down, its IDs stop resolving, and an actor that moves elsewhere gets a new ID that everyone has to follow again. FEP-ef61, written by @silverpill of Mitra, proposes IDs that do not name a server at all. A portable ID is an ap: URI whose authority is a DID:
The same object can be stored on several servers, called gateways, each of which serves it under /.well-known/apgateway/. Because no server owns the ID, a portable actor, activity, or object is authenticated only by its FEP-8b32 Object Integrity Proof, made with a key of the DID in its ID. A portable actor lists its gateways in a gateways property, and other servers deliver to its inbox through them.
Fedify 2.4.0 implements a profile of FEP-ef61 for gateways and consumers of portable objects. It makes two deliberate choices where the FEP or the specifications it rests on leave questions open, and it leaves out a few parts of the proposal; both are described below. The new Portable objects chapter of the manual walks through running portable actors from start to finish and then describes the profile as a whole, so that other implementers can tell what to expect from a Fedify server. The request came from @anotherdoesnm in #288, which tracked the work from a roadmap to the final pieces.
Portable objects are a foundation for nomadic identity, not nomadic identity itself. Fedify serves and verifies portable objects and forwards activities between gateways, but it does not copy actors or posts between servers, rotate keys, or move an actor to another DID. Full nomadic identity is still open in #413.
Hosting a portable actor
A portable actor starts with an Ed25519 key pair. Its DID is made from the public key with exportDidKey(), and whoever holds the private key can act as the actor, so store it with the actor and keep it safe:
import { generateCryptoKeyPair } from "@fedify/fedify";import { exportDidKey } from "@fedify/vocab-runtime";const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");const did = await exportDidKey(publicKey); // did:key:z6Mk...// The verification method that the actor's proofs refer to:const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);
The actor dispatcher returns a portable actor as it would an ordinary one. Context.getPortableActorUri() and its siblings for inboxes, outboxes, collections, and objects build portable IDs from the dispatcher's paths and the DID, and signObject() signs the actor with the DID's key:
federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = await findUser(identifier); if (user == null) return null; // A request through the gateway endpoint names the DID in its path, // e.g., GET /.well-known/apgateway/did:key:z6Mk.../users/alice: if ( ctx.portableRequest != null && ctx.portableRequest.authority !== user.did ) { return null; } // This server's gateway keys for the actor (see below): const keys = await ctx.getActorKeyPairs(identifier); return await signObject( new Person({ id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice preferredUsername: user.username, inbox: ctx.getPortableInboxUri(identifier, user.did), outbox: ctx.getPortableOutboxUri(identifier, user.did), followers: ctx.getPortableFollowersUri(identifier, user.did), gateways: [new URL("https://example.com")], publicKeys: keys.map((key) => key.cryptographicKey), assertionMethods: keys.map((key) => key.multikey), }), user.didKeyPair.privateKey, new URL(`${user.did}#${user.did.slice("did:key:".length)}`), ); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = await findUser(identifier); // This server's own key pair for the actor, not the DID's: return user == null ? [] : [user.gatewayKeyPair]; }) .mapPortableActorId(async (ctx, identifier) => { const user = await findUser(identifier); return user == null ? null : ctx.getPortableActorUri(identifier, user.did); });
With this dispatcher, Fedify serves the actor at its compatible identifier, https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice, which is how other servers retrieve it. It serves it through WebFinger as @alice@example.com, since the handle's domain comes from the first gateway in gateways, and at its ordinary URL as before. On the gateway endpoint, Fedify serves a document only if its ID is the requested portable ID and it carries a proof made with a key of that DID.
The key pairs dispatcher and the new ActorCallbackSetters.mapPortableActorId() give the actor gateway keys. These are the server's own keys for the actor, and they sign only the HTTP requests the server makes on the actor's behalf, such as deliveries. They never sign the actor's activities or objects; only the DID's key does that. The actor document lists them under assertionMethods, as FEP-521a describes, so that other servers can verify the HTTP Signatures. See Dispatching the actor and Gateway keys of portable actors for the details.
Object and collection dispatchers serve portable objects and collections through the gateway endpoint in the same way, routed by the path after the DID. Two rules protect the actor's data. A non-public portable object is served only if the dispatcher has an authorization predicate, because FEP-ef61 forbids a gateway to show it to anyone outside its audience; the new RequestContext.isSignedByAudience() helps to write one. Without a predicate, a request for such an object gets 404 Not Found, as though the gateway did not store it. A deleted object is served as a signed Tombstone with 410 Gone. Resources that portable objects refer to by SHA-256 hashlinks, such as GET /.well-known/apgateway/hl:zQm..., are served by the new Federatable.setHashlinkMediaDispatcher().
Sending and receiving
A portable actor's activities have to be signed by its DID, which Fedify's key pairs dispatcher does not know about. Sign them with signObject() before sending, and pass normalizeExistingProofs: true so that the activity goes out in the form its proof covers. An activity that embeds a portable object, such as the Note of a Create, is signed inside out:
This example depends on a fix to signObject() that is useful outside FEP-ef61 as well. Previously, a signed object assigned to a typed parent was rebuilt under the parent's JSON-LD context when the parent was serialized. The child kept its proofValue but lost the document its proof covered, so it no longer verified on its own. signObject() now captures the exact document it signed, and nested serialization embeds that document verbatim. The captured document is a snapshot: clone() does not carry it, so sign a clone again if it has to be embedded. The design is in #1044 and the fix in #1051.
Context.sendActivity() refuses a portable activity that is unsigned, signed by the wrong key, or carries a Linked Data Signature, and throws a TypeError before anything is queued. It also refuses a few shapes of compound document that Fedify's own inboxes would reject, such as a received signed Follow embedded in an Accept; refer to such an object by its ID instead. Deliveries to portable inboxes go through the recipient's gateways, or else the @gateway hints on the inbox, trying at most five of them until one accepts. Before this release, they failed with UrlError: Unsupported protocol: ap+ef61:. The rules are in Delivering to portable actors and Producing a compound document.
On the receiving side, a portable inbox is reached as POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox and dispatched to the same inbox listeners as the ordinary inbox. Context.parseUri() gains a portable option, and the parsed result has the DID in a new authority property to check against the one you store. Fedify accepts an activity of a portable actor, or one with a portable ID, only if it has a valid proof by that DID; neither an HTTP Signature nor a Linked Data Signature authenticates it. Each portable object embedded in the activity is verified against its own proof before dispatch. An accepted activity that carries its own proof is forwarded to the actor's other gateways at most once, as FEP-ef61 recommends. Forwarding needs a key–value store that supports cas(), and the FederationOptions.portableInboxForwarding option tunes it. See Portable inboxes and Forwarding to other gateways.
Consuming portable objects
Applications that never host a portable actor still meet portable objects from other servers. Context.lookupObject() resolves portable IDs, compatible identifiers, and the fediverse handles of portable actors, and returns portable objects only if they verify:
const actor = await ctx.lookupObject("@alice@example.com");// A portable ID without @gateway hints needs gateways to ask:const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", { gateways: ["https://example.com"],});
Property accessors fetch portable references through gateways as well, and they verify what they fetch with the new verifyPortableObject option. Context has a matching verifyPortableObject property, so passing a context as the options, as in await create.getObject(ctx), is enough. Activities received in inboxes use this verifier by default, and objects fetched through their accessors inherit it, so a chained call such as (await create.getObject(ctx))?.getAttribution() verifies each portable object along the way. A lookup asks at most five gateways, falling back to the next when one fails or serves something that does not verify. Unsigned collections are accepted only from a gateway that the owner lists, and only for an actor's inbox, outbox, followers, following, and liked, as FEP-ef61 allows. fetchPortableMedia() retrieves the media of a portable object and returns it only if it matches the object's digestMultibase. See Dereferencing portable references and Default verifiers.
Compatible identifiers are no longer trusted by origin
Software that does not understand ap: URIs can refer to a portable object by a compatible identifier, an HTTP(S) URL under a gateway's /.well-known/apgateway/ path such as https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor. Some implementations, tootik among them, use compatible identifiers as the IDs of their portable actors.
Earlier versions of Fedify treated these URLs as ordinary web URLs and trusted them by the server that served them. Since anyone can serve a compatible identifier for any DID, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor could act as Alice without a proof by Alice's DID. Fedify 2.4.0 treats a compatible identifier as the portable object it stands for, as FEP-ef61 requires. Inboxes reject an activity from an actor whose ID is a compatible identifier with 401 Unauthorized unless it has a valid Object Integrity Proof by the actor's DID, and there is no option to turn this off. Actors that publish compatible identifiers without signing them are no longer accepted, and neither are those whose DIDs use key types that Fedify cannot verify yet. The change was tracked in #1093 and made in #1105.
Your own portable actors may still use compatible identifiers as their IDs, for software that rejects ap: IDs. Build them with toCompatibleEf61Id() before signing, and Fedify will treat them as portable wherever it serves or sends them. It also warns if one is malformed or does not point at the actor's first gateway. See Compatible identifiers as actor IDs.
Where Fedify's profile differs from the FEP
Two choices in Fedify's profile differ from the current FEP text or rest on semantics that are not settled yet. Fedify keeps both for the 2.x series, and either may change in Fedify 3.0.
The first is the scheme. FEP-ef61 recommends ap: and canonicalizes ap+ef61: to it, while also warning that the recommendation may change to ap+ef61:, since these IDs are meant only for portable objects. Fedify accepts both, compares them as equal, and serializes ap+ef61:. Parsing a document normalizes its portable IRIs to ap+ef61:, so re-serializing an object that another implementation signed with ap: IDs breaks its proof. Keep the received JSON of such documents, and compare portable IDs with arePortableUrisEqual() rather than as strings. The discussion is in #826 and #828, and the reasoning is in Canonical ap+ef61: scheme.
The second concerns compound documents. When a signed Note sits inside a signed Create, the verifier has to know which part of the JSON each proof covers, and neither FEP-8b32 nor Verifiable Credential Data Integrity defines that yet; the question is open in w3c/vc-data-integrity#350. Fedify uses an interim map-local profile, worked out in #938. Each JSON map with a proof is its own secured document, each embedded portable object needs its own proof and its own @context, and proof sets and chains are not supported. The profile authenticates the exact JSON values each proof covers; it does not promise that a child's JSON-LD expansion inside its parent matches its expansion on its own. @silverpill, @dmitrizagidulin, and @by_caballero reviewed the proposal in that thread, and @silverpill reproduced its test vectors independently. See Map-local compound proofs.
What is not implemented
Fedify does not implement FEP-ae97, the companion proposal for client-side signing. There are no endpoints for registering actors, submitting client-signed activities to a portable outbox, or gateway discovery, and no media upload and deletion endpoints under /.well-known/apgateway-media. Your own outbox listeners can still relay activities that a client signed, unchanged. Fedify also does not copy or reconcile actors, objects, or collections between gateways, so running an actor on several gateways means keeping their data in sync yourself. There is no workflow for rotating a DID's key or migrating an actor to another DID. Fedify resolves verification methods only for did:key DIDs with Ed25519 keys; other DID methods verify only if your document loader resolves them. Gateways must be plain origins, since the arbitrary gateway paths that FEP-ef61 discusses are not supported. The full list is in Not supported.
Interoperability
The profile was tested against tootik v0.25.4 and Mitra v5.10.0 in #1202. Fedify verifies tootik's portable actors, activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on activities that a client signed. Both accept the portable activities that Fedify sends. Two fixes came out of this testing. Fedify now reads a gateways list that tootik leaves unmapped in its JSON-LD context. It also serializes gateways as bare origins, because Mitra rejects a trailing slash. Fedify has not yet verified an actor or activity that Mitra signed itself, and Mastodon's handling of portable actors has not been tested. The FEP-ef61 interoperability section of FEDERATION.md records what was tested and how.
Tooling
fedify lookup looks up portable objects by their ap: and ap+ef61: IDs, compatible identifiers, and the handles of portable actors, and it verifies their proofs. When no gateway returns an acceptable object, it says why. A new --gateway option on fedify lookup, fedify inbox, and fedify webfinger names the gateways to ask. fedify inbox --follow follows portable actors, and fedify webfinger accepts portable actor IDs. See Looking up portable objects in the CLI manual.
The mock federation and contexts in @fedify/testing support portable IDs, verification against fixture document loaders, gateway requests, and hashlink media, so tests can exercise portable objects without a live gateway. @fedify/lint's actor URI rules accept the portable IDs that the getPortable*Uri() methods build. @fedify/next now passes hashlink media requests to Fedify, a problem reported in #1149 and fixed in #1170; to have Next.js run the middleware for them, add { source: "/.well-known/apgateway/:path*" } to your matcher.
Background tasks
Fedify already runs activity delivery and inbox processing on queue workers, but applications usually have jobs of their own: digest emails, timeline rebuilds, link previews. Until now, those jobs could not use Fedify's queues, serialization, or retries. Fedify 2.4.0 opens the same machinery to application-defined tasks.
A task is defined once with defineTask() and enqueued from any Context with enqueueTask() or enqueueTaskMany(). Every task takes a Standard Schema, such as a Zod or Valibot schema, which types its payload. Fedify validates the payload when it is enqueued and again when a worker picks it up; the second check catches payloads from an earlier deployment that no longer match the schema:
import { createExponentialBackoffPolicy } from "@fedify/fedify";import { z } from "zod";const sendDigest = federation.defineTask("sendDigest", { schema: z.object({ userId: z.string(), since: z.date() }), handler: async (ctx, data) => { // data is typed as { userId: string; since: Date } await sendEmail(data.userId, await buildDigest(data.userId, data.since)); }, retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),});// Anywhere with a Context:await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, { delay: { minutes: 30 }, deduplicationKey: "digest:alice",});
Fedify serializes payloads with devalue, so Date, Map, Set, URL, bigint, Temporal values, circular references, and Activity Vocabulary objects all survive the trip through any message queue backend. A Note in a payload comes back as a Note. Failed handlers are retried with exponential backoff by default, and queues with native retries handle retries themselves. Tasks fall back to the outbox queue unless you give them their own. Use the new task slot of the queue option or a per-task queue for that, and startQueue(ctxData, { queue: "task" }) runs a task-only worker. Setting taskQueueResolution: "strict" turns the fallback into an error.
A deduplicationKey deduplicates enqueues. The feature came from @julian's point in #206 that recurring tasks are hard to manage when you cannot tell whether one is already queued. A queue that declares nativeDeduplication enforces the key itself; otherwise, Fedify keeps a best-effort marker in the key–value store through cas(). Each task runs in a fedify.task span, and the queue metrics from 2.3.0 now include task queues. The Background tasks chapter covers routing, retries, deduplication, and the limitations; cron-style scheduling, result backends, and priorities are out of scope for now.
ChanHaeng Lee (@2chanhaeng) implemented the API: the core in #803, deduplication in #806, and observability in #812, following the plans in #797, #798, and #799. The work was merged in #923, closing #206, which had been open since February 2025.
Media uploads
Fedify 2.2.0 added client-to-server outbox listeners, but a C2S client also has to upload images and video before it can post them. That traffic does not go through POST /outbox. Fedify 2.4.0 supports the ActivityPub Media Upload extension, a SocialCG proposal last updated in 2017 that never reached W3C Recommendation status.
setMediaUploader() registers a multipart/form-data endpoint. Its callback receives the uploaded file and the object shell the client posted along with it. If the callback returns the created object, Fedify responds with 201 Created; if it returns a URL where the object will appear once processing finishes, Fedify responds with 202 Accepted:
The actor dispatcher advertises the endpoint itself by setting endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }), the same way it places its inbox and outbox URIs. Without an authorize() hook, anyone who finds the endpoint can upload to it. For that reason, Fedify logs a warning when an uploader is registered without one, and when the returned object's URI does not match an object dispatcher or the actor does not advertise the endpoint. Four new @fedify/lint rules catch the same mistakes before they run. Fedify reads the upload into memory and sets no size limit of its own, so cap the request size at your reverse proxy. Storing the file and serving the object back are up to the application. See the Media upload chapter; the work is in #754 and #927.
Inbox request reports
Fedify decides several things about every inbox delivery: which signature mechanisms applied, which keys they tried, whether the actor owned the key, and what happened to the activity. Before this release, applications saw only part of that. A Linked Data Signature by a key the actor did not own was counted the same as a bad signature, and the key that verified a request was never handed to the application. The request in #1191 came from an effort to log these decisions for a debugging tool.
The new onRequestFinished() callback on inbox listeners receives an InboxRequestReport for every delivery to an inbox route, including rejected requests and requests that fail before verification:
Each HTTP Signature, Linked Data Signature, or Object Integrity Proof check appears as an attempt with the actual keys it tried, including a stale cached key and its refreshed replacement. The report also carries the final authentication decision, such as actorKeyMismatch or proofPolicy, and the response or exception that ended the request. Fedify awaits the callback whether or not the trace is sampled. Errors it throws are logged without changing the response, and enqueued means the queue accepted the activity, not that a worker later processed it. Mock federations in @fedify/testing accept the same callback. See Observing inbox requests; the implementation is in #1201.
Tombstones for deleted objects
Actor dispatchers have been able to return a Tombstone since 2.2.0, but object dispatchers could only return null. A deleted post was answered with 404 Not Found, as if it had never existed, so applications that wanted 410 Gone, as ActivityPub recommends, needed their own route in front of Fedify.
Object dispatchers may now return a Tombstone, which Fedify serves with 410 Gone and the serialized tombstone after applying the authorization predicate:
federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", async (ctx, values) => { const post = await getPost(values.identifier, values.id); if (post == null) return null; if (post.deletedAt != null) { return new Tombstone({ id: ctx.getObjectUri(Note, values), formerType: Note, deleted: post.deletedAt, }); } return new Note({ id: ctx.getObjectUri(Note, values), content: post.content }); },);
The return type of ObjectDispatcher widens to TObject | Tombstone | null. RequestContext.getObject() still returns null for a tombstone unless you pass { tombstone: "passthrough" } or ask for the Tombstone or Object class itself. Dispatchers registered for Tombstone or Object that already returned tombstones now respond with 410 rather than 200. See Deleted objects; the change was proposed in #1112 and made in #1117.
Limits on what a remote peer can make Fedify do
Much of what Fedify fetches is chosen by the sender of an incoming request. Key IDs in signatures are one example, and the servers behind them can be slow on purpose. Several changes in 2.4.0 bound that work.
As #1130 points out, a request may carry several RFC 9421 signatures, and each one can make Fedify fetch a key from a URL of the sender's choosing. Fedify now verifies at most the first three, in Signature-Input order, and ignores the rest. A signature counts toward the limit even if it fails before its key is fetched, and a key named by several signatures is looked up once. The new FederationOptions.maxHttpSignatures option, added with the limit in #1166, changes it.
The built-in document loaders now give each call ten seconds by default, a limit proposed in #1131 and added in #1169. The time limit covers every redirect, alternate document, double-knocking retry, and the response body. A timed-out call throws a FetchError whose cause is a DOMException named "TimeoutError". Error responses are read in full before the loader throws, up to 1 MiB, so that a server cannot send a 404 header and then stall the body. FederationOptions.documentLoaderTimeout changes the limit or turns it off with null, and custom document loaders are not affected. Since #913, getDocumentLoader() also rejects HTML responses that do not advertise an ActivityPub alternate with a FetchError, rather than failing inside JSON.parse(), which #912 found cluttering error trackers.
Cached public keys and remembered HTTP Message Signatures specs used to live forever in the key–value store. #1017 reports a production Redis instance holding 194,424 public-key entries, about 165 MiB, for actors that had mostly stopped federating. Keys now expire after 30 days and specs after 90, configurable through FederationOptions.publicKeyTtl and FederationOptions.httpMessageSignaturesSpecTtl. Entries written by earlier versions have no expiry until they are next written. The Clearing legacy cache entries section shows how to remove them for Redis, PostgreSQL, and other stores. Heewon Chae (@heeoneie) implemented this in #1027, their first contribution to Fedify.
Finally, verifyProof() now authenticates every option of an Ed25519 JCS proof except proofValue, including expires, domain, challenge, and nonce. It also rejects expired or malformed ones. Previously, a field such as expires could be added to an already signed proof without invalidating it. Jiwon Kwon (@z9mb1) spotted this in review of #968. Callers can now require a domain and challenge through VerifyProofOptions to prevent cross-domain or replayed use.
New packages
@fedify/adonisjs
@fedify/adonisjs integrates Fedify with AdonisJS. Running node ace add @fedify/adonisjs registers a service provider that owns the federation's lifecycle and a server middleware that mounts it. It also scaffolds config/fedify.ts, start/federation.ts, and app/federation/main.ts. Controllers get a ctx.federation request context:
// app/federation/main.tsimport federation from "@fedify/adonisjs/services/builder";import { Person } from "@fedify/vocab";federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));
The middleware sits first in AdonisJS's server stack rather than its router stack. That way Fedify sees paths the router does not know, such as /.well-known/webfinger, and reads request bodies before the body parser consumes them. The package targets Node.js and is published to npm only. See the AdonisJS section of the integration guide.
Samuel Brinkmann (@sabrinkmann) contributed the package in #1006, closing #139, and built it on a prototype that Emelia Smith (@thisismissem) attached to that issue.
GoToSocial interaction controls, FEP-044f quote authorization, and FEP-7aa9 featured collections share a request-and-authorization flow. A server asks to reply to, quote, or feature a post, and the author's server approves according to the post's interactionPolicy. @fedify/interaction-controls implements that flow for like, reply, announce, quote, and feature interactions. Each has a helper, such as likeInteraction or quoteInteraction, that creates and verifies requests and authorizations, evaluates policies, and recognizes interactions that arrived without a request:
import { likeInteraction } from "@fedify/interaction-controls";// When a LikeRequest arrives:const verified = await likeInteraction.verifyRequest(ctx, { request });if (verified.verified) { const decision = await likeInteraction.evaluatePolicy(ctx, { subject: note, requester: request.actorId!, }); if (decision.result === "automatic") { // or "manual" or "denied" const authorization = likeInteraction.createAuthorization({ id: new URL("https://example.com/authorizations/1"), attributedTo: owner, interactingObject: like, interactionTarget: note, }); // ... }}
The package does not install inbox listeners or store anything; the application decides where requests and authorizations live and when a human needs to approve. Verification fails closed. Without a policy, likes, replies, and announces are approved automatically, while quotes and features are denied. @fedify/vocab gains the FEP-7aa9 types this needs: FeaturedCollection, FeaturedItem, FeatureRequest, and FeatureAuthorization, along with actors' featuredCollections and InteractionPolicy.canFeature. Samuel Brinkmann asked for them in #810 once Mastodon 4.6 shipped the FEP. See the Interaction controls chapter; the work is in #914 and #929.
@fedify/netlify lets a Fedify application run on Netlify Functions without an external database or queue. NetlifyMessageQueue submits jobs as Netlify Async Workloads events, and createNetlifyQueueHandler() turns a Function into their consumer, with delayed delivery, native retries, and per-key FIFO ordering. NetlifyBlobsKvStore is a key–value store on Netlify Blobs with expiration, prefix listing, and compare-and-swap:
import { createNetlifyQueueHandler, NetlifyBlobsKvStore, NetlifyMessageQueue,} from "@fedify/netlify";import { AsyncWorkloadsClient } from "@netlify/async-workloads";import { getStore } from "@netlify/blobs";export const kv = new NetlifyBlobsKvStore( getStore({ name: "fedify", consistency: "strong" }),);export const queue = new NetlifyMessageQueue({ client: new AsyncWorkloadsClient(), orderingKv: kv,});// netlify/functions/fedify-queue.tsexport default createNetlifyQueueHandler({ queue, maxRetries: 4, federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),});
The key–value store exists because of cost. #1010 describes a static blog whose ActivityPub traffic kept a Netlify Database from ever idling, which used most of the account's monthly credits. Jiwon Kwon contributed NetlifyBlobsKvStore in #1029, and the queue landed in #934. See Netlify Functions in the deployment guide.
@fedify/pglite provides PgliteKvStore, a KvStore on an embedded PGlite database that you create and pass in. It uses the same schema as PostgresKvStore. It is meant for a single PGlite instance in a single runtime isolate, so it does not include a message queue: PGlite does not share data between processes.
import { PGlite } from "@electric-sql/pglite";import { PgliteKvStore } from "@fedify/pglite";const federation = createFederation({ kv: new PgliteKvStore(new PGlite("./data/fedify")),});
ChanHaeng Lee contributed the package in #1020, together with testKvStore() in @fedify/testing. testKvStore() is a conformance suite for KvStore implementations, the counterpart to testMessageQueue(). See PgliteKvStore.
Portable inbox forwarding, task deduplication, Netlify queue ordering, and the delivery circuit breaker all need an atomic compare-and-swap on the key–value store. PostgresKvStore now implements cas(), added in #934, and so does RedisKvStore, requested in #1163 and added in #1167. The Redis version uses a single-key Lua script, which works on standalone Redis and Redis Cluster. Before this, the circuit breaker fell back to racy updates on Redis.
Both come with conditions. RedisKvStore needs Redis to permit EVAL, and custom codecs must encode equal values identically. PostgresKvStore now creates logged tables by default, because losing sequence state in crash recovery could block queued events for good. It migrates existing unlogged tables when it initializes. That one-time migration rewrites the table and locks it exclusively, so schedule the upgrade accordingly if the table is large or busy; unlogged: true keeps the old behavior.
fedify init and the CLI
fedify init can now scaffold a SvelteKit project, contributed by Jang Hanarae (@menele) in #971. Jang Hanarae also added a test task to generated projects in #990. It starts the app, waits for it to be ready, and checks that it resolves a local actor, which gives new projects a smoke test to run after scaffolding and after later changes. Before generating anything, fedify init now checks that the selected Deno, Bun, or Node.js meets Fedify's minimum version, or a framework's higher one, such as Astro's Node.js 22.12. It fails with a clear error in non-interactive mode, and in interactive mode it disables the affected package managers. Lee Jeongmin (@userjmmm) contributed the check in #981. Astro projects now use Astro 7, and since #936@fedify/astro is tested against Astro 5, 6, and 7 with real builds on Node.js, Deno, and Bun, as #931 asked.
fedify tunnel gains fedify.com.es, a tunneling service run by the Fedify project that needs no account; the CLI pins its SSH host key and refuses a mismatched server before exposing a local port. localhost.run is removed in the same change, #940, because the service no longer exists. fedify lookup now times out each request after ten seconds when -T/--timeout is not given, and the option now also bounds the wait for a DNS lookup. The CLI moves to Optique 1.3.2 in #1197, which handles attached values like --timeout=30 consistently, gives better suggestions for mistyped options, and no longer hides errors for known options behind positional arguments before --.
Lint rules
@fedify/lint gains several rules beyond the media upload ones mentioned above. actor-preferred-username-required, requested in #895 and added in #1022, warns when an actor dispatcher returns an actor without preferredUsername. outbox-listener-delivery-not-awaited, from #1057 and #1067, reports an outbox listener that calls ctx.sendActivity() or ctx.forwardActivity() and drops the promise. On Cloudflare Workers, such an activity may never leave, because pending work is discarded once the response returns. await, return, Promise.all(), Promise.allSettled(), and waitUntil() count as handling the promise, while void, Promise.race(), and Promise.any() are accepted as a deliberate choice not to wait. The rule is a warning in the ESLint recommended configuration and an error in strict. Oxlint users enable it by name, and it is not available in Deno Lint.
In #1050, which resolves #900, the existing outbox-listener-delivery-required rule began following control flow instead of scanning the listener's source as text. It reports a listener whose only delivery calls sit in a dead branch, after an unconditional return or throw, or in a function that is never used, and it stays quiet when it cannot tell. Jae-Hyuk-Jang (@jhyuk) contributed this change and both new rules. archie (@ArchieTansaria) then taught both delivery rules about statically false loops and loop binding patterns in #1088. See the linting guide.
Other changes
@fedify/vocab supports the FEP-22cd draft for translations. A Translation records a translated version's language, translators, source object, and source review time, and the new Object.translations property attaches them to a post without creating separate posts. See Translation metadata. (#1037, #1038)
@fedify/vocab-tools gains an extraContext property option, which adds a JSON-LD context only when its terms are used, and a trustEmbeddedObjects type option. (#1037, #1038)
Vocabulary fetch and parse failures that suppressError: true handles are now logged as warnings rather than errors, so that they no longer show up as application errors. Contributed by Jae Hui Hong (@axz0612). (#933, #1035)
A queue whose enqueueMany() makes separate sends can set MessageQueue.atomicEnqueueMany to false. Fedify then rejects a multi-message batch governed by one deduplicationKey up front, since a partial send followed by a retry could enqueue duplicates. (#930, #934)
CommonJS builds that use Temporal bundle temporal-polyfill instead of requiring @js-temporal/polyfill at runtime, and type declarations rely on the standard esnext.temporal lib. (#823, #925)
The document loaders' Accept header now includes the ActivityStreams profile on the JSON-LD media type, as ActivityPub and FEP-ef61 gateways require. (#834, #1077)
The relay documentation now uses the canonical actor and shared inbox URIs, separates Mastodon-style and LitePub-style subscriptions, and explains what remains the application's responsibility. Contributed by Jiwon Kwon. (#899, #996)
Bug fixes
Fixed outbound delivery circuit breaker transitions on key–value stores that compare encoded values. Existing half-open states can recover after switching to a CAS-capable store. (#1163, #1167)
Fixed fedify lookup --recurse reporting a timeout or other network failure as a possibly private object; it now reports the actual cause. (#1156, #1199)
Fixed the -p/--allow-private-address option of fedify webfinger being ignored. (#1156, #1199)
Fixed outbox-listener-delivery-required and outbox-listener-delivery-not-awaited losing track of an object's functions when a nested helper assigns another property of that object. (#1125, #1140)
Update the other @fedify/* packages you use to 2.4.0 at the same time. To update the CLI, run npm install -g @fedify/cli@2.4.0, or see the installation instructions for other package managers.
Most applications need no code changes, but these changes in behavior may affect you:
Activities from actors whose IDs are FEP-ef61 compatible identifiers are rejected with 401 Unauthorized unless they carry a valid Object Integrity Proof by the actor's DID. Keys at compatible identifiers, and keys at ordinary URLs that claim a portable owner, are no longer trusted by origin.
Property accessors given a Context as their options, such as await create.getObject(ctx), now verify the portable objects they dereference, including references to compatible identifiers from objects with ordinary HTTP(S) IDs.
Your dispatchers may be called for requests to /.well-known/apgateway/, and Context.sendActivity() may reject an activity that embeds others' portable objects. Next.js applications need the matcher shown above to serve hashlink media.
Only the first three RFC 9421 signatures of a request are verified. Set maxHttpSignatures if you need more.
The built-in document loaders time out after ten seconds, and fedify lookup does too.
Cached public keys expire after 30 days and HTTP Message Signatures specs after 90 days.
Object dispatchers registered for Tombstone or Object that return tombstones now respond with 410 Gone instead of 200 OK.
PostgresKvStore migrates existing unlogged tables to logged tables on initialization, which locks the table.
RedisKvStore needs EVAL permission for cas().
Queue workers from older versions deliver queued activities for portable inboxes only through the first gateway. Upgrade workers before the servers that enqueue deliveries.
Custom implementations of the Context and RequestContext interfaces need the new getPortable*Uri() methods and isSignedByAudience().
fedify tunnel no longer supports localhost.run.
Acknowledgments
Thanks to this release's contributors:
Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, and the request for FEP-7aa9 vocabulary
Jang Hanarae (@menele): SvelteKit support and the test task in fedify init
Lee Jeongmin (@userjmmm): runtime version verification in fedify init
Jae-Hyuk-Jang (@jhyuk): the actor-preferred-username-required and outbox-listener-delivery-not-awaited lint rules, and control-flow analysis for outbox-listener-delivery-required
archie (@ArchieTansaria): loop handling in the outbox delivery lint rules
Jae Hui Hong (@axz0612): warning-level logging for suppressed vocabulary failures
Heewon Chae (@heeoneie): expiring public key and HTTP Message Signatures spec caches
ChanHaeng Lee (@2chanhaeng) and Jiwon Kwon (@z9mb1) joined as maintainers after 2.3.0. ChanHaeng built the background task API and @fedify/pglite, Jiwon built NetlifyBlobsKvStore and rewrote the relay documentation, and both reviewed much of the rest of this release.
The FEP-ef61 work owes a lot to people outside the project. @anotherdoesnm opened the request in #288. @silverpill, the author of FEP-ef61 and FEP-ae97, clarified the gateway trust model, reviewed the compound proof profile, and checked its test vectors. @dmitrizagidulin and @by_caballero weighed in on the profile's JSON-LD questions in #938. Testing against tootik and Mitra led to two of the interoperability fixes in this release.
@silverpill Thank you for adding it! I think it does. I implemented it in Fedify because I believe the fediverse needs identities that outlive any single server. I hope having it in a framework makes it a little easier for others to adopt. Thanks again for all your patient answers along the way.
The main addition is support for FEP-ef61 portable objects. A Fedify server can now host actors whose identity is a DID instead of a domain, serve their signed objects through /.well-known/apgateway/, and verify the portable objects of other servers. I tested it against tootik and Mitra, and wrote up where Fedify's profile differs from the FEP and what it leaves out.
The release also adds application-defined background tasks, the ActivityPub Media Upload extension, per-request inbox reports, 410 Gone for deleted objects, and four new packages (AdonisJS, Netlify, PGlite, and interaction controls).
Some defaults changed, so please read the upgrading section before you update, especially if you federate with software that uses FEP-ef61 compatible identifiers.
Fedify is a TypeScript framework for building ActivityPub servers. It implements federation details such as HTTP Signatures, JSON-LD processing, WebFinger, inbox and outbox routing, and activity delivery.
Fedify 2.4.0 is a minor release with an unusually long changelog. The main addition is support for FEP-ef61 portable objects: a Fedify server can now host actors whose identity is a DID rather than a domain, serve and verify their signed objects, and consume the portable objects of other servers. The release also adds an API for application-defined background tasks, the ActivityPub Media Upload extension, a hook that reports how every inbox request was authenticated and handled, and 410 Gone tombstones for deleted objects. Several defaults change to limit what a hostile peer can make Fedify do. Four new packages ship with this version: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, and @fedify/pglite.
Most applications can upgrade without code changes, but a few of the new defaults are behavior changes. They are listed in the Upgrading section at the end.
FEP-ef61 portable objects
An ActivityPub object's ID is an HTTP(S) URL on its server's domain. If that server shuts down, its IDs stop resolving, and an actor that moves elsewhere gets a new ID that everyone has to follow again. FEP-ef61, written by @silverpill of Mitra, proposes IDs that do not name a server at all. A portable ID is an ap: URI whose authority is a DID:
The same object can be stored on several servers, called gateways, each of which serves it under /.well-known/apgateway/. Because no server owns the ID, a portable actor, activity, or object is authenticated only by its FEP-8b32 Object Integrity Proof, made with a key of the DID in its ID. A portable actor lists its gateways in a gateways property, and other servers deliver to its inbox through them.
Fedify 2.4.0 implements a profile of FEP-ef61 for gateways and consumers of portable objects. It makes two deliberate choices where the FEP or the specifications it rests on leave questions open, and it leaves out a few parts of the proposal; both are described below. The new Portable objects chapter of the manual walks through running portable actors from start to finish and then describes the profile as a whole, so that other implementers can tell what to expect from a Fedify server. The request came from @anotherdoesnm in #288, which tracked the work from a roadmap to the final pieces.
Portable objects are a foundation for nomadic identity, not nomadic identity itself. Fedify serves and verifies portable objects and forwards activities between gateways, but it does not copy actors or posts between servers, rotate keys, or move an actor to another DID. Full nomadic identity is still open in #413.
Hosting a portable actor
A portable actor starts with an Ed25519 key pair. Its DID is made from the public key with exportDidKey(), and whoever holds the private key can act as the actor, so store it with the actor and keep it safe:
import { generateCryptoKeyPair } from "@fedify/fedify";import { exportDidKey } from "@fedify/vocab-runtime";const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");const did = await exportDidKey(publicKey); // did:key:z6Mk...// The verification method that the actor's proofs refer to:const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);
The actor dispatcher returns a portable actor as it would an ordinary one. Context.getPortableActorUri() and its siblings for inboxes, outboxes, collections, and objects build portable IDs from the dispatcher's paths and the DID, and signObject() signs the actor with the DID's key:
federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = await findUser(identifier); if (user == null) return null; // A request through the gateway endpoint names the DID in its path, // e.g., GET /.well-known/apgateway/did:key:z6Mk.../users/alice: if ( ctx.portableRequest != null && ctx.portableRequest.authority !== user.did ) { return null; } // This server's gateway keys for the actor (see below): const keys = await ctx.getActorKeyPairs(identifier); return await signObject( new Person({ id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice preferredUsername: user.username, inbox: ctx.getPortableInboxUri(identifier, user.did), outbox: ctx.getPortableOutboxUri(identifier, user.did), followers: ctx.getPortableFollowersUri(identifier, user.did), gateways: [new URL("https://example.com")], publicKeys: keys.map((key) => key.cryptographicKey), assertionMethods: keys.map((key) => key.multikey), }), user.didKeyPair.privateKey, new URL(`${user.did}#${user.did.slice("did:key:".length)}`), ); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = await findUser(identifier); // This server's own key pair for the actor, not the DID's: return user == null ? [] : [user.gatewayKeyPair]; }) .mapPortableActorId(async (ctx, identifier) => { const user = await findUser(identifier); return user == null ? null : ctx.getPortableActorUri(identifier, user.did); });
With this dispatcher, Fedify serves the actor at its compatible identifier, https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice, which is how other servers retrieve it. It serves it through WebFinger as @alice@example.com, since the handle's domain comes from the first gateway in gateways, and at its ordinary URL as before. On the gateway endpoint, Fedify serves a document only if its ID is the requested portable ID and it carries a proof made with a key of that DID.
The key pairs dispatcher and the new ActorCallbackSetters.mapPortableActorId() give the actor gateway keys. These are the server's own keys for the actor, and they sign only the HTTP requests the server makes on the actor's behalf, such as deliveries. They never sign the actor's activities or objects; only the DID's key does that. The actor document lists them under assertionMethods, as FEP-521a describes, so that other servers can verify the HTTP Signatures. See Dispatching the actor and Gateway keys of portable actors for the details.
Object and collection dispatchers serve portable objects and collections through the gateway endpoint in the same way, routed by the path after the DID. Two rules protect the actor's data. A non-public portable object is served only if the dispatcher has an authorization predicate, because FEP-ef61 forbids a gateway to show it to anyone outside its audience; the new RequestContext.isSignedByAudience() helps to write one. Without a predicate, a request for such an object gets 404 Not Found, as though the gateway did not store it. A deleted object is served as a signed Tombstone with 410 Gone. Resources that portable objects refer to by SHA-256 hashlinks, such as GET /.well-known/apgateway/hl:zQm..., are served by the new Federatable.setHashlinkMediaDispatcher().
Sending and receiving
A portable actor's activities have to be signed by its DID, which Fedify's key pairs dispatcher does not know about. Sign them with signObject() before sending, and pass normalizeExistingProofs: true so that the activity goes out in the form its proof covers. An activity that embeds a portable object, such as the Note of a Create, is signed inside out:
This example depends on a fix to signObject() that is useful outside FEP-ef61 as well. Previously, a signed object assigned to a typed parent was rebuilt under the parent's JSON-LD context when the parent was serialized. The child kept its proofValue but lost the document its proof covered, so it no longer verified on its own. signObject() now captures the exact document it signed, and nested serialization embeds that document verbatim. The captured document is a snapshot: clone() does not carry it, so sign a clone again if it has to be embedded. The design is in #1044 and the fix in #1051.
Context.sendActivity() refuses a portable activity that is unsigned, signed by the wrong key, or carries a Linked Data Signature, and throws a TypeError before anything is queued. It also refuses a few shapes of compound document that Fedify's own inboxes would reject, such as a received signed Follow embedded in an Accept; refer to such an object by its ID instead. Deliveries to portable inboxes go through the recipient's gateways, or else the @gateway hints on the inbox, trying at most five of them until one accepts. Before this release, they failed with UrlError: Unsupported protocol: ap+ef61:. The rules are in Delivering to portable actors and Producing a compound document.
On the receiving side, a portable inbox is reached as POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox and dispatched to the same inbox listeners as the ordinary inbox. Context.parseUri() gains a portable option, and the parsed result has the DID in a new authority property to check against the one you store. Fedify accepts an activity of a portable actor, or one with a portable ID, only if it has a valid proof by that DID; neither an HTTP Signature nor a Linked Data Signature authenticates it. Each portable object embedded in the activity is verified against its own proof before dispatch. An accepted activity that carries its own proof is forwarded to the actor's other gateways at most once, as FEP-ef61 recommends. Forwarding needs a key–value store that supports cas(), and the FederationOptions.portableInboxForwarding option tunes it. See Portable inboxes and Forwarding to other gateways.
Consuming portable objects
Applications that never host a portable actor still meet portable objects from other servers. Context.lookupObject() resolves portable IDs, compatible identifiers, and the fediverse handles of portable actors, and returns portable objects only if they verify:
const actor = await ctx.lookupObject("@alice@example.com");// A portable ID without @gateway hints needs gateways to ask:const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", { gateways: ["https://example.com"],});
Property accessors fetch portable references through gateways as well, and they verify what they fetch with the new verifyPortableObject option. Context has a matching verifyPortableObject property, so passing a context as the options, as in await create.getObject(ctx), is enough. Activities received in inboxes use this verifier by default, and objects fetched through their accessors inherit it, so a chained call such as (await create.getObject(ctx))?.getAttribution() verifies each portable object along the way. A lookup asks at most five gateways, falling back to the next when one fails or serves something that does not verify. Unsigned collections are accepted only from a gateway that the owner lists, and only for an actor's inbox, outbox, followers, following, and liked, as FEP-ef61 allows. fetchPortableMedia() retrieves the media of a portable object and returns it only if it matches the object's digestMultibase. See Dereferencing portable references and Default verifiers.
Compatible identifiers are no longer trusted by origin
Software that does not understand ap: URIs can refer to a portable object by a compatible identifier, an HTTP(S) URL under a gateway's /.well-known/apgateway/ path such as https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor. Some implementations, tootik among them, use compatible identifiers as the IDs of their portable actors.
Earlier versions of Fedify treated these URLs as ordinary web URLs and trusted them by the server that served them. Since anyone can serve a compatible identifier for any DID, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor could act as Alice without a proof by Alice's DID. Fedify 2.4.0 treats a compatible identifier as the portable object it stands for, as FEP-ef61 requires. Inboxes reject an activity from an actor whose ID is a compatible identifier with 401 Unauthorized unless it has a valid Object Integrity Proof by the actor's DID, and there is no option to turn this off. Actors that publish compatible identifiers without signing them are no longer accepted, and neither are those whose DIDs use key types that Fedify cannot verify yet. The change was tracked in #1093 and made in #1105.
Your own portable actors may still use compatible identifiers as their IDs, for software that rejects ap: IDs. Build them with toCompatibleEf61Id() before signing, and Fedify will treat them as portable wherever it serves or sends them. It also warns if one is malformed or does not point at the actor's first gateway. See Compatible identifiers as actor IDs.
Where Fedify's profile differs from the FEP
Two choices in Fedify's profile differ from the current FEP text or rest on semantics that are not settled yet. Fedify keeps both for the 2.x series, and either may change in Fedify 3.0.
The first is the scheme. FEP-ef61 recommends ap: and canonicalizes ap+ef61: to it, while also warning that the recommendation may change to ap+ef61:, since these IDs are meant only for portable objects. Fedify accepts both, compares them as equal, and serializes ap+ef61:. Parsing a document normalizes its portable IRIs to ap+ef61:, so re-serializing an object that another implementation signed with ap: IDs breaks its proof. Keep the received JSON of such documents, and compare portable IDs with arePortableUrisEqual() rather than as strings. The discussion is in #826 and #828, and the reasoning is in Canonical ap+ef61: scheme.
The second concerns compound documents. When a signed Note sits inside a signed Create, the verifier has to know which part of the JSON each proof covers, and neither FEP-8b32 nor Verifiable Credential Data Integrity defines that yet; the question is open in w3c/vc-data-integrity#350. Fedify uses an interim map-local profile, worked out in #938. Each JSON map with a proof is its own secured document, each embedded portable object needs its own proof and its own @context, and proof sets and chains are not supported. The profile authenticates the exact JSON values each proof covers; it does not promise that a child's JSON-LD expansion inside its parent matches its expansion on its own. @silverpill, @dmitrizagidulin, and @by_caballero reviewed the proposal in that thread, and @silverpill reproduced its test vectors independently. See Map-local compound proofs.
What is not implemented
Fedify does not implement FEP-ae97, the companion proposal for client-side signing. There are no endpoints for registering actors, submitting client-signed activities to a portable outbox, or gateway discovery, and no media upload and deletion endpoints under /.well-known/apgateway-media. Your own outbox listeners can still relay activities that a client signed, unchanged. Fedify also does not copy or reconcile actors, objects, or collections between gateways, so running an actor on several gateways means keeping their data in sync yourself. There is no workflow for rotating a DID's key or migrating an actor to another DID. Fedify resolves verification methods only for did:key DIDs with Ed25519 keys; other DID methods verify only if your document loader resolves them. Gateways must be plain origins, since the arbitrary gateway paths that FEP-ef61 discusses are not supported. The full list is in Not supported.
Interoperability
The profile was tested against tootik v0.25.4 and Mitra v5.10.0 in #1202. Fedify verifies tootik's portable actors, activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on activities that a client signed. Both accept the portable activities that Fedify sends. Two fixes came out of this testing. Fedify now reads a gateways list that tootik leaves unmapped in its JSON-LD context. It also serializes gateways as bare origins, because Mitra rejects a trailing slash. Fedify has not yet verified an actor or activity that Mitra signed itself, and Mastodon's handling of portable actors has not been tested. The FEP-ef61 interoperability section of FEDERATION.md records what was tested and how.
Tooling
fedify lookup looks up portable objects by their ap: and ap+ef61: IDs, compatible identifiers, and the handles of portable actors, and it verifies their proofs. When no gateway returns an acceptable object, it says why. A new --gateway option on fedify lookup, fedify inbox, and fedify webfinger names the gateways to ask. fedify inbox --follow follows portable actors, and fedify webfinger accepts portable actor IDs. See Looking up portable objects in the CLI manual.
The mock federation and contexts in @fedify/testing support portable IDs, verification against fixture document loaders, gateway requests, and hashlink media, so tests can exercise portable objects without a live gateway. @fedify/lint's actor URI rules accept the portable IDs that the getPortable*Uri() methods build. @fedify/next now passes hashlink media requests to Fedify, a problem reported in #1149 and fixed in #1170; to have Next.js run the middleware for them, add { source: "/.well-known/apgateway/:path*" } to your matcher.
Background tasks
Fedify already runs activity delivery and inbox processing on queue workers, but applications usually have jobs of their own: digest emails, timeline rebuilds, link previews. Until now, those jobs could not use Fedify's queues, serialization, or retries. Fedify 2.4.0 opens the same machinery to application-defined tasks.
A task is defined once with defineTask() and enqueued from any Context with enqueueTask() or enqueueTaskMany(). Every task takes a Standard Schema, such as a Zod or Valibot schema, which types its payload. Fedify validates the payload when it is enqueued and again when a worker picks it up; the second check catches payloads from an earlier deployment that no longer match the schema:
import { createExponentialBackoffPolicy } from "@fedify/fedify";import { z } from "zod";const sendDigest = federation.defineTask("sendDigest", { schema: z.object({ userId: z.string(), since: z.date() }), handler: async (ctx, data) => { // data is typed as { userId: string; since: Date } await sendEmail(data.userId, await buildDigest(data.userId, data.since)); }, retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),});// Anywhere with a Context:await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, { delay: { minutes: 30 }, deduplicationKey: "digest:alice",});
Fedify serializes payloads with devalue, so Date, Map, Set, URL, bigint, Temporal values, circular references, and Activity Vocabulary objects all survive the trip through any message queue backend. A Note in a payload comes back as a Note. Failed handlers are retried with exponential backoff by default, and queues with native retries handle retries themselves. Tasks fall back to the outbox queue unless you give them their own. Use the new task slot of the queue option or a per-task queue for that, and startQueue(ctxData, { queue: "task" }) runs a task-only worker. Setting taskQueueResolution: "strict" turns the fallback into an error.
A deduplicationKey deduplicates enqueues. The feature came from @julian's point in #206 that recurring tasks are hard to manage when you cannot tell whether one is already queued. A queue that declares nativeDeduplication enforces the key itself; otherwise, Fedify keeps a best-effort marker in the key–value store through cas(). Each task runs in a fedify.task span, and the queue metrics from 2.3.0 now include task queues. The Background tasks chapter covers routing, retries, deduplication, and the limitations; cron-style scheduling, result backends, and priorities are out of scope for now.
ChanHaeng Lee (@2chanhaeng) implemented the API: the core in #803, deduplication in #806, and observability in #812, following the plans in #797, #798, and #799. The work was merged in #923, closing #206, which had been open since February 2025.
Media uploads
Fedify 2.2.0 added client-to-server outbox listeners, but a C2S client also has to upload images and video before it can post them. That traffic does not go through POST /outbox. Fedify 2.4.0 supports the ActivityPub Media Upload extension, a SocialCG proposal last updated in 2017 that never reached W3C Recommendation status.
setMediaUploader() registers a multipart/form-data endpoint. Its callback receives the uploaded file and the object shell the client posted along with it. If the callback returns the created object, Fedify responds with 201 Created; if it returns a URL where the object will appear once processing finishes, Fedify responds with 202 Accepted:
The actor dispatcher advertises the endpoint itself by setting endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }), the same way it places its inbox and outbox URIs. Without an authorize() hook, anyone who finds the endpoint can upload to it. For that reason, Fedify logs a warning when an uploader is registered without one, and when the returned object's URI does not match an object dispatcher or the actor does not advertise the endpoint. Four new @fedify/lint rules catch the same mistakes before they run. Fedify reads the upload into memory and sets no size limit of its own, so cap the request size at your reverse proxy. Storing the file and serving the object back are up to the application. See the Media upload chapter; the work is in #754 and #927.
Inbox request reports
Fedify decides several things about every inbox delivery: which signature mechanisms applied, which keys they tried, whether the actor owned the key, and what happened to the activity. Before this release, applications saw only part of that. A Linked Data Signature by a key the actor did not own was counted the same as a bad signature, and the key that verified a request was never handed to the application. The request in #1191 came from an effort to log these decisions for a debugging tool.
The new onRequestFinished() callback on inbox listeners receives an InboxRequestReport for every delivery to an inbox route, including rejected requests and requests that fail before verification:
Each HTTP Signature, Linked Data Signature, or Object Integrity Proof check appears as an attempt with the actual keys it tried, including a stale cached key and its refreshed replacement. The report also carries the final authentication decision, such as actorKeyMismatch or proofPolicy, and the response or exception that ended the request. Fedify awaits the callback whether or not the trace is sampled. Errors it throws are logged without changing the response, and enqueued means the queue accepted the activity, not that a worker later processed it. Mock federations in @fedify/testing accept the same callback. See Observing inbox requests; the implementation is in #1201.
Tombstones for deleted objects
Actor dispatchers have been able to return a Tombstone since 2.2.0, but object dispatchers could only return null. A deleted post was answered with 404 Not Found, as if it had never existed, so applications that wanted 410 Gone, as ActivityPub recommends, needed their own route in front of Fedify.
Object dispatchers may now return a Tombstone, which Fedify serves with 410 Gone and the serialized tombstone after applying the authorization predicate:
federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", async (ctx, values) => { const post = await getPost(values.identifier, values.id); if (post == null) return null; if (post.deletedAt != null) { return new Tombstone({ id: ctx.getObjectUri(Note, values), formerType: Note, deleted: post.deletedAt, }); } return new Note({ id: ctx.getObjectUri(Note, values), content: post.content }); },);
The return type of ObjectDispatcher widens to TObject | Tombstone | null. RequestContext.getObject() still returns null for a tombstone unless you pass { tombstone: "passthrough" } or ask for the Tombstone or Object class itself. Dispatchers registered for Tombstone or Object that already returned tombstones now respond with 410 rather than 200. See Deleted objects; the change was proposed in #1112 and made in #1117.
Limits on what a remote peer can make Fedify do
Much of what Fedify fetches is chosen by the sender of an incoming request. Key IDs in signatures are one example, and the servers behind them can be slow on purpose. Several changes in 2.4.0 bound that work.
As #1130 points out, a request may carry several RFC 9421 signatures, and each one can make Fedify fetch a key from a URL of the sender's choosing. Fedify now verifies at most the first three, in Signature-Input order, and ignores the rest. A signature counts toward the limit even if it fails before its key is fetched, and a key named by several signatures is looked up once. The new FederationOptions.maxHttpSignatures option, added with the limit in #1166, changes it.
The built-in document loaders now give each call ten seconds by default, a limit proposed in #1131 and added in #1169. The time limit covers every redirect, alternate document, double-knocking retry, and the response body. A timed-out call throws a FetchError whose cause is a DOMException named "TimeoutError". Error responses are read in full before the loader throws, up to 1 MiB, so that a server cannot send a 404 header and then stall the body. FederationOptions.documentLoaderTimeout changes the limit or turns it off with null, and custom document loaders are not affected. Since #913, getDocumentLoader() also rejects HTML responses that do not advertise an ActivityPub alternate with a FetchError, rather than failing inside JSON.parse(), which #912 found cluttering error trackers.
Cached public keys and remembered HTTP Message Signatures specs used to live forever in the key–value store. #1017 reports a production Redis instance holding 194,424 public-key entries, about 165 MiB, for actors that had mostly stopped federating. Keys now expire after 30 days and specs after 90, configurable through FederationOptions.publicKeyTtl and FederationOptions.httpMessageSignaturesSpecTtl. Entries written by earlier versions have no expiry until they are next written. The Clearing legacy cache entries section shows how to remove them for Redis, PostgreSQL, and other stores. Heewon Chae (@heeoneie) implemented this in #1027, their first contribution to Fedify.
Finally, verifyProof() now authenticates every option of an Ed25519 JCS proof except proofValue, including expires, domain, challenge, and nonce. It also rejects expired or malformed ones. Previously, a field such as expires could be added to an already signed proof without invalidating it. Jiwon Kwon (@z9mb1) spotted this in review of #968. Callers can now require a domain and challenge through VerifyProofOptions to prevent cross-domain or replayed use.
New packages
@fedify/adonisjs
@fedify/adonisjs integrates Fedify with AdonisJS. Running node ace add @fedify/adonisjs registers a service provider that owns the federation's lifecycle and a server middleware that mounts it. It also scaffolds config/fedify.ts, start/federation.ts, and app/federation/main.ts. Controllers get a ctx.federation request context:
// app/federation/main.tsimport federation from "@fedify/adonisjs/services/builder";import { Person } from "@fedify/vocab";federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));
The middleware sits first in AdonisJS's server stack rather than its router stack. That way Fedify sees paths the router does not know, such as /.well-known/webfinger, and reads request bodies before the body parser consumes them. The package targets Node.js and is published to npm only. See the AdonisJS section of the integration guide.
Samuel Brinkmann (@sabrinkmann) contributed the package in #1006, closing #139, and built it on a prototype that Emelia Smith (@thisismissem) attached to that issue.
GoToSocial interaction controls, FEP-044f quote authorization, and FEP-7aa9 featured collections share a request-and-authorization flow. A server asks to reply to, quote, or feature a post, and the author's server approves according to the post's interactionPolicy. @fedify/interaction-controls implements that flow for like, reply, announce, quote, and feature interactions. Each has a helper, such as likeInteraction or quoteInteraction, that creates and verifies requests and authorizations, evaluates policies, and recognizes interactions that arrived without a request:
import { likeInteraction } from "@fedify/interaction-controls";// When a LikeRequest arrives:const verified = await likeInteraction.verifyRequest(ctx, { request });if (verified.verified) { const decision = await likeInteraction.evaluatePolicy(ctx, { subject: note, requester: request.actorId!, }); if (decision.result === "automatic") { // or "manual" or "denied" const authorization = likeInteraction.createAuthorization({ id: new URL("https://example.com/authorizations/1"), attributedTo: owner, interactingObject: like, interactionTarget: note, }); // ... }}
The package does not install inbox listeners or store anything; the application decides where requests and authorizations live and when a human needs to approve. Verification fails closed. Without a policy, likes, replies, and announces are approved automatically, while quotes and features are denied. @fedify/vocab gains the FEP-7aa9 types this needs: FeaturedCollection, FeaturedItem, FeatureRequest, and FeatureAuthorization, along with actors' featuredCollections and InteractionPolicy.canFeature. Samuel Brinkmann asked for them in #810 once Mastodon 4.6 shipped the FEP. See the Interaction controls chapter; the work is in #914 and #929.
@fedify/netlify lets a Fedify application run on Netlify Functions without an external database or queue. NetlifyMessageQueue submits jobs as Netlify Async Workloads events, and createNetlifyQueueHandler() turns a Function into their consumer, with delayed delivery, native retries, and per-key FIFO ordering. NetlifyBlobsKvStore is a key–value store on Netlify Blobs with expiration, prefix listing, and compare-and-swap:
import { createNetlifyQueueHandler, NetlifyBlobsKvStore, NetlifyMessageQueue,} from "@fedify/netlify";import { AsyncWorkloadsClient } from "@netlify/async-workloads";import { getStore } from "@netlify/blobs";export const kv = new NetlifyBlobsKvStore( getStore({ name: "fedify", consistency: "strong" }),);export const queue = new NetlifyMessageQueue({ client: new AsyncWorkloadsClient(), orderingKv: kv,});// netlify/functions/fedify-queue.tsexport default createNetlifyQueueHandler({ queue, maxRetries: 4, federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),});
The key–value store exists because of cost. #1010 describes a static blog whose ActivityPub traffic kept a Netlify Database from ever idling, which used most of the account's monthly credits. Jiwon Kwon contributed NetlifyBlobsKvStore in #1029, and the queue landed in #934. See Netlify Functions in the deployment guide.
@fedify/pglite provides PgliteKvStore, a KvStore on an embedded PGlite database that you create and pass in. It uses the same schema as PostgresKvStore. It is meant for a single PGlite instance in a single runtime isolate, so it does not include a message queue: PGlite does not share data between processes.
import { PGlite } from "@electric-sql/pglite";import { PgliteKvStore } from "@fedify/pglite";const federation = createFederation({ kv: new PgliteKvStore(new PGlite("./data/fedify")),});
ChanHaeng Lee contributed the package in #1020, together with testKvStore() in @fedify/testing. testKvStore() is a conformance suite for KvStore implementations, the counterpart to testMessageQueue(). See PgliteKvStore.
Portable inbox forwarding, task deduplication, Netlify queue ordering, and the delivery circuit breaker all need an atomic compare-and-swap on the key–value store. PostgresKvStore now implements cas(), added in #934, and so does RedisKvStore, requested in #1163 and added in #1167. The Redis version uses a single-key Lua script, which works on standalone Redis and Redis Cluster. Before this, the circuit breaker fell back to racy updates on Redis.
Both come with conditions. RedisKvStore needs Redis to permit EVAL, and custom codecs must encode equal values identically. PostgresKvStore now creates logged tables by default, because losing sequence state in crash recovery could block queued events for good. It migrates existing unlogged tables when it initializes. That one-time migration rewrites the table and locks it exclusively, so schedule the upgrade accordingly if the table is large or busy; unlogged: true keeps the old behavior.
fedify init and the CLI
fedify init can now scaffold a SvelteKit project, contributed by Jang Hanarae (@menele) in #971. Jang Hanarae also added a test task to generated projects in #990. It starts the app, waits for it to be ready, and checks that it resolves a local actor, which gives new projects a smoke test to run after scaffolding and after later changes. Before generating anything, fedify init now checks that the selected Deno, Bun, or Node.js meets Fedify's minimum version, or a framework's higher one, such as Astro's Node.js 22.12. It fails with a clear error in non-interactive mode, and in interactive mode it disables the affected package managers. Lee Jeongmin (@userjmmm) contributed the check in #981. Astro projects now use Astro 7, and since #936@fedify/astro is tested against Astro 5, 6, and 7 with real builds on Node.js, Deno, and Bun, as #931 asked.
fedify tunnel gains fedify.com.es, a tunneling service run by the Fedify project that needs no account; the CLI pins its SSH host key and refuses a mismatched server before exposing a local port. localhost.run is removed in the same change, #940, because the service no longer exists. fedify lookup now times out each request after ten seconds when -T/--timeout is not given, and the option now also bounds the wait for a DNS lookup. The CLI moves to Optique 1.3.2 in #1197, which handles attached values like --timeout=30 consistently, gives better suggestions for mistyped options, and no longer hides errors for known options behind positional arguments before --.
Lint rules
@fedify/lint gains several rules beyond the media upload ones mentioned above. actor-preferred-username-required, requested in #895 and added in #1022, warns when an actor dispatcher returns an actor without preferredUsername. outbox-listener-delivery-not-awaited, from #1057 and #1067, reports an outbox listener that calls ctx.sendActivity() or ctx.forwardActivity() and drops the promise. On Cloudflare Workers, such an activity may never leave, because pending work is discarded once the response returns. await, return, Promise.all(), Promise.allSettled(), and waitUntil() count as handling the promise, while void, Promise.race(), and Promise.any() are accepted as a deliberate choice not to wait. The rule is a warning in the ESLint recommended configuration and an error in strict. Oxlint users enable it by name, and it is not available in Deno Lint.
In #1050, which resolves #900, the existing outbox-listener-delivery-required rule began following control flow instead of scanning the listener's source as text. It reports a listener whose only delivery calls sit in a dead branch, after an unconditional return or throw, or in a function that is never used, and it stays quiet when it cannot tell. Jae-Hyuk-Jang (@jhyuk) contributed this change and both new rules. archie (@ArchieTansaria) then taught both delivery rules about statically false loops and loop binding patterns in #1088. See the linting guide.
Other changes
@fedify/vocab supports the FEP-22cd draft for translations. A Translation records a translated version's language, translators, source object, and source review time, and the new Object.translations property attaches them to a post without creating separate posts. See Translation metadata. (#1037, #1038)
@fedify/vocab-tools gains an extraContext property option, which adds a JSON-LD context only when its terms are used, and a trustEmbeddedObjects type option. (#1037, #1038)
Vocabulary fetch and parse failures that suppressError: true handles are now logged as warnings rather than errors, so that they no longer show up as application errors. Contributed by Jae Hui Hong (@axz0612). (#933, #1035)
A queue whose enqueueMany() makes separate sends can set MessageQueue.atomicEnqueueMany to false. Fedify then rejects a multi-message batch governed by one deduplicationKey up front, since a partial send followed by a retry could enqueue duplicates. (#930, #934)
CommonJS builds that use Temporal bundle temporal-polyfill instead of requiring @js-temporal/polyfill at runtime, and type declarations rely on the standard esnext.temporal lib. (#823, #925)
The document loaders' Accept header now includes the ActivityStreams profile on the JSON-LD media type, as ActivityPub and FEP-ef61 gateways require. (#834, #1077)
The relay documentation now uses the canonical actor and shared inbox URIs, separates Mastodon-style and LitePub-style subscriptions, and explains what remains the application's responsibility. Contributed by Jiwon Kwon. (#899, #996)
Bug fixes
Fixed outbound delivery circuit breaker transitions on key–value stores that compare encoded values. Existing half-open states can recover after switching to a CAS-capable store. (#1163, #1167)
Fixed fedify lookup --recurse reporting a timeout or other network failure as a possibly private object; it now reports the actual cause. (#1156, #1199)
Fixed the -p/--allow-private-address option of fedify webfinger being ignored. (#1156, #1199)
Fixed outbox-listener-delivery-required and outbox-listener-delivery-not-awaited losing track of an object's functions when a nested helper assigns another property of that object. (#1125, #1140)
Update the other @fedify/* packages you use to 2.4.0 at the same time. To update the CLI, run npm install -g @fedify/cli@2.4.0, or see the installation instructions for other package managers.
Most applications need no code changes, but these changes in behavior may affect you:
Activities from actors whose IDs are FEP-ef61 compatible identifiers are rejected with 401 Unauthorized unless they carry a valid Object Integrity Proof by the actor's DID. Keys at compatible identifiers, and keys at ordinary URLs that claim a portable owner, are no longer trusted by origin.
Property accessors given a Context as their options, such as await create.getObject(ctx), now verify the portable objects they dereference, including references to compatible identifiers from objects with ordinary HTTP(S) IDs.
Your dispatchers may be called for requests to /.well-known/apgateway/, and Context.sendActivity() may reject an activity that embeds others' portable objects. Next.js applications need the matcher shown above to serve hashlink media.
Only the first three RFC 9421 signatures of a request are verified. Set maxHttpSignatures if you need more.
The built-in document loaders time out after ten seconds, and fedify lookup does too.
Cached public keys expire after 30 days and HTTP Message Signatures specs after 90 days.
Object dispatchers registered for Tombstone or Object that return tombstones now respond with 410 Gone instead of 200 OK.
PostgresKvStore migrates existing unlogged tables to logged tables on initialization, which locks the table.
RedisKvStore needs EVAL permission for cas().
Queue workers from older versions deliver queued activities for portable inboxes only through the first gateway. Upgrade workers before the servers that enqueue deliveries.
Custom implementations of the Context and RequestContext interfaces need the new getPortable*Uri() methods and isSignedByAudience().
fedify tunnel no longer supports localhost.run.
Acknowledgments
Thanks to this release's contributors:
Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, and the request for FEP-7aa9 vocabulary
Jang Hanarae (@menele): SvelteKit support and the test task in fedify init
Lee Jeongmin (@userjmmm): runtime version verification in fedify init
Jae-Hyuk-Jang (@jhyuk): the actor-preferred-username-required and outbox-listener-delivery-not-awaited lint rules, and control-flow analysis for outbox-listener-delivery-required
archie (@ArchieTansaria): loop handling in the outbox delivery lint rules
Jae Hui Hong (@axz0612): warning-level logging for suppressed vocabulary failures
Heewon Chae (@heeoneie): expiring public key and HTTP Message Signatures spec caches
ChanHaeng Lee (@2chanhaeng) and Jiwon Kwon (@z9mb1) joined as maintainers after 2.3.0. ChanHaeng built the background task API and @fedify/pglite, Jiwon built NetlifyBlobsKvStore and rewrote the relay documentation, and both reviewed much of the rest of this release.
The FEP-ef61 work owes a lot to people outside the project. @anotherdoesnm opened the request in #288. @silverpill, the author of FEP-ef61 and FEP-ae97, clarified the gateway trust model, reviewed the compound proof profile, and checked its test vectors. @dmitrizagidulin and @by_caballero weighed in on the profile's JSON-LD questions in #938. Testing against tootik and Mitra led to two of the interoperability fixes in this release.
Fedify is a TypeScript framework for building ActivityPub servers. It implements federation details such as HTTP Signatures, JSON-LD processing, WebFinger, inbox and outbox routing, and activity delivery.
Fedify 2.4.0 is a minor release with an unusually long changelog. The main addition is support for FEP-ef61 portable objects: a Fedify server can now host actors whose identity is a DID rather than a domain, serve and verify their signed objects, and consume the portable objects of other servers. The release also adds an API for application-defined background tasks, the ActivityPub Media Upload extension, a hook that reports how every inbox request was authenticated and handled, and 410 Gone tombstones for deleted objects. Several defaults change to limit what a hostile peer can make Fedify do. Four new packages ship with this version: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, and @fedify/pglite.
Most applications can upgrade without code changes, but a few of the new defaults are behavior changes. They are listed in the Upgrading section at the end.
FEP-ef61 portable objects
An ActivityPub object's ID is an HTTP(S) URL on its server's domain. If that server shuts down, its IDs stop resolving, and an actor that moves elsewhere gets a new ID that everyone has to follow again. FEP-ef61, written by @silverpill of Mitra, proposes IDs that do not name a server at all. A portable ID is an ap: URI whose authority is a DID:
The same object can be stored on several servers, called gateways, each of which serves it under /.well-known/apgateway/. Because no server owns the ID, a portable actor, activity, or object is authenticated only by its FEP-8b32 Object Integrity Proof, made with a key of the DID in its ID. A portable actor lists its gateways in a gateways property, and other servers deliver to its inbox through them.
Fedify 2.4.0 implements a profile of FEP-ef61 for gateways and consumers of portable objects. It makes two deliberate choices where the FEP or the specifications it rests on leave questions open, and it leaves out a few parts of the proposal; both are described below. The new Portable objects chapter of the manual walks through running portable actors from start to finish and then describes the profile as a whole, so that other implementers can tell what to expect from a Fedify server. The request came from @anotherdoesnm in #288, which tracked the work from a roadmap to the final pieces.
Portable objects are a foundation for nomadic identity, not nomadic identity itself. Fedify serves and verifies portable objects and forwards activities between gateways, but it does not copy actors or posts between servers, rotate keys, or move an actor to another DID. Full nomadic identity is still open in #413.
Hosting a portable actor
A portable actor starts with an Ed25519 key pair. Its DID is made from the public key with exportDidKey(), and whoever holds the private key can act as the actor, so store it with the actor and keep it safe:
import { generateCryptoKeyPair } from "@fedify/fedify";import { exportDidKey } from "@fedify/vocab-runtime";const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");const did = await exportDidKey(publicKey); // did:key:z6Mk...// The verification method that the actor's proofs refer to:const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);
The actor dispatcher returns a portable actor as it would an ordinary one. Context.getPortableActorUri() and its siblings for inboxes, outboxes, collections, and objects build portable IDs from the dispatcher's paths and the DID, and signObject() signs the actor with the DID's key:
federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = await findUser(identifier); if (user == null) return null; // A request through the gateway endpoint names the DID in its path, // e.g., GET /.well-known/apgateway/did:key:z6Mk.../users/alice: if ( ctx.portableRequest != null && ctx.portableRequest.authority !== user.did ) { return null; } // This server's gateway keys for the actor (see below): const keys = await ctx.getActorKeyPairs(identifier); return await signObject( new Person({ id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice preferredUsername: user.username, inbox: ctx.getPortableInboxUri(identifier, user.did), outbox: ctx.getPortableOutboxUri(identifier, user.did), followers: ctx.getPortableFollowersUri(identifier, user.did), gateways: [new URL("https://example.com")], publicKeys: keys.map((key) => key.cryptographicKey), assertionMethods: keys.map((key) => key.multikey), }), user.didKeyPair.privateKey, new URL(`${user.did}#${user.did.slice("did:key:".length)}`), ); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = await findUser(identifier); // This server's own key pair for the actor, not the DID's: return user == null ? [] : [user.gatewayKeyPair]; }) .mapPortableActorId(async (ctx, identifier) => { const user = await findUser(identifier); return user == null ? null : ctx.getPortableActorUri(identifier, user.did); });
With this dispatcher, Fedify serves the actor at its compatible identifier, https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice, which is how other servers retrieve it. It serves it through WebFinger as @alice@example.com, since the handle's domain comes from the first gateway in gateways, and at its ordinary URL as before. On the gateway endpoint, Fedify serves a document only if its ID is the requested portable ID and it carries a proof made with a key of that DID.
The key pairs dispatcher and the new ActorCallbackSetters.mapPortableActorId() give the actor gateway keys. These are the server's own keys for the actor, and they sign only the HTTP requests the server makes on the actor's behalf, such as deliveries. They never sign the actor's activities or objects; only the DID's key does that. The actor document lists them under assertionMethods, as FEP-521a describes, so that other servers can verify the HTTP Signatures. See Dispatching the actor and Gateway keys of portable actors for the details.
Object and collection dispatchers serve portable objects and collections through the gateway endpoint in the same way, routed by the path after the DID. Two rules protect the actor's data. A non-public portable object is served only if the dispatcher has an authorization predicate, because FEP-ef61 forbids a gateway to show it to anyone outside its audience; the new RequestContext.isSignedByAudience() helps to write one. Without a predicate, a request for such an object gets 404 Not Found, as though the gateway did not store it. A deleted object is served as a signed Tombstone with 410 Gone. Resources that portable objects refer to by SHA-256 hashlinks, such as GET /.well-known/apgateway/hl:zQm..., are served by the new Federatable.setHashlinkMediaDispatcher().
Sending and receiving
A portable actor's activities have to be signed by its DID, which Fedify's key pairs dispatcher does not know about. Sign them with signObject() before sending, and pass normalizeExistingProofs: true so that the activity goes out in the form its proof covers. An activity that embeds a portable object, such as the Note of a Create, is signed inside out:
This example depends on a fix to signObject() that is useful outside FEP-ef61 as well. Previously, a signed object assigned to a typed parent was rebuilt under the parent's JSON-LD context when the parent was serialized. The child kept its proofValue but lost the document its proof covered, so it no longer verified on its own. signObject() now captures the exact document it signed, and nested serialization embeds that document verbatim. The captured document is a snapshot: clone() does not carry it, so sign a clone again if it has to be embedded. The design is in #1044 and the fix in #1051.
Context.sendActivity() refuses a portable activity that is unsigned, signed by the wrong key, or carries a Linked Data Signature, and throws a TypeError before anything is queued. It also refuses a few shapes of compound document that Fedify's own inboxes would reject, such as a received signed Follow embedded in an Accept; refer to such an object by its ID instead. Deliveries to portable inboxes go through the recipient's gateways, or else the @gateway hints on the inbox, trying at most five of them until one accepts. Before this release, they failed with UrlError: Unsupported protocol: ap+ef61:. The rules are in Delivering to portable actors and Producing a compound document.
On the receiving side, a portable inbox is reached as POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox and dispatched to the same inbox listeners as the ordinary inbox. Context.parseUri() gains a portable option, and the parsed result has the DID in a new authority property to check against the one you store. Fedify accepts an activity of a portable actor, or one with a portable ID, only if it has a valid proof by that DID; neither an HTTP Signature nor a Linked Data Signature authenticates it. Each portable object embedded in the activity is verified against its own proof before dispatch. An accepted activity that carries its own proof is forwarded to the actor's other gateways at most once, as FEP-ef61 recommends. Forwarding needs a key–value store that supports cas(), and the FederationOptions.portableInboxForwarding option tunes it. See Portable inboxes and Forwarding to other gateways.
Consuming portable objects
Applications that never host a portable actor still meet portable objects from other servers. Context.lookupObject() resolves portable IDs, compatible identifiers, and the fediverse handles of portable actors, and returns portable objects only if they verify:
const actor = await ctx.lookupObject("@alice@example.com");// A portable ID without @gateway hints needs gateways to ask:const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", { gateways: ["https://example.com"],});
Property accessors fetch portable references through gateways as well, and they verify what they fetch with the new verifyPortableObject option. Context has a matching verifyPortableObject property, so passing a context as the options, as in await create.getObject(ctx), is enough. Activities received in inboxes use this verifier by default, and objects fetched through their accessors inherit it, so a chained call such as (await create.getObject(ctx))?.getAttribution() verifies each portable object along the way. A lookup asks at most five gateways, falling back to the next when one fails or serves something that does not verify. Unsigned collections are accepted only from a gateway that the owner lists, and only for an actor's inbox, outbox, followers, following, and liked, as FEP-ef61 allows. fetchPortableMedia() retrieves the media of a portable object and returns it only if it matches the object's digestMultibase. See Dereferencing portable references and Default verifiers.
Compatible identifiers are no longer trusted by origin
Software that does not understand ap: URIs can refer to a portable object by a compatible identifier, an HTTP(S) URL under a gateway's /.well-known/apgateway/ path such as https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor. Some implementations, tootik among them, use compatible identifiers as the IDs of their portable actors.
Earlier versions of Fedify treated these URLs as ordinary web URLs and trusted them by the server that served them. Since anyone can serve a compatible identifier for any DID, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor could act as Alice without a proof by Alice's DID. Fedify 2.4.0 treats a compatible identifier as the portable object it stands for, as FEP-ef61 requires. Inboxes reject an activity from an actor whose ID is a compatible identifier with 401 Unauthorized unless it has a valid Object Integrity Proof by the actor's DID, and there is no option to turn this off. Actors that publish compatible identifiers without signing them are no longer accepted, and neither are those whose DIDs use key types that Fedify cannot verify yet. The change was tracked in #1093 and made in #1105.
Your own portable actors may still use compatible identifiers as their IDs, for software that rejects ap: IDs. Build them with toCompatibleEf61Id() before signing, and Fedify will treat them as portable wherever it serves or sends them. It also warns if one is malformed or does not point at the actor's first gateway. See Compatible identifiers as actor IDs.
Where Fedify's profile differs from the FEP
Two choices in Fedify's profile differ from the current FEP text or rest on semantics that are not settled yet. Fedify keeps both for the 2.x series, and either may change in Fedify 3.0.
The first is the scheme. FEP-ef61 recommends ap: and canonicalizes ap+ef61: to it, while also warning that the recommendation may change to ap+ef61:, since these IDs are meant only for portable objects. Fedify accepts both, compares them as equal, and serializes ap+ef61:. Parsing a document normalizes its portable IRIs to ap+ef61:, so re-serializing an object that another implementation signed with ap: IDs breaks its proof. Keep the received JSON of such documents, and compare portable IDs with arePortableUrisEqual() rather than as strings. The discussion is in #826 and #828, and the reasoning is in Canonical ap+ef61: scheme.
The second concerns compound documents. When a signed Note sits inside a signed Create, the verifier has to know which part of the JSON each proof covers, and neither FEP-8b32 nor Verifiable Credential Data Integrity defines that yet; the question is open in w3c/vc-data-integrity#350. Fedify uses an interim map-local profile, worked out in #938. Each JSON map with a proof is its own secured document, each embedded portable object needs its own proof and its own @context, and proof sets and chains are not supported. The profile authenticates the exact JSON values each proof covers; it does not promise that a child's JSON-LD expansion inside its parent matches its expansion on its own. @silverpill, @dmitrizagidulin, and @by_caballero reviewed the proposal in that thread, and @silverpill reproduced its test vectors independently. See Map-local compound proofs.
What is not implemented
Fedify does not implement FEP-ae97, the companion proposal for client-side signing. There are no endpoints for registering actors, submitting client-signed activities to a portable outbox, or gateway discovery, and no media upload and deletion endpoints under /.well-known/apgateway-media. Your own outbox listeners can still relay activities that a client signed, unchanged. Fedify also does not copy or reconcile actors, objects, or collections between gateways, so running an actor on several gateways means keeping their data in sync yourself. There is no workflow for rotating a DID's key or migrating an actor to another DID. Fedify resolves verification methods only for did:key DIDs with Ed25519 keys; other DID methods verify only if your document loader resolves them. Gateways must be plain origins, since the arbitrary gateway paths that FEP-ef61 discusses are not supported. The full list is in Not supported.
Interoperability
The profile was tested against tootik v0.25.4 and Mitra v5.10.0 in #1202. Fedify verifies tootik's portable actors, activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on activities that a client signed. Both accept the portable activities that Fedify sends. Two fixes came out of this testing. Fedify now reads a gateways list that tootik leaves unmapped in its JSON-LD context. It also serializes gateways as bare origins, because Mitra rejects a trailing slash. Fedify has not yet verified an actor or activity that Mitra signed itself, and Mastodon's handling of portable actors has not been tested. The FEP-ef61 interoperability section of FEDERATION.md records what was tested and how.
Tooling
fedify lookup looks up portable objects by their ap: and ap+ef61: IDs, compatible identifiers, and the handles of portable actors, and it verifies their proofs. When no gateway returns an acceptable object, it says why. A new --gateway option on fedify lookup, fedify inbox, and fedify webfinger names the gateways to ask. fedify inbox --follow follows portable actors, and fedify webfinger accepts portable actor IDs. See Looking up portable objects in the CLI manual.
The mock federation and contexts in @fedify/testing support portable IDs, verification against fixture document loaders, gateway requests, and hashlink media, so tests can exercise portable objects without a live gateway. @fedify/lint's actor URI rules accept the portable IDs that the getPortable*Uri() methods build. @fedify/next now passes hashlink media requests to Fedify, a problem reported in #1149 and fixed in #1170; to have Next.js run the middleware for them, add { source: "/.well-known/apgateway/:path*" } to your matcher.
Background tasks
Fedify already runs activity delivery and inbox processing on queue workers, but applications usually have jobs of their own: digest emails, timeline rebuilds, link previews. Until now, those jobs could not use Fedify's queues, serialization, or retries. Fedify 2.4.0 opens the same machinery to application-defined tasks.
A task is defined once with defineTask() and enqueued from any Context with enqueueTask() or enqueueTaskMany(). Every task takes a Standard Schema, such as a Zod or Valibot schema, which types its payload. Fedify validates the payload when it is enqueued and again when a worker picks it up; the second check catches payloads from an earlier deployment that no longer match the schema:
import { createExponentialBackoffPolicy } from "@fedify/fedify";import { z } from "zod";const sendDigest = federation.defineTask("sendDigest", { schema: z.object({ userId: z.string(), since: z.date() }), handler: async (ctx, data) => { // data is typed as { userId: string; since: Date } await sendEmail(data.userId, await buildDigest(data.userId, data.since)); }, retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),});// Anywhere with a Context:await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, { delay: { minutes: 30 }, deduplicationKey: "digest:alice",});
Fedify serializes payloads with devalue, so Date, Map, Set, URL, bigint, Temporal values, circular references, and Activity Vocabulary objects all survive the trip through any message queue backend. A Note in a payload comes back as a Note. Failed handlers are retried with exponential backoff by default, and queues with native retries handle retries themselves. Tasks fall back to the outbox queue unless you give them their own. Use the new task slot of the queue option or a per-task queue for that, and startQueue(ctxData, { queue: "task" }) runs a task-only worker. Setting taskQueueResolution: "strict" turns the fallback into an error.
A deduplicationKey deduplicates enqueues. The feature came from @julian's point in #206 that recurring tasks are hard to manage when you cannot tell whether one is already queued. A queue that declares nativeDeduplication enforces the key itself; otherwise, Fedify keeps a best-effort marker in the key–value store through cas(). Each task runs in a fedify.task span, and the queue metrics from 2.3.0 now include task queues. The Background tasks chapter covers routing, retries, deduplication, and the limitations; cron-style scheduling, result backends, and priorities are out of scope for now.
ChanHaeng Lee (@2chanhaeng) implemented the API: the core in #803, deduplication in #806, and observability in #812, following the plans in #797, #798, and #799. The work was merged in #923, closing #206, which had been open since February 2025.
Media uploads
Fedify 2.2.0 added client-to-server outbox listeners, but a C2S client also has to upload images and video before it can post them. That traffic does not go through POST /outbox. Fedify 2.4.0 supports the ActivityPub Media Upload extension, a SocialCG proposal last updated in 2017 that never reached W3C Recommendation status.
setMediaUploader() registers a multipart/form-data endpoint. Its callback receives the uploaded file and the object shell the client posted along with it. If the callback returns the created object, Fedify responds with 201 Created; if it returns a URL where the object will appear once processing finishes, Fedify responds with 202 Accepted:
The actor dispatcher advertises the endpoint itself by setting endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }), the same way it places its inbox and outbox URIs. Without an authorize() hook, anyone who finds the endpoint can upload to it. For that reason, Fedify logs a warning when an uploader is registered without one, and when the returned object's URI does not match an object dispatcher or the actor does not advertise the endpoint. Four new @fedify/lint rules catch the same mistakes before they run. Fedify reads the upload into memory and sets no size limit of its own, so cap the request size at your reverse proxy. Storing the file and serving the object back are up to the application. See the Media upload chapter; the work is in #754 and #927.
Inbox request reports
Fedify decides several things about every inbox delivery: which signature mechanisms applied, which keys they tried, whether the actor owned the key, and what happened to the activity. Before this release, applications saw only part of that. A Linked Data Signature by a key the actor did not own was counted the same as a bad signature, and the key that verified a request was never handed to the application. The request in #1191 came from an effort to log these decisions for a debugging tool.
The new onRequestFinished() callback on inbox listeners receives an InboxRequestReport for every delivery to an inbox route, including rejected requests and requests that fail before verification:
Each HTTP Signature, Linked Data Signature, or Object Integrity Proof check appears as an attempt with the actual keys it tried, including a stale cached key and its refreshed replacement. The report also carries the final authentication decision, such as actorKeyMismatch or proofPolicy, and the response or exception that ended the request. Fedify awaits the callback whether or not the trace is sampled. Errors it throws are logged without changing the response, and enqueued means the queue accepted the activity, not that a worker later processed it. Mock federations in @fedify/testing accept the same callback. See Observing inbox requests; the implementation is in #1201.
Tombstones for deleted objects
Actor dispatchers have been able to return a Tombstone since 2.2.0, but object dispatchers could only return null. A deleted post was answered with 404 Not Found, as if it had never existed, so applications that wanted 410 Gone, as ActivityPub recommends, needed their own route in front of Fedify.
Object dispatchers may now return a Tombstone, which Fedify serves with 410 Gone and the serialized tombstone after applying the authorization predicate:
federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", async (ctx, values) => { const post = await getPost(values.identifier, values.id); if (post == null) return null; if (post.deletedAt != null) { return new Tombstone({ id: ctx.getObjectUri(Note, values), formerType: Note, deleted: post.deletedAt, }); } return new Note({ id: ctx.getObjectUri(Note, values), content: post.content }); },);
The return type of ObjectDispatcher widens to TObject | Tombstone | null. RequestContext.getObject() still returns null for a tombstone unless you pass { tombstone: "passthrough" } or ask for the Tombstone or Object class itself. Dispatchers registered for Tombstone or Object that already returned tombstones now respond with 410 rather than 200. See Deleted objects; the change was proposed in #1112 and made in #1117.
Limits on what a remote peer can make Fedify do
Much of what Fedify fetches is chosen by the sender of an incoming request. Key IDs in signatures are one example, and the servers behind them can be slow on purpose. Several changes in 2.4.0 bound that work.
As #1130 points out, a request may carry several RFC 9421 signatures, and each one can make Fedify fetch a key from a URL of the sender's choosing. Fedify now verifies at most the first three, in Signature-Input order, and ignores the rest. A signature counts toward the limit even if it fails before its key is fetched, and a key named by several signatures is looked up once. The new FederationOptions.maxHttpSignatures option, added with the limit in #1166, changes it.
The built-in document loaders now give each call ten seconds by default, a limit proposed in #1131 and added in #1169. The time limit covers every redirect, alternate document, double-knocking retry, and the response body. A timed-out call throws a FetchError whose cause is a DOMException named "TimeoutError". Error responses are read in full before the loader throws, up to 1 MiB, so that a server cannot send a 404 header and then stall the body. FederationOptions.documentLoaderTimeout changes the limit or turns it off with null, and custom document loaders are not affected. Since #913, getDocumentLoader() also rejects HTML responses that do not advertise an ActivityPub alternate with a FetchError, rather than failing inside JSON.parse(), which #912 found cluttering error trackers.
Cached public keys and remembered HTTP Message Signatures specs used to live forever in the key–value store. #1017 reports a production Redis instance holding 194,424 public-key entries, about 165 MiB, for actors that had mostly stopped federating. Keys now expire after 30 days and specs after 90, configurable through FederationOptions.publicKeyTtl and FederationOptions.httpMessageSignaturesSpecTtl. Entries written by earlier versions have no expiry until they are next written. The Clearing legacy cache entries section shows how to remove them for Redis, PostgreSQL, and other stores. Heewon Chae (@heeoneie) implemented this in #1027, their first contribution to Fedify.
Finally, verifyProof() now authenticates every option of an Ed25519 JCS proof except proofValue, including expires, domain, challenge, and nonce. It also rejects expired or malformed ones. Previously, a field such as expires could be added to an already signed proof without invalidating it. Jiwon Kwon (@z9mb1) spotted this in review of #968. Callers can now require a domain and challenge through VerifyProofOptions to prevent cross-domain or replayed use.
New packages
@fedify/adonisjs
@fedify/adonisjs integrates Fedify with AdonisJS. Running node ace add @fedify/adonisjs registers a service provider that owns the federation's lifecycle and a server middleware that mounts it. It also scaffolds config/fedify.ts, start/federation.ts, and app/federation/main.ts. Controllers get a ctx.federation request context:
// app/federation/main.tsimport federation from "@fedify/adonisjs/services/builder";import { Person } from "@fedify/vocab";federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));
The middleware sits first in AdonisJS's server stack rather than its router stack. That way Fedify sees paths the router does not know, such as /.well-known/webfinger, and reads request bodies before the body parser consumes them. The package targets Node.js and is published to npm only. See the AdonisJS section of the integration guide.
Samuel Brinkmann (@sabrinkmann) contributed the package in #1006, closing #139, and built it on a prototype that Emelia Smith (@thisismissem) attached to that issue.
GoToSocial interaction controls, FEP-044f quote authorization, and FEP-7aa9 featured collections share a request-and-authorization flow. A server asks to reply to, quote, or feature a post, and the author's server approves according to the post's interactionPolicy. @fedify/interaction-controls implements that flow for like, reply, announce, quote, and feature interactions. Each has a helper, such as likeInteraction or quoteInteraction, that creates and verifies requests and authorizations, evaluates policies, and recognizes interactions that arrived without a request:
import { likeInteraction } from "@fedify/interaction-controls";// When a LikeRequest arrives:const verified = await likeInteraction.verifyRequest(ctx, { request });if (verified.verified) { const decision = await likeInteraction.evaluatePolicy(ctx, { subject: note, requester: request.actorId!, }); if (decision.result === "automatic") { // or "manual" or "denied" const authorization = likeInteraction.createAuthorization({ id: new URL("https://example.com/authorizations/1"), attributedTo: owner, interactingObject: like, interactionTarget: note, }); // ... }}
The package does not install inbox listeners or store anything; the application decides where requests and authorizations live and when a human needs to approve. Verification fails closed. Without a policy, likes, replies, and announces are approved automatically, while quotes and features are denied. @fedify/vocab gains the FEP-7aa9 types this needs: FeaturedCollection, FeaturedItem, FeatureRequest, and FeatureAuthorization, along with actors' featuredCollections and InteractionPolicy.canFeature. Samuel Brinkmann asked for them in #810 once Mastodon 4.6 shipped the FEP. See the Interaction controls chapter; the work is in #914 and #929.
@fedify/netlify lets a Fedify application run on Netlify Functions without an external database or queue. NetlifyMessageQueue submits jobs as Netlify Async Workloads events, and createNetlifyQueueHandler() turns a Function into their consumer, with delayed delivery, native retries, and per-key FIFO ordering. NetlifyBlobsKvStore is a key–value store on Netlify Blobs with expiration, prefix listing, and compare-and-swap:
import { createNetlifyQueueHandler, NetlifyBlobsKvStore, NetlifyMessageQueue,} from "@fedify/netlify";import { AsyncWorkloadsClient } from "@netlify/async-workloads";import { getStore } from "@netlify/blobs";export const kv = new NetlifyBlobsKvStore( getStore({ name: "fedify", consistency: "strong" }),);export const queue = new NetlifyMessageQueue({ client: new AsyncWorkloadsClient(), orderingKv: kv,});// netlify/functions/fedify-queue.tsexport default createNetlifyQueueHandler({ queue, maxRetries: 4, federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),});
The key–value store exists because of cost. #1010 describes a static blog whose ActivityPub traffic kept a Netlify Database from ever idling, which used most of the account's monthly credits. Jiwon Kwon contributed NetlifyBlobsKvStore in #1029, and the queue landed in #934. See Netlify Functions in the deployment guide.
@fedify/pglite provides PgliteKvStore, a KvStore on an embedded PGlite database that you create and pass in. It uses the same schema as PostgresKvStore. It is meant for a single PGlite instance in a single runtime isolate, so it does not include a message queue: PGlite does not share data between processes.
import { PGlite } from "@electric-sql/pglite";import { PgliteKvStore } from "@fedify/pglite";const federation = createFederation({ kv: new PgliteKvStore(new PGlite("./data/fedify")),});
ChanHaeng Lee contributed the package in #1020, together with testKvStore() in @fedify/testing. testKvStore() is a conformance suite for KvStore implementations, the counterpart to testMessageQueue(). See PgliteKvStore.
Portable inbox forwarding, task deduplication, Netlify queue ordering, and the delivery circuit breaker all need an atomic compare-and-swap on the key–value store. PostgresKvStore now implements cas(), added in #934, and so does RedisKvStore, requested in #1163 and added in #1167. The Redis version uses a single-key Lua script, which works on standalone Redis and Redis Cluster. Before this, the circuit breaker fell back to racy updates on Redis.
Both come with conditions. RedisKvStore needs Redis to permit EVAL, and custom codecs must encode equal values identically. PostgresKvStore now creates logged tables by default, because losing sequence state in crash recovery could block queued events for good. It migrates existing unlogged tables when it initializes. That one-time migration rewrites the table and locks it exclusively, so schedule the upgrade accordingly if the table is large or busy; unlogged: true keeps the old behavior.
fedify init and the CLI
fedify init can now scaffold a SvelteKit project, contributed by Jang Hanarae (@menele) in #971. Jang Hanarae also added a test task to generated projects in #990. It starts the app, waits for it to be ready, and checks that it resolves a local actor, which gives new projects a smoke test to run after scaffolding and after later changes. Before generating anything, fedify init now checks that the selected Deno, Bun, or Node.js meets Fedify's minimum version, or a framework's higher one, such as Astro's Node.js 22.12. It fails with a clear error in non-interactive mode, and in interactive mode it disables the affected package managers. Lee Jeongmin (@userjmmm) contributed the check in #981. Astro projects now use Astro 7, and since #936@fedify/astro is tested against Astro 5, 6, and 7 with real builds on Node.js, Deno, and Bun, as #931 asked.
fedify tunnel gains fedify.com.es, a tunneling service run by the Fedify project that needs no account; the CLI pins its SSH host key and refuses a mismatched server before exposing a local port. localhost.run is removed in the same change, #940, because the service no longer exists. fedify lookup now times out each request after ten seconds when -T/--timeout is not given, and the option now also bounds the wait for a DNS lookup. The CLI moves to Optique 1.3.2 in #1197, which handles attached values like --timeout=30 consistently, gives better suggestions for mistyped options, and no longer hides errors for known options behind positional arguments before --.
Lint rules
@fedify/lint gains several rules beyond the media upload ones mentioned above. actor-preferred-username-required, requested in #895 and added in #1022, warns when an actor dispatcher returns an actor without preferredUsername. outbox-listener-delivery-not-awaited, from #1057 and #1067, reports an outbox listener that calls ctx.sendActivity() or ctx.forwardActivity() and drops the promise. On Cloudflare Workers, such an activity may never leave, because pending work is discarded once the response returns. await, return, Promise.all(), Promise.allSettled(), and waitUntil() count as handling the promise, while void, Promise.race(), and Promise.any() are accepted as a deliberate choice not to wait. The rule is a warning in the ESLint recommended configuration and an error in strict. Oxlint users enable it by name, and it is not available in Deno Lint.
In #1050, which resolves #900, the existing outbox-listener-delivery-required rule began following control flow instead of scanning the listener's source as text. It reports a listener whose only delivery calls sit in a dead branch, after an unconditional return or throw, or in a function that is never used, and it stays quiet when it cannot tell. Jae-Hyuk-Jang (@jhyuk) contributed this change and both new rules. archie (@ArchieTansaria) then taught both delivery rules about statically false loops and loop binding patterns in #1088. See the linting guide.
Other changes
@fedify/vocab supports the FEP-22cd draft for translations. A Translation records a translated version's language, translators, source object, and source review time, and the new Object.translations property attaches them to a post without creating separate posts. See Translation metadata. (#1037, #1038)
@fedify/vocab-tools gains an extraContext property option, which adds a JSON-LD context only when its terms are used, and a trustEmbeddedObjects type option. (#1037, #1038)
Vocabulary fetch and parse failures that suppressError: true handles are now logged as warnings rather than errors, so that they no longer show up as application errors. Contributed by Jae Hui Hong (@axz0612). (#933, #1035)
A queue whose enqueueMany() makes separate sends can set MessageQueue.atomicEnqueueMany to false. Fedify then rejects a multi-message batch governed by one deduplicationKey up front, since a partial send followed by a retry could enqueue duplicates. (#930, #934)
CommonJS builds that use Temporal bundle temporal-polyfill instead of requiring @js-temporal/polyfill at runtime, and type declarations rely on the standard esnext.temporal lib. (#823, #925)
The document loaders' Accept header now includes the ActivityStreams profile on the JSON-LD media type, as ActivityPub and FEP-ef61 gateways require. (#834, #1077)
The relay documentation now uses the canonical actor and shared inbox URIs, separates Mastodon-style and LitePub-style subscriptions, and explains what remains the application's responsibility. Contributed by Jiwon Kwon. (#899, #996)
Bug fixes
Fixed outbound delivery circuit breaker transitions on key–value stores that compare encoded values. Existing half-open states can recover after switching to a CAS-capable store. (#1163, #1167)
Fixed fedify lookup --recurse reporting a timeout or other network failure as a possibly private object; it now reports the actual cause. (#1156, #1199)
Fixed the -p/--allow-private-address option of fedify webfinger being ignored. (#1156, #1199)
Fixed outbox-listener-delivery-required and outbox-listener-delivery-not-awaited losing track of an object's functions when a nested helper assigns another property of that object. (#1125, #1140)
Update the other @fedify/* packages you use to 2.4.0 at the same time. To update the CLI, run npm install -g @fedify/cli@2.4.0, or see the installation instructions for other package managers.
Most applications need no code changes, but these changes in behavior may affect you:
Activities from actors whose IDs are FEP-ef61 compatible identifiers are rejected with 401 Unauthorized unless they carry a valid Object Integrity Proof by the actor's DID. Keys at compatible identifiers, and keys at ordinary URLs that claim a portable owner, are no longer trusted by origin.
Property accessors given a Context as their options, such as await create.getObject(ctx), now verify the portable objects they dereference, including references to compatible identifiers from objects with ordinary HTTP(S) IDs.
Your dispatchers may be called for requests to /.well-known/apgateway/, and Context.sendActivity() may reject an activity that embeds others' portable objects. Next.js applications need the matcher shown above to serve hashlink media.
Only the first three RFC 9421 signatures of a request are verified. Set maxHttpSignatures if you need more.
The built-in document loaders time out after ten seconds, and fedify lookup does too.
Cached public keys expire after 30 days and HTTP Message Signatures specs after 90 days.
Object dispatchers registered for Tombstone or Object that return tombstones now respond with 410 Gone instead of 200 OK.
PostgresKvStore migrates existing unlogged tables to logged tables on initialization, which locks the table.
RedisKvStore needs EVAL permission for cas().
Queue workers from older versions deliver queued activities for portable inboxes only through the first gateway. Upgrade workers before the servers that enqueue deliveries.
Custom implementations of the Context and RequestContext interfaces need the new getPortable*Uri() methods and isSignedByAudience().
fedify tunnel no longer supports localhost.run.
Acknowledgments
Thanks to this release's contributors:
Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, and the request for FEP-7aa9 vocabulary
Jang Hanarae (@menele): SvelteKit support and the test task in fedify init
Lee Jeongmin (@userjmmm): runtime version verification in fedify init
Jae-Hyuk-Jang (@jhyuk): the actor-preferred-username-required and outbox-listener-delivery-not-awaited lint rules, and control-flow analysis for outbox-listener-delivery-required
archie (@ArchieTansaria): loop handling in the outbox delivery lint rules
Jae Hui Hong (@axz0612): warning-level logging for suppressed vocabulary failures
Heewon Chae (@heeoneie): expiring public key and HTTP Message Signatures spec caches
ChanHaeng Lee (@2chanhaeng) and Jiwon Kwon (@z9mb1) joined as maintainers after 2.3.0. ChanHaeng built the background task API and @fedify/pglite, Jiwon built NetlifyBlobsKvStore and rewrote the relay documentation, and both reviewed much of the rest of this release.
The FEP-ef61 work owes a lot to people outside the project. @anotherdoesnm opened the request in #288. @silverpill, the author of FEP-ef61 and FEP-ae97, clarified the gateway trust model, reviewed the compound proof profile, and checked its test vectors. @dmitrizagidulin and @by_caballero weighed in on the profile's JSON-LD questions in #938. Testing against tootik and Mitra led to two of the interoperability fixes in this release.
"소프트웨어 개발이 자명하게 공학으로 불릴 만하다면 소프트웨어 엔지니어라는 이름에도 특별함이 없었을 것이다. 이런 느낌이 드는 이유는 무엇이고, 소프트웨어 공학이 기계 공학, 전기 공학, 토목 공학과 왜 다르게 느껴지며, 소프트웨어 개발을 공학으로 만드는 것은 또 무엇일까? (...) 조직이 개인의 천재성에 의존하지 않고 반복해서 신뢰할 수 있는 결과를 만들어야 하는 순간부터 공학적 접근이 필요해진다." https://parksb.github.io/article/44.html
If you use BotKit, update to a patched release now. Three vulnerabilities affect Fedify versions included by BotKit as a dependency: CVE-2026-96625, a critical actor impersonation vulnerability; CVE-2026-96623, a high-severity denial-of-service vulnerability in remote document parsing; and CVE-2026-96624, a medium-severity server-side request forgery vulnerability in outbound activity delivery. Treat the actor impersonation issue as an immediate upgrade: anyone on the internet could have an activity accepted by your bot's inbox as coming from any actor.
CVE-2026-96625 affects signature verification for incoming activities. Fedify verified the signature but trusted the signing key document's own claim about whom the key belonged to. An attacker with an ordinary HTTP server could serve a key document naming any actor as its owner and have activities accepted under that actor's identity. No account on the receiving server was required. HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs were all affected. The same flaw affected getKeyOwner() and Context.getSignedKeyOwner(), so applications using those methods for authorized fetch access control could also expose resources reserved for the impersonated actor.
The fix resolves the claimed owner's actor document and requires it to link back to the key. It also validates the origin of fetched actor documents, so a host serving a key cannot speak for an actor on another origin. Fedify's built-in key cache automatically stops reading entries cached before the fix. If your application passes a custom KeyCache to verifyRequest(), verifyJsonLd(), or verifyObject(), discard its contents when upgrading: a patched process would still trust unverified ownership in a stale custom cache entry.
CVE-2026-96623 affects inbox requests and remote document fetching. Fedify parsed JSON bodies without a byte limit, including inbox bodies and fetched keys, actors, objects, JSON-LD contexts, WebFinger descriptors, and NodeInfo documents. An attacker who caused Fedify to fetch a URL they controlled could exhaust memory and CPU with a large response. A small compressed response could expand substantially before parsing, so an inbound body limit at a reverse proxy did not protect the outbound fetch paths.
The fix limits JSON bodies to 16 MiB after decompression. Oversized inbox requests receive HTTP 413, and oversized WebFinger descriptors resolve to null. HTML alternate-link discovery retains its existing 1 MiB limit. The JSON limit is fixed in these patch releases, so legitimate JSON-LD documents larger than 16 MiB will now be rejected too.
CVE-2026-96624 affects outbound activity delivery to inbox URLs learned from remote actors. The delivery path checked neither the advertised inbox URL nor redirect destinations. A remote actor could point its inbox at a loopback address, a link-local cloud metadata service, or a private network host, or redirect delivery there. On the RSA delivery path, the redirected request remained a POST carrying the activity body and was re-signed for the internal host.
The fix validates the initial destination and every redirect target before sending a request. The authenticated document-loader fix in CVE-2026-77632, included in BotKit 0.4.6 and 0.5.2, covered a separate fetch path and did not protect outbound delivery. Applications that explicitly enable Fedify's allowPrivateAddress option continue to allow private inbox URLs and private redirect targets. Keep that option limited to testing or closed federation environments where you control the actors you federate with.
BotKit 0.4.x versions through 0.4.6 and BotKit 0.5.x versions through 0.5.3 include Fedify versions affected by all three vulnerabilities. Patched releases are BotKit 0.4.7 and 0.5.4. BotKit 0.4.7 uses Fedify 2.1.24, and BotKit 0.5.4 uses Fedify 2.3.8.
Check that your resolved Fedify version is at least 2.1.24 on the 2.1.x line or 2.3.8 on the 2.3.x line. If you depend directly on @fedify/vocab-runtime or @fedify/webfinger, update those packages too; their parsing fixes use the same patched version numbers.
Thanks to @kaimandalic and @moreal for independently reporting the actor impersonation issue. Thanks also to @kaimandalic for the unbounded document parsing report and @euriconicacio for the outbound delivery SSRF report, and to all three for responsible disclosure.
If you use an affected Fedify release, update now. Three vulnerabilities have been fixed: CVE-2026-96625, a critical actor impersonation vulnerability; CVE-2026-96623, a high-severity denial-of-service vulnerability in remote document parsing; and CVE-2026-96624, a medium-severity server-side request forgery vulnerability in outbound activity delivery. Treat the actor impersonation issue as an immediate upgrade: anyone on the internet could have an activity accepted by your inbox as coming from any actor.
The patched releases are 2.0.28, 2.1.24, 2.2.13, and 2.3.8. All three vulnerabilities affect the preceding releases on those lines: 2.0.27, 2.1.23, 2.2.12, and 2.3.7, respectively. If you still use Fedify 1.x, change your dependency to a patched 2.x release because package-manager update commands do not cross the declared major-version range.
Actor impersonation (CVE-2026-96625, critical, CVSS 9.1)
CVE-2026-96625 has been present since Fedify's first public release, 0.1.0. Fedify verified the signature on an incoming activity, but trusted the signing key document's own claim about whom the key belonged to. An attacker with an ordinary HTTP server could serve a key document naming any actor as its owner and have activities accepted under that actor's identity, even if the actor did not exist. No account on the receiving server was required. HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs were all affected; the latter two did not require an HTTP signature on the request.
The same vulnerability affected getKeyOwner() and Context.getSignedKeyOwner(). A forged key document could pass ownership checks under another actor's identity, allowing an attacker to read resources that an application's authorized fetch access control reserved for that actor.
The fix resolves the claimed owner's actor document and requires it to link back to the key. It also validates the origin of fetched actor documents, so a host serving a key cannot speak for an actor on another origin. A key without an explicit owner is attributed to the actor whose document carried it.
Public keys cached before this release recorded ownership that had not been verified. Fedify's built-in key cache automatically stops reading those entries. If you pass a custom KeyCache implementation to verifyRequest(), verifyJsonLd(), or verifyObject(), discard its contents when you upgrade. A patched process reading a stale entry from a custom cache would still trust the unverified owner in it.
CVE-2026-96623 affects deployments that accept inbox requests or fetch remote documents. Fedify parsed JSON bodies without a byte limit, including inbox bodies and responses fetched for keys, actors, objects, JSON-LD contexts, WebFinger descriptors, and NodeInfo documents. An attacker who caused Fedify to fetch a URL they controlled could exhaust memory and CPU with a large response. A small compressed response could expand substantially before parsing, so an inbound body limit at a reverse proxy did not protect the outbound fetch paths.
The fix limits JSON bodies to 16 MiB after decompression. HTML alternate-link discovery retains its existing 1 MiB limit. Oversized inbox requests receive HTTP 413, and oversized WebFinger descriptors resolve to null. The JSON limit is fixed in these patch releases; applications that exchange legitimate JSON-LD documents larger than 16 MiB will now have those documents rejected.
The parsing fixes also ship in @fedify/vocab-runtime and @fedify/webfinger. If you depend on either package directly, update it too. They use the same patched version numbers listed above.
CVE-2026-96624 affects applications that deliver activities to inbox URLs learned from remote actors. The delivery path validated neither the advertised inbox URL nor redirect destinations. A remote actor could point its inbox at a loopback address, a link-local metadata service, or a private network host, or redirect delivery there. On the RSA delivery path, the redirected request remained a POST carrying the activity body and was re-signed for the internal host. The vulnerable delivery path dates back to JSR release 0.1.0 and npm release 0.5.0.
The fix validates the initial destination and every redirect target before sending a request. The authenticated document-loader fix in CVE-2026-77632 covered a separate fetch path and did not protect outbound activity delivery.
If you deliberately deliver to private addresses for local testing or a closed federation, these releases will refuse those deliveries unless you pass allowPrivateAddress: true to createFederation(). That option permits both private inbox URLs and private redirect targets, restoring the exposure described here. Keep it to environments where you control the actors you federate with.
Check that your resolved dependency versions are at least 2.0.28, 2.1.24, 2.2.13, or 2.3.8 on the corresponding release line. Update any direct dependencies on @fedify/vocab-runtime and @fedify/webfinger as well, and clear any custom key cache as described above.
After updating, redeploy. If you run other Fedify-based servers, update those too.
Thanks to @kaimandalic and @moreal for independently reporting the actor impersonation issue. Thanks also to @kaimandalic for the unbounded document parsing report and @euriconicacio for the outbound delivery SSRF report, and to all three for responsible disclosure.
If you use an affected Fedify release, update now. Three vulnerabilities have been fixed: CVE-2026-96625, a critical actor impersonation vulnerability; CVE-2026-96623, a high-severity denial-of-service vulnerability in remote document parsing; and CVE-2026-96624, a medium-severity server-side request forgery vulnerability in outbound activity delivery. Treat the actor impersonation issue as an immediate upgrade: anyone on the internet could have an activity accepted by your inbox as coming from any actor.
The patched releases are 2.0.28, 2.1.24, 2.2.13, and 2.3.8. All three vulnerabilities affect the preceding releases on those lines: 2.0.27, 2.1.23, 2.2.12, and 2.3.7, respectively. If you still use Fedify 1.x, change your dependency to a patched 2.x release because package-manager update commands do not cross the declared major-version range.
Actor impersonation (CVE-2026-96625, critical, CVSS 9.1)
CVE-2026-96625 has been present since Fedify's first public release, 0.1.0. Fedify verified the signature on an incoming activity, but trusted the signing key document's own claim about whom the key belonged to. An attacker with an ordinary HTTP server could serve a key document naming any actor as its owner and have activities accepted under that actor's identity, even if the actor did not exist. No account on the receiving server was required. HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs were all affected; the latter two did not require an HTTP signature on the request.
The same vulnerability affected getKeyOwner() and Context.getSignedKeyOwner(). A forged key document could pass ownership checks under another actor's identity, allowing an attacker to read resources that an application's authorized fetch access control reserved for that actor.
The fix resolves the claimed owner's actor document and requires it to link back to the key. It also validates the origin of fetched actor documents, so a host serving a key cannot speak for an actor on another origin. A key without an explicit owner is attributed to the actor whose document carried it.
Public keys cached before this release recorded ownership that had not been verified. Fedify's built-in key cache automatically stops reading those entries. If you pass a custom KeyCache implementation to verifyRequest(), verifyJsonLd(), or verifyObject(), discard its contents when you upgrade. A patched process reading a stale entry from a custom cache would still trust the unverified owner in it.
CVE-2026-96623 affects deployments that accept inbox requests or fetch remote documents. Fedify parsed JSON bodies without a byte limit, including inbox bodies and responses fetched for keys, actors, objects, JSON-LD contexts, WebFinger descriptors, and NodeInfo documents. An attacker who caused Fedify to fetch a URL they controlled could exhaust memory and CPU with a large response. A small compressed response could expand substantially before parsing, so an inbound body limit at a reverse proxy did not protect the outbound fetch paths.
The fix limits JSON bodies to 16 MiB after decompression. HTML alternate-link discovery retains its existing 1 MiB limit. Oversized inbox requests receive HTTP 413, and oversized WebFinger descriptors resolve to null. The JSON limit is fixed in these patch releases; applications that exchange legitimate JSON-LD documents larger than 16 MiB will now have those documents rejected.
The parsing fixes also ship in @fedify/vocab-runtime and @fedify/webfinger. If you depend on either package directly, update it too. They use the same patched version numbers listed above.
CVE-2026-96624 affects applications that deliver activities to inbox URLs learned from remote actors. The delivery path validated neither the advertised inbox URL nor redirect destinations. A remote actor could point its inbox at a loopback address, a link-local metadata service, or a private network host, or redirect delivery there. On the RSA delivery path, the redirected request remained a POST carrying the activity body and was re-signed for the internal host. The vulnerable delivery path dates back to JSR release 0.1.0 and npm release 0.5.0.
The fix validates the initial destination and every redirect target before sending a request. The authenticated document-loader fix in CVE-2026-77632 covered a separate fetch path and did not protect outbound activity delivery.
If you deliberately deliver to private addresses for local testing or a closed federation, these releases will refuse those deliveries unless you pass allowPrivateAddress: true to createFederation(). That option permits both private inbox URLs and private redirect targets, restoring the exposure described here. Keep it to environments where you control the actors you federate with.
Check that your resolved dependency versions are at least 2.0.28, 2.1.24, 2.2.13, or 2.3.8 on the corresponding release line. Update any direct dependencies on @fedify/vocab-runtime and @fedify/webfinger as well, and clear any custom key cache as described above.
After updating, redeploy. If you run other Fedify-based servers, update those too.
Thanks to @kaimandalic and @moreal for independently reporting the actor impersonation issue. Thanks also to @kaimandalic for the unbounded document parsing report and @euriconicacio for the outbound delivery SSRF report, and to all three for responsible disclosure.
Security updates have been released for three vulnerabilities in #Fedify: CVE-2026-96625, CVE-2026-96623, and CVE-2026-96624. Please update as soon as possible to version 2.0.28, 2.1.24, 2.2.13, 2.3.8, or later. For more information about the security updates, see here.
If you use an affected Fedify release, update now. Three vulnerabilities have been fixed: CVE-2026-96625, a critical actor impersonation vulnerability; CVE-2026-96623, a high-severity denial-of-service vulnerability in remote document parsing; and CVE-2026-96624, a medium-severity server-side request forgery vulnerability in outbound activity delivery. Treat the actor impersonation issue as an immediate upgrade: anyone on the internet could have an activity accepted by your inbox as coming from any actor.
The patched releases are 2.0.28, 2.1.24, 2.2.13, and 2.3.8. All three vulnerabilities affect the preceding releases on those lines: 2.0.27, 2.1.23, 2.2.12, and 2.3.7, respectively. If you still use Fedify 1.x, change your dependency to a patched 2.x release because package-manager update commands do not cross the declared major-version range.
Actor impersonation (CVE-2026-96625, critical, CVSS 9.1)
CVE-2026-96625 has been present since Fedify's first public release, 0.1.0. Fedify verified the signature on an incoming activity, but trusted the signing key document's own claim about whom the key belonged to. An attacker with an ordinary HTTP server could serve a key document naming any actor as its owner and have activities accepted under that actor's identity, even if the actor did not exist. No account on the receiving server was required. HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs were all affected; the latter two did not require an HTTP signature on the request.
The same vulnerability affected getKeyOwner() and Context.getSignedKeyOwner(). A forged key document could pass ownership checks under another actor's identity, allowing an attacker to read resources that an application's authorized fetch access control reserved for that actor.
The fix resolves the claimed owner's actor document and requires it to link back to the key. It also validates the origin of fetched actor documents, so a host serving a key cannot speak for an actor on another origin. A key without an explicit owner is attributed to the actor whose document carried it.
Public keys cached before this release recorded ownership that had not been verified. Fedify's built-in key cache automatically stops reading those entries. If you pass a custom KeyCache implementation to verifyRequest(), verifyJsonLd(), or verifyObject(), discard its contents when you upgrade. A patched process reading a stale entry from a custom cache would still trust the unverified owner in it.
CVE-2026-96623 affects deployments that accept inbox requests or fetch remote documents. Fedify parsed JSON bodies without a byte limit, including inbox bodies and responses fetched for keys, actors, objects, JSON-LD contexts, WebFinger descriptors, and NodeInfo documents. An attacker who caused Fedify to fetch a URL they controlled could exhaust memory and CPU with a large response. A small compressed response could expand substantially before parsing, so an inbound body limit at a reverse proxy did not protect the outbound fetch paths.
The fix limits JSON bodies to 16 MiB after decompression. HTML alternate-link discovery retains its existing 1 MiB limit. Oversized inbox requests receive HTTP 413, and oversized WebFinger descriptors resolve to null. The JSON limit is fixed in these patch releases; applications that exchange legitimate JSON-LD documents larger than 16 MiB will now have those documents rejected.
The parsing fixes also ship in @fedify/vocab-runtime and @fedify/webfinger. If you depend on either package directly, update it too. They use the same patched version numbers listed above.
CVE-2026-96624 affects applications that deliver activities to inbox URLs learned from remote actors. The delivery path validated neither the advertised inbox URL nor redirect destinations. A remote actor could point its inbox at a loopback address, a link-local metadata service, or a private network host, or redirect delivery there. On the RSA delivery path, the redirected request remained a POST carrying the activity body and was re-signed for the internal host. The vulnerable delivery path dates back to JSR release 0.1.0 and npm release 0.5.0.
The fix validates the initial destination and every redirect target before sending a request. The authenticated document-loader fix in CVE-2026-77632 covered a separate fetch path and did not protect outbound activity delivery.
If you deliberately deliver to private addresses for local testing or a closed federation, these releases will refuse those deliveries unless you pass allowPrivateAddress: true to createFederation(). That option permits both private inbox URLs and private redirect targets, restoring the exposure described here. Keep it to environments where you control the actors you federate with.
Check that your resolved dependency versions are at least 2.0.28, 2.1.24, 2.2.13, or 2.3.8 on the corresponding release line. Update any direct dependencies on @fedify/vocab-runtime and @fedify/webfinger as well, and clear any custom key cache as described above.
After updating, redeploy. If you run other Fedify-based servers, update those too.
Thanks to @kaimandalic and @moreal for independently reporting the actor impersonation issue. Thanks also to @kaimandalic for the unbounded document parsing report and @euriconicacio for the outbound delivery SSRF report, and to all three for responsible disclosure.
If you use an affected Fedify release, update now. Three vulnerabilities have been fixed: CVE-2026-96625, a critical actor impersonation vulnerability; CVE-2026-96623, a high-severity denial-of-service vulnerability in remote document parsing; and CVE-2026-96624, a medium-severity server-side request forgery vulnerability in outbound activity delivery. Treat the actor impersonation issue as an immediate upgrade: anyone on the internet could have an activity accepted by your inbox as coming from any actor.
The patched releases are 2.0.28, 2.1.24, 2.2.13, and 2.3.8. All three vulnerabilities affect the preceding releases on those lines: 2.0.27, 2.1.23, 2.2.12, and 2.3.7, respectively. If you still use Fedify 1.x, change your dependency to a patched 2.x release because package-manager update commands do not cross the declared major-version range.
Actor impersonation (CVE-2026-96625, critical, CVSS 9.1)
CVE-2026-96625 has been present since Fedify's first public release, 0.1.0. Fedify verified the signature on an incoming activity, but trusted the signing key document's own claim about whom the key belonged to. An attacker with an ordinary HTTP server could serve a key document naming any actor as its owner and have activities accepted under that actor's identity, even if the actor did not exist. No account on the receiving server was required. HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs were all affected; the latter two did not require an HTTP signature on the request.
The same vulnerability affected getKeyOwner() and Context.getSignedKeyOwner(). A forged key document could pass ownership checks under another actor's identity, allowing an attacker to read resources that an application's authorized fetch access control reserved for that actor.
The fix resolves the claimed owner's actor document and requires it to link back to the key. It also validates the origin of fetched actor documents, so a host serving a key cannot speak for an actor on another origin. A key without an explicit owner is attributed to the actor whose document carried it.
Public keys cached before this release recorded ownership that had not been verified. Fedify's built-in key cache automatically stops reading those entries. If you pass a custom KeyCache implementation to verifyRequest(), verifyJsonLd(), or verifyObject(), discard its contents when you upgrade. A patched process reading a stale entry from a custom cache would still trust the unverified owner in it.
CVE-2026-96623 affects deployments that accept inbox requests or fetch remote documents. Fedify parsed JSON bodies without a byte limit, including inbox bodies and responses fetched for keys, actors, objects, JSON-LD contexts, WebFinger descriptors, and NodeInfo documents. An attacker who caused Fedify to fetch a URL they controlled could exhaust memory and CPU with a large response. A small compressed response could expand substantially before parsing, so an inbound body limit at a reverse proxy did not protect the outbound fetch paths.
The fix limits JSON bodies to 16 MiB after decompression. HTML alternate-link discovery retains its existing 1 MiB limit. Oversized inbox requests receive HTTP 413, and oversized WebFinger descriptors resolve to null. The JSON limit is fixed in these patch releases; applications that exchange legitimate JSON-LD documents larger than 16 MiB will now have those documents rejected.
The parsing fixes also ship in @fedify/vocab-runtime and @fedify/webfinger. If you depend on either package directly, update it too. They use the same patched version numbers listed above.
CVE-2026-96624 affects applications that deliver activities to inbox URLs learned from remote actors. The delivery path validated neither the advertised inbox URL nor redirect destinations. A remote actor could point its inbox at a loopback address, a link-local metadata service, or a private network host, or redirect delivery there. On the RSA delivery path, the redirected request remained a POST carrying the activity body and was re-signed for the internal host. The vulnerable delivery path dates back to JSR release 0.1.0 and npm release 0.5.0.
The fix validates the initial destination and every redirect target before sending a request. The authenticated document-loader fix in CVE-2026-77632 covered a separate fetch path and did not protect outbound activity delivery.
If you deliberately deliver to private addresses for local testing or a closed federation, these releases will refuse those deliveries unless you pass allowPrivateAddress: true to createFederation(). That option permits both private inbox URLs and private redirect targets, restoring the exposure described here. Keep it to environments where you control the actors you federate with.
Check that your resolved dependency versions are at least 2.0.28, 2.1.24, 2.2.13, or 2.3.8 on the corresponding release line. Update any direct dependencies on @fedify/vocab-runtime and @fedify/webfinger as well, and clear any custom key cache as described above.
After updating, redeploy. If you run other Fedify-based servers, update those too.
Thanks to @kaimandalic and @moreal for independently reporting the actor impersonation issue. Thanks also to @kaimandalic for the unbounded document parsing report and @euriconicacio for the outbound delivery SSRF report, and to all three for responsible disclosure.