# content
littleFedi and your data

Several people have asked me the same thing over the past few days, in different words but with the same question underneath: "if I set up a littleFedi instance, or open an account on one that is already running, what ends up stored somewhere?".

It is a fair question and it deserves a precise answer. So I went back and reread the code, the database schema and the default configuration, and what follows is what I found. Including the parts I do not like, and the parts no fediverse software can solve.

One premise that applies to everything else: littleFedi is a binary running on a server. Whoever administers that server has access to the database. This is true for littleFedi, for Mastodon, for snac, for GoToSocial, for anything. The difference is how much that database holds, and how much of it leaves for the outside world without anyone asking.

The database holds the obvious things: username, display name, bio, avatar, header image, the key pair that is the account's ActivityPub identity, posts, favourites, follows, blocks, mutes, bookmarks, lists, filters.

Then the preferences: theme, interface language, default post language, default visibility, which notifications you want by email, which by push. Functional stuff.

Email is optional. require_email is false by default: on a single-user instance you can leave it out entirely, and on a multi-user one it is the operator who decides whether to require it. If you do provide it, it is used for verification, password reset and the notifications you chose. Nothing else.

The password is hashed with bcrypt. The TOTP secret, if you enable the second factor, is encrypted at rest, not stored in the clear.

And here is something I want to say because it was a deliberate choice: every bearer token is stored as a SHA-256 hash, never in the clear. Session tokens, OAuth access and refresh tokens, application client secrets, password reset tokens, email verification tokens. Anyone who walks off with a copy of the database does not walk off with reusable credentials.

We did a cleanup on exactly this just yesterday. The sessions table had been carrying a legacy token compatibility column for a long time, inherited from an older schema. It held nothing dangerous, because the function that creates sessions was writing the hash into it rather than the browser token, and I checked that this is the only path that leads to an insert. But the guarantee lived in one line of Go, not in the schema. A line of Go can be removed by accident; a column that does not exist cannot. So I dropped the column with a migration, and now there is simply nowhere in the sessions table for a token in the clear to end up.

There is exactly one place where an IP address ends up

The sessions table. When you log in, littleFedi saves the session row with: hashed token, creation date, expiry, User-Agent and IP address.

It serves one purpose: the Settings > Sessions page, where you can see your active logins and revoke them one at a time. It is your data, shown to you. There is no admin screen listing users' IP addresses, and we did not write one on purpose.

Sessions last 30 days and are removed by automatic maintenance when they expire, along with the push subscriptions attached to them.

What is not saved, and what other software does save:

No registration IP. Until yesterday the registration struct had a field for the IP and one for the User-Agent that nobody ever read: the data arrived inside the function and died there, without touching the database. We took them out. I did ask myself whether it was worth actually recording it, the way Mastodon does, and the answer I arrived at is no. There is really only one use case, the wave of fake accounts created in bulk, and in that case you already have the data: a freshly registered account that is spamming has an open session, and that session holds the IP and the User-Agent. Someone who registers and never logs in is not spamming. On top of that the IP is a weak signal in itself, between CGNAT, mobile carriers and VPNs, while invites, manual approval and email verification are real defences. And unlike the session, which expires in 30 days and disappears, a column like that would stay attached to the account forever: one more entry in the privacy policy, one more target in a breach, and one more thing someone can come asking for with a stamped piece of paper. On a single-user instance it would mean nothing at all.

No last-login IP on the account. Only the session, which expires.

No IP history. Close the session and that row is gone.

The per-IP rate limiter does exist, but it keeps its counters in memory only and cleans them up by itself. It never touches the disk.

What about cookies?

Five, all first-party, all functional:

session (HttpOnly, SameSite=Lax, Secure when you are behind HTTPS, 30 days), pwreset_token (lives 15 minutes during a password reset), locale, theme, appearance.

There is nothing else. There is no analytics cookie because there is no analytics.

Zero third parties

What the code does not contain: Google Analytics, Matomo, Plausible, Sentry, a CDN, a remote font, a third-party script, a pixel, a beacon. And, old-fashioned as it may sound, there is no AI listening in and offering suggestions.

