The marketing site tells visitors their tokens are encrypted at rest. They are not. resources/views/features/own-your-keys.stx is a public page, and the claim is specific:
Tokens stay where you put them OAuth tokens and app passwords are encrypted with a key your instance generates. Nobody else has a copy, because there is nobody else.
(own-your-keys.stx:31-32, with Tokens encrypted with a key only you hold repeated as a bullet at :44)
This is not a missing feature that happens to be advertised early. It is a security property a reader will take as a guarantee when deciding to put a live credential into the app, and it is the page that exists to make that case.
What is actually stored
social_identities carries access_token, refresh_token and token_expires_at. The model declares no encryption:
// app/Models/SocialIdentity.ts:56
accessToken: {
required: false,
hidden: true,
fillable: true,
validation: { rule: schema.string().max(4000).optional() },
},hidden: true controls API serialisation — whether the column is rendered in a response. It is not encryption, and nothing else supplies any: grep -rn "encrypt\|decrypt\|cipher" across app/Services/Social/, app/Support/Social/ and app/Models/SocialIdentity.ts returns nothing.
Every provider writes its token straight through:
| Provider | Written at |
|---|---|
| Bluesky | BlueskyService.ts:468 — access_token: session.accessJwt |
InstagramService.ts:291 — the long-lived Page token | |
LinkedInService.ts:304,311 — on refresh, with the rotated refresh token | |
| Mastodon | MastodonService.ts:59 — the token the user pastes into the UI |
| Twitter / Threads | same pattern via their own saveSession |
So the SQLite file holds live, replayable credentials for every connected network in plaintext. On the deployment this app recommends — "one Bun process and a SQLite file", often a laptop — that file is also whatever a backup, a sync client or a stray cp picks up.
One part of the claim is better than it reads: the Bluesky app password is not persisted. connect(identifier, password) exchanges it for a session and stores only the JWTs. The sentence promises more than it needs to on that point, and less than it needs to on the rest.
Why this is worth fixing rather than rewording
Self-hosting is the product position ("no broker to run", :48), and encryption-at-rest is the thing that position is sold on. Deleting the claim would weaken the pitch to match the code, when the code is a short distance from matching the claim.
Everything needed already ships:
@stacksjs/securityexportsencrypt(message, customPassphrase?)/decrypt(encrypted, customPassphrase?)(dist/crypt.d.ts), with a bounded derived-key cache.APP_KEYis already configured —config/app.ts:22readsenv.APP_KEY, and.env.example:3seeds it. That is already "a key your instance generates".
Suggested shape
- Encrypt
access_tokenandrefresh_tokenon write and decrypt on read, in the shared identity layer rather than per service, so a new provider cannot forget - Migrate existing rows, and tolerate a plaintext value on read so an upgrade does not sign everybody out
- Decide what happens when
APP_KEYis absent or rotated: refusing to start is defensible, silently writing plaintext is not — that is this bug again - Cover it with a test that reads the column directly and asserts the token is not in it, since a round-trip test passes whether or not encryption is on
- Until it ships, soften the page. A claim about where credentials sit should not be the one thing on the site that is aspirational
Worth noting for whoever picks this up: #40 has a credential-storage section proposing that OAuth app credentials move from .env into the same workspace storage. That makes this issue a prerequisite rather than a neighbour — moving client secrets into a database that does not encrypt would widen exactly this gap.