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.
- We acknowledge within two working days.
- We will tell you what we found and when it is fixed.
- We will not take legal action against anyone who reports a vulnerability in good faith, stays within their own account's data, and gives us reasonable time to fix it before publishing.
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.
- AES-256-GCM, which authenticates as well as encrypts: a modified ciphertext fails to open rather than opening as something else.
- The row and column are bound into the encryption as associated data, so a credential cannot be moved from one account to another and still decrypt.
- The data key is zeroed in memory after use.
- Master keys are versioned, and several versions can be loaded at once, so a key can be replaced while existing rows are re-wrapped. Re-wrapping does not decrypt the token itself.
Two things to be precise about, because the difference matters to anyone assessing this:
- The master key is an environment secret, not a hosted KMS key. The code is written against a key-management interface so that moving to a hosted KMS is a swap rather than a rewrite, but today the key is held by the process.
- Rotation is supported, not scheduled. The mechanism exists and works; there is no job running it on a timer yet.
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.
- Scoped. A key carries only the permissions you gave it. A key that can write but not publish gets the approval workflow automatically — it drafts, and a person confirms.
- Restricted to accounts. A key can be limited to particular connected accounts and is refused on the others.
- Test mode. A test key can only reach the simulator. It cannot publish to a real audience even if it holds the permission to.
- Revocation is immediate, across every server process, rather than eventually.
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
- Content-Security-Policy with
script-src 'self'and nounsafe-inline. The console has no inline event handlers at all — that is what this header costs and we pay it, because an injected script that can run inline does not care what else the policy says. Image sources are named individually; there is no blanket allowance. - HSTS (two years, including subdomains),
X-Frame-Options: DENYandframe-ancestors 'none', so no page can be framed — the confirm page a person is sent a link to would otherwise be a clickjacking target. - **
Referrer-Policy: strict-origin-when-cross-origin**, so a reset or confirm link never lands in somebody else's access log. Plusnosniff, a same-origin opener policy, and aPermissions-Policydenying camera, microphone, geolocation and payment. - CORS allows any origin on
/v1and never allows credentials — which is the only reason the wildcard is safe, since an API key travels in a header a browser will not attach on its own. The console gets no CORS headers at all. - Redirects are validated. Any "return here afterwards" parameter is parsed and refused unless it is a path on this site.
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.
- We hold no certifications. No SOC 2, no ISO 27001. apublished is new and small; claiming an audit we have not had would be the first thing on this page you could not check.
- No third-party penetration test has been performed. When one has, it will be on the changelog.
- We do not yet publish a backup and restore commitment. Until we can state a retention period and say we have tested a restore, there is nothing here worth promising.
- We cannot protect a post after it is published. Once content reaches a platform it is governed by that platform, and deleting it here asks them to remove it rather than guaranteeing they have.
- A platform token is only as narrow as the platform allows. Some do not offer a permission as specific as we would like; where that is true it is written down in the platform disclosures.
- Nothing here substitutes for your own care with keys. A key pasted into a public repository is a key that works. Scope them narrowly, give each agent its own, and revoke the moment one is loose — revocation takes effect immediately.
Questions about any of this: security@apublished.com.