The default Content-Security-Policy is default-src 'self' with a nonce for inline content. Pages load only what your own instance serves. If someone tried to slip an external resource in tomorrow, the browser would block it on its own.

There is not even an update check phoning home. littleFedi does not know you exist and I have no way of finding out.

The only connection to a server of mine is the commercial Big Tech domain list, still empty, (https://littlefedi.org/lists/commercial-bigtech.csv), which is downloaded only if you turn that block on, and it is off by default. It is a download of a CSV of domain names: it sends nothing about you, and you can point the same option at your own list or at a mirror.

The /metrics endpoint is disabled by default.

What about logs?

Here I owe you the whole truth. The access log is on by default and writes one line per request: method, path, status, bytes, duration and remote address.

High-volume, low-value requests (static assets, proxied media, /health, /metrics, /robots.txt, the service worker) are logged at debug level only, so at the default info level they stay silent. But pages are logged.

It goes away with one line:

[observability]
access_log = false

And retention is not littleFedi's call: those lines end up wherever your init system sends them, journald or syslog or anything else. It is the operator who decides how long they stay. In the policy template I ship I suggest 14 days as a starting point.

As for media?

EXIF metadata on uploaded images is stripped by default (strip_exif = true). Orientation is preserved because it is baked into the pixels before the rewrite, so the photo does not end up sideways.

With one honest caveat: the rewrite only happens for JPEG and PNG. GIFs are deliberately left untouched, because rewriting them would flatten the animation, and WebP files are not rewritten because the library I use has no WebP encoder. In practice: if you upload a WebP with GPS coordinates inside, those coordinates stay. I am saying it plainly because it is better to know than to find out. We will look at it later on.

File names are randomised (rename_media = true), so IMG_20250812_my_coffee.jpg does not become a public URL.

And then there is the thing I am proudest of, which is on by default: the remote media proxy.

proxy_remote = true. When an image, an avatar or an emoji living on another server shows up in your timeline, your browser does not contact that server. It contacts your instance, which streams it through. The result is that the remote instance does not see your IP address, does not see your User-Agent, does not see what time you read that post, and cannot use an image as a tracking pixel.

Proxy URLs are HMAC-signed and regenerated on every response, never stored anywhere. And cache_remote is off by default: the stream is passed through and that is it, without keeping other people's media on your disk. If you would rather keep them to cut down on traffic there are the lazy and eager modes, but that is your choice, not a default you find yourself saddled with.

I will add the security headers, which are privacy too: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin (meaning: when you click a link to the outside, the destination site does not see which exact page you came from), HSTS when you are behind TLS.

But there is also the part no software can fix.

Which is to say, it is time for the uncomfortable section, and it applies to littleFedi just as it does to any other ActivityPub implementation. If someone tells you otherwise, they are selling you something that does not exist.

Public posts are public. When you publish, a copy is delivered to every server that has at least one of your followers, and to every relay your instance is connected to. Those copies are on machines you do not administer. There is no way to call them back.

Direct messages are not end-to-end encrypted. In the fediverse a DM is a post with direct visibility. It sits in the clear in your instance's database and in the clear in the recipient's instance database. Whoever administers either server can read it. Do not use fediverse DMs for anything you would not put on a postcard. This applies to littleFedi, it applies to Mastodon, it applies to everyone. We are thinking about a way to encrypt DMs between littleFedi instances, but that could not apply with other software, and it risks giving a false sense of security.

Followers-only is a convention, not a lock. You tell the remote server "this is for followers only". The remote server, if it is honest, respects that. If it is not, no amount of encryption is going to stop it.

Deleting means asking to delete. littleFedi sends Delete activities to peers and has a queue with retries. If a peer is offline, or uncooperative, that copy stays where it is. That is a limitation of the protocol, not a bug.

And what if I delete my account?

I wrote this part carefully, because it is where a lot of software pretends.

When an account is deleted, a single transaction removes: posts and boosts, polls, options and votes, notifications (both received and generated), mentions, favourites, emoji reactions, bookmarks, link previews, home feed rows, attachments, filters and filter keywords, scheduled posts, push subscriptions, OAuth tokens, sessions, lists, followed tags, featured tags, blocks and mutes (in both directions), follows (in both directions), relays, read markers, personal domain blocks, password resets, email verifications, digest state, MFA recovery codes, exports, imports, reports, generated invites, and finally the account row itself. Counters on other accounts and other posts are recalculated, not left out of sync.

The actual files, meaning media, export archives and import archives, are deleted from storage, whether that is a local disk or S3.

What stays, and I am saying so explicitly:

Tombstones. A row with a URI, the original type and a date. They exist so that deleted content is not resurrected when a peer offers it back to us. The ones for posts have a configurable retention, 90 days by default. The one for a deleted actor stays indefinitely, but it holds nothing but URI, type and date.

The moderation log. Administrator actions stay, because an audit log you can delete is not an audit log. The template suggests 2 years.

Deletion has a window: peers are notified first, then it is finalised. There is a deadline past which it is finalised anyway, because at that point the right to erasure of someone who is here outweighs an unreachable peer.

But you can take your data with you.

There is the account export: a ZIP with account.json, the media index and the media themselves. It is served with private, no-store, it has an expiry, and a cleanup job removes it from storage. The template suggests 24 hours and a single download.

There is the import, which accepts the same format. And there is migration to another instance. The archive uploaded for an import is deleted as soon as the import succeeds.

What if you use littleMesh?

Anyone running an instance behind NAT with littleMesh has one more surface, and I want to be precise here too, because it is already written in the documentation.

The lighthouse relays encrypted circuits. It does not read the contents, it cannot impersonate a node, and it has neither the mesh key nor the actor key. But it does see which node IDs open circuits to which, with what sizes and what timings. littleMesh solves reachability, not anonymity. If anonymity is what you need, an onion service is the right tool, not this one.

The HTTPS gateway is a different matter: it is the one terminating TLS for non-mesh servers, so it sees the traffic. That is written in the trust table in the documentation, in plain terms, along with what it can and cannot do.

The default exposure is federation: discovery, inboxes, objects, actors and public media get through. The web interface, the authentication pages, the Mastodon API and the media proxy do not.

Retention defaults, all in one place

What | Default
Sessions | 30 days, then removed
Remote posts | 30 days (prune_remote_statuses_after_days = 30)
Remote media cache | disabled; if enabled, 720 hours
Terminal jobs | 24 hours
Post tombstones | 90 days
Your own posts | never, unless you ask
Moderation log | indefinitely
Access log | your system keeps them, not littleFedi

You can have your own posts pruned automatically, by age and with like and boost thresholds, if you want a timeline that forgets. And there are self-destructing posts with a per-post timer. But those are things you choose, not things I decide for you.

There is something the software cannot do in your place, though

If you open an instance to other people, the code cannot write your privacy policy for you. It cannot decide the lawful basis, the jurisdiction, the subprocessors, the policy on minors, the breach notification timelines.

That is what docs/instance-policy-template.md is for: an operator checklist with a starting retention table and a list of what an honest privacy policy has to cover, namely federation of profiles and posts to independent servers, caching and proxying of remote content, email, push endpoints, logs, moderation records, backups, exports, imports, tombstones, where the data lives and how people exercise their rights. It is not legal advice and does not claim to be.

Moderation, privacy and security contacts, the rules and the terms of service are published from the admin console without restarting anything, and they land on /about, on /terms and in the instance APIs.

In two lines...

...littleFedi collects what it needs to work and nothing more. One IP address per session, shown to you and revocable by you. No analytics, no telemetry, no third parties, no phoning home. We do not know how many of you there are or who you are, and we have no interest in finding out. EXIF stripped by default, remote media proxied by default, metrics off by default, secrets hashed at rest, deletion that actually deletes.

And then there is the fediverse, which is a publishing protocol. What you put on it in public is public, DMs are not encrypted, and deleting is a polite request to other servers. littleFedi does not change that and neither does anyone else. Once you know it, you can live with it perfectly well.

#littleFedi #littleOne #Fediverse #OwnYourData

# source.content
# littleFedi and your data

Several people have asked me the same thing over the past few days, in different words but with the same question underneath: "if I set up a littleFedi instance, or open an account on one that is already running, what ends up stored somewhere?".

It is a fair question and it deserves a precise answer. So I went back and reread the code, the database schema and the default configuration, and what follows is what I found. Including the parts I do not like, and the parts no fediverse software can solve.

One premise that applies to everything else: littleFedi is a binary running on a server. Whoever administers that server has access to the database. This is true for littleFedi, for Mastodon, for snac, for GoToSocial, for anything. The difference is how much that database holds, and how much of it leaves for the outside world without anyone asking.

The database holds the obvious things: username, display name, bio, avatar, header image, the key pair that is the account's ActivityPub identity, posts, favourites, follows, blocks, mutes, bookmarks, lists, filters.

Then the preferences: theme, interface language, default post language, default visibility, which notifications you want by email, which by push. Functional stuff.

Email is optional. `require_email` is `false` by default: on a single-user instance you can leave it out entirely, and on a multi-user one it is the operator who decides whether to require it. If you do provide it, it is used for verification, password reset and the notifications you chose. Nothing else.

The password is hashed with bcrypt. The TOTP secret, if you enable the second factor, is encrypted at rest, not stored in the clear.

And here is something I want to say because it was a deliberate choice: **every bearer token is stored as a SHA-256 hash, never in the clear.** Session tokens, OAuth access and refresh tokens, application client secrets, password reset tokens, email verification tokens. Anyone who walks off with a copy of the database does not walk off with reusable credentials.

We did a cleanup on exactly this just yesterday. The sessions table had been carrying a legacy `token` compatibility column for a long time, inherited from an older schema. It held nothing dangerous, because the function that creates sessions was writing the hash into it rather than the browser token, and I checked that this is the only path that leads to an insert. But the guarantee lived in one line of Go, not in the schema. A line of Go can be removed by accident; a column that does not exist cannot. So I dropped the column with a migration, and now there is simply nowhere in the `sessions` table for a token in the clear to end up.

*There is exactly one place where an IP address ends up*

The `sessions` table. When you log in, littleFedi saves the session row with: hashed token, creation date, expiry, User-Agent and IP address.

It serves one purpose: the **Settings > Sessions** page, where you can see your active logins and revoke them one at a time. It is your data, shown to you. There is no admin screen listing users' IP addresses, and we did not write one on purpose.

Sessions last 30 days and are removed by automatic maintenance when they expire, along with the push subscriptions attached to them.

What is **not** saved, and what other software does save:

- **No registration IP.** Until yesterday the registration struct had a field for the IP and one for the User-Agent that nobody ever read: the data arrived inside the function and died there, without touching the database. We took them out. I did ask myself whether it was worth actually recording it, the way Mastodon does, and the answer I arrived at is no. There is really only one use case, the wave of fake accounts created in bulk, and in that case you already have the data: a freshly registered account that is spamming has an open session, and that session holds the IP and the User-Agent. Someone who registers and never logs in is not spamming. On top of that the IP is a weak signal in itself, between CGNAT, mobile carriers and VPNs, while invites, manual approval and email verification are real defences. And unlike the session, which expires in 30 days and disappears, a column like that would stay attached to the account forever: one more entry in the privacy policy, one more target in a breach, and one more thing someone can come asking for with a stamped piece of paper. On a single-user instance it would mean nothing at all.
- **No last-login IP** on the account. Only the session, which expires.
- **No IP history.** Close the session and that row is gone.
- The per-IP rate limiter does exist, but it keeps its counters **in memory only** and cleans them up by itself. It never touches the disk.

What about cookies?

*Five*, all first-party, all functional:

`session` (HttpOnly, SameSite=Lax, Secure when you are behind HTTPS, 30 days), `pwreset_token` (lives 15 minutes during a password reset), `locale`, `theme`, `appearance`.

There is nothing else. There is no analytics cookie because there is no analytics.

**Zero third parties**

What the code does not contain: Google Analytics, Matomo, Plausible, Sentry, a CDN, a remote font, a third-party script, a pixel, a beacon. And, old-fashioned as it may sound, there is no AI listening in and offering suggestions.

The default Content-Security-Policy is `default-src 'self'` with a nonce for inline content. Pages load only what your own instance serves. If someone tried to slip an external resource in tomorrow, the browser would block it on its own.

There is not even an update check phoning home. littleFedi does not know you exist and I have no way of finding out.

The only connection to a server of mine is the commercial Big Tech domain list, still empty, (`https://littlefedi.org/lists/commercial-bigtech.csv`), which is downloaded **only if you turn that block on**, and it is off by default. It is a download of a CSV of domain names: it sends nothing about you, and you can point the same option at your own list or at a mirror.

The `/metrics` endpoint is disabled by default.

What about logs?

Here I owe you the whole truth. The access log is **on** by default and writes one line per request: method, path, status, bytes, duration and remote address.

High-volume, low-value requests (static assets, proxied media, `/health`, `/metrics`, `/robots.txt`, the service worker) are logged at debug level only, so at the default `info` level they stay silent. But pages are logged.

It goes away with one line:

```
[observability]
access_log = false
```

And retention is not littleFedi's call: those lines end up wherever your init system sends them, journald or syslog or anything else. It is the operator who decides how long they stay. In the policy template I ship I suggest 14 days as a starting point.

As for media?

EXIF metadata on uploaded images is **stripped by default** (`strip_exif = true`). Orientation is preserved because it is baked into the pixels before the rewrite, so the photo does not end up sideways.

With one honest caveat: the rewrite only happens for JPEG and PNG. GIFs are deliberately left untouched, because rewriting them would flatten the animation, and WebP files are not rewritten because the library I use has no WebP encoder. In practice: if you upload a WebP with GPS coordinates inside, those coordinates stay. I am saying it plainly because it is better to know than to find out. We will look at it later on.

File names are randomised (`rename_media = true`), so `IMG_20250812_my_coffee.jpg` does not become a public URL.

And then there is the thing I am proudest of, which is on by default: **the remote media proxy.**

`proxy_remote = true`. When an image, an avatar or an emoji living on another server shows up in your timeline, your browser does **not** contact that server. It contacts your instance, which streams it through. The result is that the remote instance does not see your IP address, does not see your User-Agent, does not see what time you read that post, and cannot use an image as a tracking pixel.

Proxy URLs are HMAC-signed and regenerated on every response, never stored anywhere. And `cache_remote` is `off` by default: the stream is passed through and that is it, without keeping other people's media on your disk. If you would rather keep them to cut down on traffic there are the `lazy` and `eager` modes, but that is your choice, not a default you find yourself saddled with.

I will add the security headers, which are privacy too: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin` (meaning: when you click a link to the outside, the destination site does not see which exact page you came from), HSTS when you are behind TLS.

But there is also the part no software can fix.

Which is to say, it is time for the uncomfortable section, and it applies to littleFedi just as it does to any other ActivityPub implementation. If someone tells you otherwise, they are selling you something that does not exist.

**Public posts are public.** When you publish, a copy is delivered to every server that has at least one of your followers, and to every relay your instance is connected to. Those copies are on machines you do not administer. There is no way to call them back.

**Direct messages are not end-to-end encrypted.** In the fediverse a DM is a post with `direct` visibility. It sits in the clear in your instance's database and in the clear in the recipient's instance database. Whoever administers either server can read it. Do not use fediverse DMs for anything you would not put on a postcard. This applies to littleFedi, it applies to Mastodon, it applies to everyone. We are thinking about a way to encrypt DMs between littleFedi instances, but that could not apply with other software, and it risks giving a false sense of security.

**Followers-only is a convention, not a lock.** You tell the remote server "this is for followers only". The remote server, if it is honest, respects that. If it is not, no amount of encryption is going to stop it.

**Deleting means asking to delete.** littleFedi sends `Delete` activities to peers and has a queue with retries. If a peer is offline, or uncooperative, that copy stays where it is. That is a limitation of the protocol, not a bug.

And what if I delete my account?

I wrote this part carefully, because it is where a lot of software pretends.

When an account is deleted, a single transaction removes: posts and boosts, polls, options and votes, notifications (both received and generated), mentions, favourites, emoji reactions, bookmarks, link previews, home feed rows, attachments, filters and filter keywords, scheduled posts, push subscriptions, OAuth tokens, sessions, lists, followed tags, featured tags, blocks and mutes (in both directions), follows (in both directions), relays, read markers, personal domain blocks, password resets, email verifications, digest state, MFA recovery codes, exports, imports, reports, generated invites, and finally the account row itself. Counters on other accounts and other posts are recalculated, not left out of sync.

The actual files, meaning media, export archives and import archives, are deleted from storage, whether that is a local disk or S3.

What **stays**, and I am saying so explicitly:

- **Tombstones.** A row with a URI, the original type and a date. They exist so that deleted content is not resurrected when a peer offers it back to us. The ones for posts have a configurable retention, 90 days by default. The one for a deleted actor stays indefinitely, but it holds nothing but URI, type and date.
- **The moderation log.** Administrator actions stay, because an audit log you can delete is not an audit log. The template suggests 2 years.

Deletion has a window: peers are notified first, then it is finalised. There is a deadline past which it is finalised anyway, because at that point the right to erasure of someone who is here outweighs an unreachable peer.

But you can take your data with you.

There is the account export: a ZIP with `account.json`, the media index and the media themselves. It is served with `private, no-store`, it has an expiry, and a cleanup job removes it from storage. The template suggests 24 hours and a single download.

There is the import, which accepts the same format. And there is migration to another instance. The archive uploaded for an import is deleted as soon as the import succeeds.

What if you use littleMesh?

Anyone running an instance behind NAT with littleMesh has one more surface, and I want to be precise here too, because it is already written in the documentation.

The **lighthouse** relays encrypted circuits. It does not read the contents, it cannot impersonate a node, and it has neither the mesh key nor the actor key. But it does see which node IDs open circuits to which, with what sizes and what timings. **littleMesh solves reachability, not anonymity.** If anonymity is what you need, an onion service is the right tool, not this one.

The **HTTPS gateway** is a different matter: it is the one terminating TLS for non-mesh servers, so it sees the traffic. That is written in the trust table in the documentation, in plain terms, along with what it can and cannot do.

The default exposure is `federation`: discovery, inboxes, objects, actors and public media get through. The web interface, the authentication pages, the Mastodon API and the media proxy do not.

## Retention defaults, all in one place

| What | Default |
| --- | --- |
| Sessions | 30 days, then removed |
| Remote posts | 30 days (`prune_remote_statuses_after_days = 30`) |
| Remote media cache | disabled; if enabled, 720 hours |
| Terminal jobs | 24 hours |
| Post tombstones | 90 days |
| Your own posts | never, unless you ask |
| Moderation log | indefinitely |
| Access log | your system keeps them, not littleFedi |

You can have your own posts pruned automatically, by age and with like and boost thresholds, if you want a timeline that forgets. And there are self-destructing posts with a per-post timer. But those are things you choose, not things I decide for you.

There is something the software cannot do in your place, though

If you open an instance to other people, the code cannot write your privacy policy for you. It cannot decide the lawful basis, the jurisdiction, the subprocessors, the policy on minors, the breach notification timelines.

That is what `docs/instance-policy-template.md` is for: an operator checklist with a starting retention table and a list of what an honest privacy policy has to cover, namely federation of profiles and posts to independent servers, caching and proxying of remote content, email, push endpoints, logs, moderation records, backups, exports, imports, tombstones, where the data lives and how people exercise their rights. It is not legal advice and does not claim to be.

Moderation, privacy and security contacts, the rules and the terms of service are published from the admin console without restarting anything, and they land on `/about`, on `/terms` and in the instance APIs.

In two lines...

...littleFedi collects what it needs to work and nothing more. One IP address per session, shown to you and revocable by you. No analytics, no telemetry, no third parties, no phoning home. We do not know how many of you there are or who you are, and we have no interest in finding out. EXIF stripped by default, remote media proxied by default, metrics off by default, secrets hashed at rest, deletion that actually deletes.

And then there is the fediverse, which is a publishing protocol. What you put on it in public is public, DMs are not encrypted, and deleting is a polite request to other servers. littleFedi does not change that and neither does anyone else. Once you know it, you can live with it perfectly well.

#littleFedi #littleOne #Fediverse #OwnYourData