Every app with an address can say who may open it, and what traffic reaches it. Both are checked at Cloudflare's edge, before a request goes anywhere near the app: its servers, its static files, or the page that asks someone to sign in.
An app opens to anyone with its address, or only to its team: the people of its organization, signed in to Alf, and programs that send the project's bypass token.
| What | Unless it says otherwise |
|---|---|
| A project's own apps (production) | anyone |
| Its previews and environments | its team only |
A candidate, the next version held beside the live one (…--next) | its team only, always |
| A computer's shared port | its team only |
A preview is a version nobody has said may be seen yet, so it's the team's until the project says otherwise. A service can say for itself, whatever its project says: an API that a webhook provider calls, in a team-only preview, can be public.
You see it in the project's toolbar (Team only or Public, next to Settings), on each preview and environment in the lists, and next to a service's address. You set it in the project's Settings → Who can open it, in a service's Settings → Networking, with alf access, or in alf.toml:
alf access # what it is now
alf access team # production to the team only
alf access public --previews # previews and environments open to anyone
alf access public --service api # one service, whatever the project says
alf access default --service api # back to the project's
A change applies within seconds. Nothing is deployed for it.
Someone who opens a team-only app in a browser is sent to Alf to sign in (if they aren't already), and straight back to the page they asked for. They stay signed in to that app for 12 hours, with a cookie of its own address. Someone removed from the organization, or not given the app's project, is turned away within a minute.
The cookie, like everything else that lets a request in, is taken off before the request reaches the app: the app never sees it, and its own cookies and headers are untouched. Answers from a team-only app are kept out of shared caches (Cache-Control: private).
A browser app that calls a team-only API at another address has no cookie there: give the API its own access = "public" (and its own login), or serve both from one address.
A program that isn't signed in gets 401 with a short explanation instead of the sign-in page. To let it in, make the project a bypass token (Settings → Who can open it, or alf bypass-token) and keep it in your CI's secrets. It opens the project's team-only apps, its previews' and environments' too:
curl -H "x-alf-bypass: $ALF_BYPASS_TOKEN" https://web-shop-pr-12-acme.smartalfred.com/health
For Playwright, send it with every request:
// playwright.config.ts
use: { extraHTTPHeaders: { "x-alf-bypass": process.env.ALF_BYPASS_TOKEN! } },
It's shown once. Making another stops the old one working; alf bypass-token --remove takes it away.
For a quick request of your own, alf access-token <url> prints a token for that one app, for 12 hours, as you:
curl -H "x-alf-access: $(alf access-token https://web-shop-pr-12-acme.smartalfred.com)" \
https://web-shop-pr-12-acme.smartalfred.com/api/orders
A service's rules are checked in order, and the first one that matches a request decides: block answers 403, allow lets it through without checking the rest. A request no rule matches is let through.
A rule matches when everything it names does, and one that names nothing matches every request. So allow some countries followed by a bare block lets only those countries in:
[services.web]
rules = [
{ action = "allow", countries = ["DE", "AT", "CH"] },
{ action = "block" },
]
| A rule can name | Matches when the request… |
|---|---|
ips | comes from one of these addresses or ranges: "203.0.113.7", "10.0.0.0/8", "2001:db8::/32" |
countries | comes from one of these countries, by their two-letter codes (T1 is Tor) |
asns | comes from one of these networks, by AS number: 16509 |
paths | is for one of these paths or what's under them: /admin is /admin and /admin/users, not /administrator. Matched however the path is written (/ADMIN, /%61dmin) |
methods | uses one of these methods: "POST", "DELETE" |
user_agents | has a User-Agent containing one of these, in any case |
Rules come before everything else, the team's sign-in included: a blocked visitor isn't even sent to sign in. A service has at most 50.
A rate limit says how many requests a minute each visitor may send, to the paths and methods it names (or to all of the service); past it, the answer is 429 with Retry-After. A visitor is an IP address, or an IPv6 address's /64.
[services.web]
rate_limits = [
{ per_minute = 10, paths = ["/login"], methods = ["POST"] },
{ per_minute = 600 },
]
Each limit counts on its own: a login above counts against both. Every request let through counts, allowed ones too. A limit is one of 10, 30, 60, 100, 300, 600, 1000, 3000, 6000 or 10000 a minute, and a service has at most 10.
Limits use Cloudflare's own rate limiting, which is loose by design. Each Cloudflare location counts on its own, and each machine there counts for itself and catches up with the others in the background. So a limit stops floods and brute force, not the request just past it: a visitor sending a few requests a second can get well past it before they're turned away. Measured with a limit of 10 a minute, 80 requests over 40 seconds all went through, and of 150 sent ten at a time, 31 were turned away. For an exact quota (per customer, per API key), count in your app.
access = "team" # the project's own apps
[previews]
access = "public" # its previews and environments
[services.api]
access = "public" # this service, whatever the project says
rules = [{ action = "block", countries = ["KP"] }]
rate_limits = [{ per_minute = 100, paths = ["/login"] }]
As with every setting there, a deploy from the file sets what it names and leaves what it doesn't. The project's access and [previews] access are set by deploys to the project itself: a branch's preview can't change who may open the others.