Appflare

Security model

Signed artifacts, pinned builds, what the manager does with its access to your Cloudflare account, the browser installer's part, sessions, roles, notification credentials, and the optional Cloudflare Access protection.

The manager holds access to your Cloudflare account that can change Workers and data across it. Everything below exists to keep that access safe and to make sure the code it deploys is the code the catalog pinned and built.

Appflare's Cloudflare connection

The manager connects to Cloudflare in one of two ways, shown under Settings > Your account > Cloudflare connection:

  • An API token you created and pasted, stored as an encrypted secret, CF_API_TOKEN, on the manager's own Worker.
  • Cloudflare sign-in, which an install from your browser sets up and which an admin can choose later. Cloudflare issues a refresh token, which the manager stores in its D1 database encrypted with AES-GCM, under a key held in a Worker secret of its own, CF_GRANT_KEY. The database alone does not reveal it. The manager trades it for an access token that lasts an hour whenever it needs one, and stores the new refresh token Cloudflare returns each time. Renewals are serialized, so two requests never spend the same refresh token. Revoked access shows as Needs reconnecting; a renewal that fails for a moment is retried, never taken for a revocation.

Either way, the manager:

  • sends its credential only to Cloudflare (api.cloudflare.com, and dash.cloudflare.com to renew or withdraw a sign-in),
  • never writes a token in plain text to its database or KV,
  • never shows it again in the UI, and never sends it back to the browser,
  • never includes it in logs, job logs, or error messages. Job logs record each API call as METHOD path -> status only, without query strings or bodies,
  • never gives it to an installed app. Apps receive only the secrets and settings you enter for them. An app that needs Cloudflare API access gets its own token, which you create with only the permissions it lists.

The manager never changes the kind of connection by itself. Replacing a token, switching between the two, reconnecting and withdrawing access are described in Appflare's Cloudflare connection.

Installing from appflare.dev

An install from your browser adds one more party for a few minutes: Appflare's hosted installer, which deploys the manager because Cloudflare's API does not accept requests from a web page.

  • It receives the short-lived access token from your Cloudflare sign-in with every request. That token carries every permission you allowed Appflare, and while it is valid (about an hour) the installer could use it for anything those permissions allow. It uses it to deploy, keeps it only for the request, and never logs it. Discarding it does not cancel it at Cloudflare.
  • It never receives the refresh token. Your browser hands that to the new manager, which renews it at once, so the copy the browser held stops working. That is how the published code behaves; an installer running other code could still use the access token for more while it is valid, which is the trust decision below.
  • The manager accepts that handover only with a secret your browser made and kept to itself: the installer deploys the manager with the secret's SHA-256 alone (APPFLARE_HANDOFF). Before handing anything over, the browser has the manager at the chosen address prove it holds that hash. Browsers may send the handover only from appflare.dev, and the manager allows 20 tries per client address in 10 minutes.
  • In return the manager gives that browser a one-time link to the owner step, valid for 30 minutes, stored only as a hash.
  • Until the owner account exists, the installer keeps a record of the installation: account id, name, address, release, what it created, and its progress. No token, no password, no app data. It is deleted when the owner account is created or the installation is removed.

The installer's code is open source, but whether the service on appflare.dev runs exactly that code is something you take on trust. The Deploy to Cloudflare button and create-appflare avoid it. See What the installer sees and keeps.

Signed artifacts

Every app version and every manager release is an artifact with a manifest.json that lists each file with its size and sha256. The manifest is signed with Ed25519.

  • The manager and the installer have the public keys built in, each with a key id so keys can be rotated. They reject unknown key ids and unsigned artifacts.
  • The manager checks that the manifest's sha256 matches the digest in the catalog index, then verifies the signature.
  • It reads each file from the release with a range request and checks its sha256 before uploading it. Nothing is uploaded to your account unless the signature and every hash match.
  • A self-update only accepts a manifest signed with a manager release key.

The private key exists only as a secret in the appflare GitHub organization's Actions. The job that signs holds the key and never runs app code; the jobs that build apps never see the key.

Pinned, credential-free builds

