# Security model

URL: https://appflare.dev/security/

> 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](/start/browser-install/)
  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](/guides/cloudflare-connection/).

## Installing from appflare.dev

An [install from your browser](/start/browser-install/) 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](/start/browser-install/#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](/catalog/bumps/#who-merges-a-bump)), 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](/guides/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](/guides/builds/#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](#installing-from-appflaredev) 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](/guides/appflare-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](/guides/users/).
* **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](/guides/forgot-password/).

Apart from the sign-in and setup pages, the only endpoint that answers without a
session is `/api/health`, also when [Cloudflare Access](#protect-with-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](https://developers.cloudflare.com/cloudflare-one/policies/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](#installing-from-appflaredev)
  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](/guides/cloudflare-connection/#sign-in-with-cloudflare)
  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](/guides/protect-apps/).

Access protects one hostname. When an admin
[moves Appflare to a domain](/guides/appflare-address/), 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):

   ```sh
   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](/guides/notifications/) 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](/telemetry/) 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.
