apublished

Security

Last updated: 7 August 2026

apublished holds two things that matter: tokens that can post to your audience, and the posts you have not published yet. This page describes what protects them, in enough detail to be checked rather than believed.

It says what is true today, and where something is not in place it says that too. A security page whose omissions you cannot trust is not worth reading, so the last section is the one to read first if you are evaluating us.


Reporting a vulnerability

Email security@apublished.com. Please include enough detail to reproduce it.

Please do not point automated scanners at the production API — the rate limits will refuse you and the noise makes real reports harder to see. If you want a target to test against, ask and we will arrange one.

We do not run a paid bug bounty. If that changes it will appear on the changelog.


Credentials for your connected accounts

This is the most sensitive thing we store, and it is stored differently from everything else.

Every platform access and refresh token is encrypted with its own key. That key is itself encrypted with a master key that lives in the deployment environment and never in the database, and only the wrapped form is written to a row. Reading the database — a leaked dump, a stolen backup, a query somebody should not have run — yields ciphertext and a wrapped key, not a token.

Two things to be precise about, because the difference matters to anyone assessing this:

The same envelope protects webhook signing secrets, two-factor secrets, and OAuth state during a connect.

Scopes are requested narrowly — the exact permissions we ask each platform for are listed on the platform disclosures page.

Erasing your account revokes the tokens with each platform, not just our copy of them. Disconnecting a single account today deletes our copy; making that revoke at the platform as well is on the list.


Your account

Passwords are hashed with argon2id — 19 MiB of memory, 2 iterations — chosen because memory-hard hashing is expensive to attack in parallel on rented hardware. The minimum is 12 characters and there are no composition rules, because rules produce Password1! and length produces entropy. We never see or store the password, and no code path can recover one. Sign-in does constant work whether or not the account exists, so the response cannot be used to enumerate our customers.

Two-factor authentication is TOTP — any authenticator app. It is not switched on until you have submitted a working code, so nobody can lock themselves out by scanning a QR that did not take, and turning it off requires a current code or a recovery code. Ten recovery codes are issued once, stored only as a keyed hash, and each is consumed atomically so the same code cannot be spent twice.

Between password and second factor there is no session — the interim state is a five-minute signed challenge, not a cookie.

Sessions are cookies that are HttpOnly (JavaScript cannot read them), Secure (never sent over plain HTTP), and SameSite=Lax (not sent on a cross-site request, which is what stops another site acting as you). Only a keyed hash of the session token is stored. Every state-changing form also carries a CSRF token derived from that session.

You can see every active session — device, address, last used — and end any of them, or all the others, immediately. Removing someone from a workspace takes effect on their next request rather than when their session expires.


API keys

A key is shown once, at creation, and never again. We store a keyed hash, not the key: there is nothing in our database that can be turned back into a working credential, which is what makes "we cannot recover your key" a fact rather than a policy.

Account-level actions — keys minted and revoked, accounts connected and disconnected, members added and removed, posts approved and cancelled — are written to an audit log recording who did what and when. Today you read it through the data export; a screen for it is not built yet.


Isolation between customers

Separation is enforced in the database itself, by PostgreSQL row-level security, not only in application code.

Policies are ENABLE and FORCE, so they apply to the table owner too. The request path connects as a role that cannot bypass them, and the app refuses to start if that role turns out to hold the bypass privilege. The policy is written to fail closed: a query arriving without a tenant context returns nothing rather than everything.

Above that, the database client injects the tenant into every query independently and refuses to run at all outside a tenant scope — two mechanisms, either of which would be sufficient, deliberately not sharing an assumption. A caller-supplied tenant id on a write is overwritten rather than trusted.

A separate role with broader access exists for the parts that are inherently cross-customer — the scheduler claiming work, the reaper, the sweepers — and it is not the role that serves your requests.

There is a regression suite whose entire job is to try to read another customer's rows through every route we expose.


Payments

No card number ever reaches apublished. Checkout and every change of card happen on Stripe's own pages; we hold a customer reference and which plan you are on. This is why we are out of scope for PCI DSS rather than merely compliant with it. Inbound billing events from Stripe are signature-verified before they are applied.


The application

Server-side media fetches are DNS-pinned. When you hand us a URL, the address we validate is the address we connect to — there is no second lookup for an attacker to answer differently. Private, loopback, link-local and carrier-NAT addresses are refused in both IPv4 and IPv6, only http and https are allowed, and redirects are refused rather than followed.

Outgoing webhooks are signed with a per-endpoint secret — a timestamp and an HMAC-SHA256, so you can verify a delivery came from us and reject a replayed one. The destination is checked against the same private-address rules at delivery time, not only when you register it, and redirects are not followed. Unlike the media fetcher it connects by hostname, so it is not DNS-pinned.

Rate limits apply per key, separately for requests and for writes, so reading a calendar cannot exhaust the budget that publishes. Sign-in, two-factor and email endpoints are limited on both the address and the client's IP. Refusals carry Retry-After and the reset time. These limiters fail open: if the store behind them is unwell the request is allowed through, because a limiter that becomes the outage it exists to prevent is worse than none. That is a deliberate trade and you should know we made it.


Infrastructure

The application and its workers run on Fly.io with managed PostgreSQL; media sits in Cloudflare R2. Traffic between our own processes travels over an encrypted private network, and all public traffic is HTTPS.

Every third party that processes data on our behalf is listed on the subprocessors page, which is the notice channel for changes to that list.


Deleting your data

You can export everything and erase the account from the console. Erasure revokes the tokens at each platform, deletes the media objects, and then removes every row belonging to the workspace. It is immediate and irreversible — there is no grace period, deliberately, because a deletion that quietly is not one is a worse answer to somebody who asked. Credentials appear in the export by name only; the export never contains a token.

Meta's data-deletion callback is implemented and signature-verified.


What we do not claim

The honest part of a security page is the boundary.


Questions about any of this: security@apublished.com.