The catalog is the trust root for what gets installed. Each app is built from one exact upstream commit, recorded in its manifest. A change of commit is a pull request that must pass the same checks as a new app, including an install into a CI account. The catalog checks that an upstream release builds, matches its hashes and installs; it does not review upstream code. Most bumps merge themselves once those checks pass (see Version bumps), and you decide whether and when to update: automatic updates are off by default.

Builds install dependencies with --ignore-scripts, so no package install script runs. The build itself is wrangler deploy --dry-run, with every CLOUDFLARE_* and WRANGLER_* variable, GitHub tokens, and the signing key removed from its environment. The only thing a build produces is the artifact.

The manager never builds or runs app code. For prebuilt apps it uploads the files the catalog signed.

Sandbox builds

Some apps have no prebuilt release. If you enable the optional sandbox Worker (Workers Paid), it builds them in a container in your own account; see Sandbox builds. What changes:

  • The catalog still decides what is built. The index pins the exact commit and the sha256 of the entry's catalog manifest. The manager checks both before it asks for a build, and the build checks out only that commit. This also means that whoever controls the catalog index chooses the repository and commit your sandbox Worker builds, without a signature in between: the index and the catalog manifest are trusted the way the catalog's HTTPS site is. What limits this is that a catalog maintainer merges every sandbox tier entry and every change of its pinned commit by hand, and that a build runs with no credentials.
  • The build has no credentials. The container holds no Cloudflare token, no API key and no R2 keys, so the code it runs has nothing to act on your account with. Dependency install scripts do not run. The manager's Cloudflare credential never leaves the manager; it talks to the sandbox Worker only over a service binding, and the sandbox Worker has no public URL.
  • Requests that name the API are refused. The sandbox Worker refuses a build's HTTP and HTTPS requests addressed to api.cloudflare.com. It matches the host name a request is sent to, so this is not a firewall. A build has the internet, and code set on reaching the API can still do so by its IP address or through another server. Such a request carries no credential, and that is what protects your account.
  • The output is unsigned, and marked so. The manager accepts an unsigned manifest.json only through that binding, only when its sha256 matches what the build reported, and only when it names the app, the version and the pinned commit and carries the catalog manifest the catalog published. Each file is checked against it before upload, as for a signed artifact. The app's page says it was built in your account, from which commit, with which container image, and that it is unsigned.
  • Your cost, your confirmation. Each install and update of such an app asks you to confirm what the build costs on Workers Paid before it starts.

The app's build scripts are still third-party code that runs in your account's container with internet access. Catalog CI does not install sandbox tier entries, so their bumps never merge themselves: a catalog maintainer merges each one.

App tokens of self-deploying apps

A self-deploying app ships its own installer, which the sandbox Worker runs to deploy, update and remove the app (see Self-deploying apps). The installer needs to call the Cloudflare API, so it uses a token you create for that app, never the manager's.

  • Where the token lives. The manager stores it as an encrypted secret on the sandbox Worker, APP_TOKEN_<install>, through the Cloudflare API, and each of the app's secret values as APP_SECRET_<install>_<name>. It never stores them in its database or its logs. Between the install form and that API call, the values exist only in the job's Workflow parameters, which Cloudflare Workflows stores encrypted.
  • How it reaches the installer. The manager never sends it over the service binding: a request names only the install. The sandbox Worker reads the token from its own secrets and puts it in the environment of the installer's deploy or destroy command, and of no other command: the checkout, the dependency install and the build still run without credentials. Values the sandbox Worker knows are replaced with [redacted] in the output it keeps and returns.
  • Where the installer runs. In containers of their own, separate from the ones that build apps, because an installer deploys through the Cloudflare API and the sandbox Worker refuses build containers' requests addressed to api.cloudflare.com.
  • What reads the account. After a run, the sandbox Worker lists the app's Workers and their bindings with the same app token. The manager's own connection is used only to store and delete the secrets on the sandbox Worker.
  • What the installer can do is whatever the token allows, for as long as it runs. Create the token with only the permissions the catalog entry lists, and scope it to this account. Like build scripts, the installer is third-party code at a pinned commit that a catalog maintainer merged.
  • When it goes away. Uninstalling deletes the token and the secrets from the sandbox Worker once the installer's destroy command has run. Revoke the token in the dashboard afterwards. Deleting the sandbox Worker also deletes every token it held.
  • Changing them does not cut a run short. Each secret change deploys a new version of the sandbox Worker, which restarts its containers. The manager refuses to change them while another job that runs in the sandbox Worker is queued or running, and a run that was stopped anyway is retried in a fresh container rather than in the one that may still be working.
  • No .env overrides. The sandbox Worker deletes any .env in the app's checkout before the installer runs, so a file in the repository cannot replace the settings, secrets or credentials Appflare hands it. An app's own settings and secrets may not use names reserved for the installer's credentials (CLOUDFLARE_*, ALCHEMY_*) or the shell and tools (PATH, NODE_OPTIONS, BASH_ENV, and so on).

