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.
How requests reach your app
Section titled “How requests reach your app”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:
- Works out who is calling: the app host’s session cookie, or a bearer token.
- Refuses a write sent from another site, or another site opening the app in a frame.
- Asks whether that caller can use this app.
- Deletes every identity header the client sent.
- Signs a fresh identity token for your app.
- Records the request in the audit log.
- Passes the request to your app.
A denied request is recorded too, and doesn’t reach your app.
Signing in
Section titled “Signing in”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.
Identity tokens
Section titled “Identity tokens”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 tokenX-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.
Revoking access
Section titled “Revoking access”- 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 a check can’t run
Section titled “When a check can’t run”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.
Response headers
Section titled “Response headers”Set-Cookieheaders lose theirDomainattribute, 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’smax-agesays. - Every response also gets
no-transforminCache-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’shttp://links intohttps://ones, so the browser blocks a script, style sheet or image loaded from anhttp://address as mixed content. A proxy that ignoresno-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'andX-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 ownContent-Security-Policyis 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 sameframe-ancestors 'self'. - Responses to scripts at
forculuslabs-apps.tech/<workspace>/<app>/carryContent-Security-Policy: sandboxand can’t set cookies, since every app answers on that one host there.
Secrets
Section titled “Secrets”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 deployrefuses to upload a.envfile, 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 setrefuses a variable whose name or value looks like one, and points you toforculus 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.
Public paths
Section titled “Public paths”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
webhookthatrequireWebhookchecks andrequireUserrefuses. 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
requireUserstill 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.
Workspace isolation
Section titled “Workspace isolation”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.
Where data is kept
Section titled “Where data is kept”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.
Backups and recovery
Section titled “Backups and recovery”- 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.
What your code must do
Section titled “What your code must do”- 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.
Report a vulnerability
Section titled “Report a vulnerability”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.
Next steps
Section titled “Next steps”- How access works: one request, step by step.
- Backups and restore: what we keep, and how to put it back.
- SDK overview: verify the identity token in your app.