Skip to content

Security

Your app is reachable only through our edge, which signs people in, checks who can use the app, and records every request. This page covers what the edge does, what it leaves to your code, and how to report a problem.

Your app has no address of its own. A JavaScript app runs as a Worker with no routes. A container app has no public ingress. Every request for every app goes through the edge first.

Apps answer at https://<app>--<workspace>.forculuslabs-apps.tech, for people in a browser. Scripts and machines call https://forculuslabs-apps.tech/<workspace>/<app>/ with a bypass token. Both are the edge.

For each request, the edge does these in order, and denies the request at the first step that fails:

  1. Works out who is calling: the app host’s session cookie, or a bearer token.
  2. Refuses a write sent from another site, or another site opening the app in a frame.
  3. Asks whether that caller can use this app.
  4. Deletes every identity header the client sent.
  5. Signs a fresh identity token for your app.
  6. Records the request in the audit log.
  7. Passes the request to your app.

A denied request is recorded too, and doesn’t reach your app.

A person signs in once, on the dashboard. The first time they open an app, the edge sends them there and back, and sets a session cookie for that app’s host alone.

The cookie is __Host-forculus_session: HttpOnly, SameSite=Lax, Secure, and valid for one host. It lasts as long as the dashboard session behind it, 12 hours at most. The edge checks its signature, issuer, audience and expiry on every request.

Your app never sees that cookie. The edge takes it out of the Cookie header before passing the request on, and drops any Set-Cookie from your app that uses one of our cookie names.

A POST, PUT, PATCH, DELETE or WebSocket that a browser sends your app from another site gets 403, even with a valid session. So does a page on another site that opens your app in a frame. Apps on Forculus share a parent domain, and these checks stop a page on one app from acting on another app as its visitor.

For every request it lets through, the edge signs a new token for your app and sends it in X-Forculus-Identity. It is an RS256 JWT, made out to your app alone, and it lasts 60 seconds.

Your app has to verify it. Check the signature against the key set at https://edge.forculuslabs.tech/.well-known/jwks.json, then iss, aud and exp. The SDKs do all of it in one call, and refuse the request if anything fails, including a key set they couldn’t fetch.

Before the edge adds its own headers, it deletes every header the client sent whose name starts with X-Forculus-, Cf-Access- or Cf-Container-, with dashes or underscores. It then sets two:

  • X-Forculus-Identity, the signed token
  • X-Forculus-Email, the caller’s email, for display only

The caller’s IP address is in X-Forwarded-For, and it’s the only address there. The edge writes it from the address Cloudflare saw the request come from, and writes X-Forwarded-Proto too. It removes whatever the client sent in those, and in Forwarded, X-Forwarded-Host, X-Real-IP, True-Client-IP and the other headers a proxy uses to say who called, so a client can’t pick the address your app logs or rate-limits.

A webhook delivery on a verified public path gets only X-Forculus-Identity. Its token is of kind webhook and made out to webhook:<app id>, so a check for a person refuses it. See Webhooks and public paths.

A header on its own proves nothing. Authorize on the verified token, not on X-Forculus-Email. The SDKs read only the token.

  • Taking someone’s access away ends it within 60 seconds. From their first refused request on, every request they send through that Cloudflare location is refused too.
  • Signing out of the dashboard ends that browser’s sessions on every app host within 60 seconds.
  • A revoked bypass token stops working on its next request. The edge checks bearer tokens with the control plane on every request, without caching.
  • A bypass token can’t do more than the person who made it can do today. Once they lose access to its app, because they were removed from the workspace, taken off the app or deleted their account, the token is refused on its next request and marked revoked. While they’re only a viewer, it’s a viewer’s token. See When its maker loses access.

Open connections end too. The edge keeps checking access while a WebSocket, a streamed response or a long download is open. It closes the connection within 60 seconds of access being taken away, and when the session behind it ends. A connection opened with a bypass token or a CLI session is checked every 10 seconds instead, and closes within 15 seconds. A slow app isn’t cut off for being slow. If its answer starts after the last check has expired, the edge checks again before sending it, and sends it only if access still holds.