Sign-in and sessions

  • Setup. Until the owner exists, the manager serves only the setup wizard, and its first step asks for a Cloudflare API token. The token is accepted only when it belongs to the account the manager runs in: the manager looks up the version of itself that is serving the request in the token's account. Anyone who can create such a token already controls the account. Saving it gives that browser a short-lived setup claim (an HttpOnly cookie, 30 minutes); only that browser can create the owner, and every other visitor keeps seeing the token step, or the sign-in page once the owner exists. Token checks, each one call that verifies and saves, are limited to 20 per client address in 10 minutes, and refusals carry fixed messages. A manager that cannot identify its own version refuses every token unless it is opened at its workers.dev address. A manager installed from appflare.dev gets its connection from the browser that installed it, and that browser gets the setup claim through its one-time link. Until then, a pasted token works there too.
  • Better Auth. Users sign in with email and password, or with a passkey they added in Settings > Users and sign-in > Your passkeys. Passkeys are bound to the manager's own hostname. There is no public sign-up; admins create users. Session cookies are HttpOnly, Secure, and SameSite=Lax, and only the manager's own URL is a trusted origin. Sign-in attempts are rate limited, with the counters in the manager's D1 database.
  • The address is part of sign-in. Sessions and passkeys belong to the address the manager is opened at: its workers.dev address, or a domain of the account once an admin gives it its own address. After a move, everyone signs in again at the new address, and passkeys added at the old one work only there. The workers.dev address then sends page visits to the new one.
  • Roles. Admins change things; members read everything and change nothing but their own passkeys. One admin, the owner, is the only one who can change roles, delete users, or transfer ownership; Better Auth's own admin endpoints give admins read access only, so they cannot be used to get around that. The server checks the role on every action, not just the UI. See Users and roles.
  • Password recovery. A forgotten password is reset from the sign-in page with an emailed link (only when the owner turned reset emails on) or a one-time recovery code. A code comes from the owner (for any other user), from an admin (for a member), or from npx create-appflare recover, which writes a fingerprint of the code as the Worker secret RECOVERY_CODE_HASH. By design, anyone who can set secrets on the manager's Worker, that is, whoever controls the Cloudflare account, can reset any admin's password, the owner's included. Links and codes work once, for 30 minutes (an account code at most 35 minutes after the version holding it was created, whatever expiry was written). Appflare stores reset link tokens and codes only as SHA-256 hashes, compares codes in constant time, limits code tries to 5 per client address in 10 minutes, deletes the secret once it is used or expired, and signs the user out everywhere. Neither codes nor email addresses are logged. See Forgot your password.

Apart from the sign-in and setup pages, the only endpoint that answers without a session is /api/health, also when Cloudflare Access protection is on. It reports the manager's version, whether its database responds, and whether a newer release exists.

Protect with Cloudflare Access

Settings > Users and sign-in > Cloudflare Access has an optional extra layer for admins: Protect with Cloudflare Access. When it is on, Cloudflare Access asks every visitor to prove they own an admin's email before the manager's own sign-in page loads, and the manager itself checks the Access token on every request and refuses anything without a valid one.