When the edge can’t decide, it refuses:

  • If it can’t reach the control plane to check access, it answers 503.
  • If it can’t get a clear yes on a webhook delivery’s signature, it answers 503, which the provider sees as a failed delivery to retry. Only an explicit allow reaches your app.
  • If it can’t record an allowed request in the audit log, it answers 503 and your app never sees the request.
  • A request with two session cookies, or two bearer tokens, gets 401. The edge doesn’t guess which one was meant.

The SDKs do the same in your app. Missing variables or an unreachable key set mean every request is refused.

  • Set-Cookie headers lose their Domain attribute, so your app can’t set a cookie for other apps on Forculus.
  • Every response gets Cache-Control: private, and headers that tell a CDN to cache it are removed. Only the caller’s own browser may keep a copy, for as long as your app’s max-age says.
  • Every response also gets no-transform in Cache-Control. It asks Cloudflare’s features, and any proxy that follows the standard, to pass the body on as your app sent it, without adding scripts or rewriting pages. The Cloudflare features that don’t read it, Rocket Loader and Automatic HTTPS Rewrites, are switched off for the apps domain. The same goes for your own domain, unless its DNS is on Cloudflare and proxies the record to us: then your zone’s settings apply first, and what you turn on there can change your pages. Nothing turns your page’s http:// links into https:// ones, so the browser blocks a script, style sheet or image loaded from an http:// address as mixed content. A proxy that ignores no-transform, such as one on a company network, can still change a page on its way to the browser, as it can on any site.
  • Over HTTPS, every response carries Strict-Transport-Security: max-age=63072000; includeSubDomains.
  • Every response carries Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so another app’s page can’t show yours in a frame and trick someone into clicking in it. Your app’s own Content-Security-Policy is kept beside ours, and a browser enforces both, so your app can make framing stricter but not looser. A page of another app that opens yours in a frame is refused with 403 before your app sees the request. The edge’s own pages can’t be framed at all. A service worker your app registers can answer a page load without the edge, so if it builds pages of its own, give them the same frame-ancestors 'self'.
  • Responses to scripts at forculuslabs-apps.tech/<workspace>/<app>/ carry Content-Security-Policy: sandbox and can’t set cookies, since every app answers on that one host there.

Your app holds no third-party credentials. You store one with forculus secret set, naming the one host it’s for and the header it goes in. When your app makes an HTTPS request to that host, we add the header on the way out. The value isn’t in your code, its environment, its logs or its versions, and nobody can read it back. A listing shows each secret’s name and where it’s sent, not its value. See Secrets and outbound calls.

This keeps the value out of your app, but it can’t stop the host you named from handing it back. If that host puts request headers in a response, as a debugging endpoint that echoes requests does, or quotes the key in an error message, your code reads the value there like any other response. Anyone who can deploy the app can write code that calls such an endpoint. Name only a host you trust with the key, and treat everyone who can deploy the app as able to use it.

A secret is only added over HTTPS, and isn’t sent on when that host redirects.

A webhook’s signing secret stays out of your app as well. You paste it into a public path’s verifier in the dashboard, and we check each delivery with it before your app sees it. We store it the same way as a secret, and decrypt it only to check a delivery to that path.

We also refuse credentials in the wrong place:

  • forculus deploy refuses to upload a .env file, a private key or anything else that looks like a credential.
  • Every deploy is scanned for keys left in its code, its files or its image, and for packages with published vulnerabilities. A workspace chooses whether a finding warns or refuses the deploy. Private keys and live tokens are always refused.
  • forculus env set refuses a variable whose name or value looks like one, and points you to forculus secret set, or to a verifier for a webhook’s signing secret.