What turning it on does:

  • Creates a self-hosted Access application named "Appflare (<your manager's hostname>)" with one allow policy listing every admin's email, and a second application that keeps /api/health open so health checks keep working.
  • Stores the application's audience tag and your team domain. From then on every request except /api/health must carry a Cf-Access-Jwt-Assertion token signed by your team's keys and issued for that application; otherwise the manager answers with a 403 page that says why. The token is never logged.
  • Leaves two more paths out of the manager's own check. /api/handoff is where the browser that installed Appflare from appflare.dev hands over its connection; it accepts a handover only before the owner account exists. /api/cloudflare/oauth-return is where a Cloudflare sign-in from Settings comes back; it accepts only a sign-in an administrator started there in the last 10 minutes, once. Cloudflare Access itself still checks both at its edge.
  • Keeps the allow list current: adding an admin in Settings > Users and sign-in adds their email, and Re-sync admins rewrites the list after any other change.

Turning it off deletes both applications and stops the checks. The manager's own sign-in keeps protecting it either way.

This protects the manager. Installed apps can be protected too, each on its own, letting in every Appflare user rather than only admins: see Protect apps with Cloudflare Access.

Access protects one hostname. When an admin moves Appflare to a domain, changes it, or goes back to workers.dev, Access moves with the address: both applications then protect the new hostname.

Before you turn it on

  • The account needs a Zero Trust organization. The Free plan covers up to 50 users; the manager links to the dashboard to create one if there is none.
  • Appflare's API token needs Access: Apps and Policies (Edit) and Access: Organizations, Identity Providers, and Groups (Read). The token template requests both; an older token needs replacing first. Connected with Cloudflare sign-in, Appflare has both already. The manager checks both before it changes anything.
  • You must be able to sign in to Access with your own admin email. A new Zero Trust organization often offers only the Cloudflare account login method, which admits members of this Cloudflare account with their Cloudflare login email. To let other emails in, add One-time PIN as a login method in the Zero Trust dashboard first. The confirmation dialog lists the login methods your organization has.

If you are locked out

If Access will not let you in, you see Cloudflare's Access page, not the manager's, and the manager's settings are out of reach. Recover in two steps:

  1. In the Zero Trust dashboard, under Access applications, delete the application named "Appflare (<your manager's hostname>)". Access stops asking for a sign-in.

  2. The manager still requires Access tokens and now answers with its own 403 page. From a terminal where wrangler is signed in to this account, turn the check off by deleting its settings, replacing DATABASE with the name of the manager's D1 database (the DB binding on the manager's Worker):

    npx wrangler d1 execute DATABASE --remote --command "DELETE FROM settings WHERE key LIKE 'access_%'"

The manager picks up the change within about 15 seconds. Delete the leftover application for /api/health in the dashboard as well. Once your Access sign-in works, you can turn the protection on again from Settings > Users and sign-in > Cloudflare Access.

Notification credentials

Notification channels need credentials of their own: a Telegram bot token, a Slack or Discord webhook URL, or a generic webhook's URL and signing secret. The manager encrypts them with AES-GCM before they reach its D1 database, under a key derived from its BETTER_AUTH_SECRET Worker secret, so the database alone does not reveal them. Each ciphertext is bound to its channel, so it cannot be moved to another one. Once saved, a credential is never shown again, not even to admins: the channel list shows only a chat id, a webhook id or a host name, and a webhook's signing secret is shown once, when it is made. Credentials never appear in messages, logs, or error text; an error a service returns is stored with every credential of the channel removed. Messages are sent only to the address the admin entered, with no redirects followed, so a signed body never reaches a host nobody named. If BETTER_AUTH_SECRET is ever replaced, the stored credentials can no longer be decrypted: the channels show Credentials unreadable and send nothing until an admin enters the credentials again.

Anonymous report

Besides the Cloudflare API, the catalog site and releases on GitHub, your own apps for health checks, and the notification channels you add, the manager sends an anonymous report (once a day, and the outcome of each job) to PostHog's EU region, from its Worker and never from your browser. It holds counts, versions and error categories, never your account, emails, domains, secrets or tokens. It is on by default and starts with the first scheduled run after setup. No banner in the app announces it: Settings > Anonymous report and these docs disclose it, and nothing waits for an answer. One switch there turns it off; see Anonymous report for exactly what is sent and every way to turn it off. The installer keeps wrangler's own usage metrics and error reports off for the commands it runs.

On this page