Your app’s outbound requests can’t reach our own hosts: the dashboard, the API, the edge or another app. Such a request gets 403 Refused: apps can't send requests to Forculus itself. The one exception is the key set your app verifies tokens with. Every outbound request carries the header X-Forculus-Outbound: 1, so an app’s custom domain refuses it as well, with a 404.

You can go further and hold an app to a list of hosts, so a request anywhere else is refused. See Sending requests out.

A public path is the only way into your app without signing in, and is meant for webhooks.

  • On a path with a verifier, only a delivery whose signature holds reaches your app, with a token of kind webhook that requireWebhook checks and requireUser refuses. Every failed check gets the same 401, so a stranger can’t tell which one failed.
  • To check a delivery, the edge sends its body and signature headers to our control plane before your app sees it. We don’t store the body. The audit log keeps the provider’s id for the delivery, if it signs one. For providers that sign a time, we keep a hash of the signature so we can refuse a copy of the delivery. It’s deleted within an hour.
  • A copy of a delivery your app answered with a 2xx is refused while it’s still fresh, on Stripe, Slack and Standard Webhooks. GitHub, Shopify and plain HMAC senders sign no time, so your app has to make a repeat harmless itself.
  • On a path with no verifier, a request reaches your app with no identity token, so requireUser still refuses it. Your app has to check the sender’s signature itself.
  • A request that carries a bypass token or a CLI session is treated as coming from that caller, on a public path too. The edge checks the token as it would anywhere, and refuses one that can’t use the app.
  • The edge limits how many requests one client can send to an app’s public paths each minute, and answers 429 past that. Verified paths don’t have that limit. They have their own limits, per path and per workspace.
  • A public path takes no WebSockets.
  • Only a person in the dashboard can open one or change its verifier. The CLI and agents can list them, but can’t open one or change a verifier.

Every table in our database that holds workspace data has a Postgres row-level security policy, so a query can’t return another workspace’s rows. Each app runs in its own isolate or container, with its own storage, secrets and logs. Every change we make runs tests on a full copy of the platform. They deploy apps for two workspaces side by side, as Worker apps and as container apps in real containers, and check that neither workspace’s apps can read the other’s database, files, secrets or logs.

Our own database is in AWS ap-southeast-1 (Singapore). An app’s database and files are kept by Cloudflare, in the region nearest whoever created the app, and the audit log older than 30 days is archived in Cloudflare R2, which Cloudflare is asked to keep in the Asia-Pacific region. Keeping an Enterprise workspace’s data in the EU isn’t open yet. Keeping data in the EU says what it will cover and what it won’t.

  • An app’s database can be put back by a workspace owner as it was at a minute in the last 30 days, with Cloudflare D1’s Time Travel. After a restore, it goes back by the bookmark each restore gives.
  • Our own database, with workspaces, members, sharing, settings, secrets and the recent audit log, can be put back by us as it was at any moment in the last 7 days, to the second: that’s its recovery point (RPO). Our recovery time (RTO) target is 4 hours from deciding to restore. It’s in one AWS region, with no copy outside it yet: if that region is down, so is the dashboard, and apps answer 503 within a minute, until it’s back.
  • An app’s files on R2 have no history. A file the app overwrites or deletes is gone at once.
  • A deleted app or workspace is kept for 30 days and can be restored, unless it was deleted for good, or an owner of it has yet to accept our terms (see when deletion is immediate).

Backups and restore has the details, and how to restore each.

  • Verify the identity token on every request. Use an SDK. Don’t trust a header on its own.
  • Answer 401 when there’s no valid caller, and 403 when the caller isn’t allowed to do this.
  • Keep credentials out of your code, your files and your environment. Use forculus secret set.
  • Treat a public path as open to anyone. Give it a verifier and check the delivery with requireWebhook, or check the signature yourself on a path with none.
  • Treat a bypass token as a secret. Make one per script, and revoke it when the script is retired.

Email security@forculuslabs.tech with what you found, how to reproduce it, and what it lets someone do. Don’t open a public issue.

Test only against your own workspaces and apps. The security page has the full policy, including how quickly we answer.