Authorization
Authentication answers “who is this”. Authorization answers “may they do this”. Cloudflare Access owns the first; prick owns the second, from its own tables.
Source: packages/app/src/lib/server/auth/. This layer is implemented and tested,
and every route on the API enters through it.
From token to actor
A verified Access JWT is classified into exactly one of two identities:
sub !== "" && email → { kind: "user", subject: email.toLowerCase() }common_name && sub === "" → { kind: "service", subject: common_name }Two shapes are rejected rather than guessed at:
- Both
emailandcommon_name. Access does not issue such a token. Picking one would mean an attacker able to influence either claim chooses which identity the request is authorized as. - Neither. There is nothing to key a grant on, and an identity of
""is one every future subject would collide with.
Mismatched pairs — an email with an empty sub, a common_name with a
non-empty sub — fall through to the same rejection, for the same reason.
Service tokens are shaped differently, and it matters
A service token payload has no email, no nbf, and sub is the empty
string. A verifier written against the human token shape — requiring nbf, or
a non-empty sub, or an email — rejects every machine client with a message
that explains nothing. That is an explicit negative test, not a footnote.
Resolution
The actor’s identity row, every grant attached to it, and every grant held by a group it belongs to are loaded in one query. The direct half is a left join, so that an identity with no grants still produces a snapshot: that case is the normal one for a service token pointed at the Worker for the first time, and it has to produce a snapshot so the denial can be audited and the subject can appear in the “seen but not granted” list. The group half uses inner joins, because a group with no grants must contribute nothing — membership is a list, not a permission, and an inner join is how that is said in SQL rather than in a conditional somebody could later “simplify”.
Why the group half is a UNION ALL and not two more joins
Hanging LEFT JOIN group_members LEFT JOIN group_grants off the same row source
would also be one round trip and would also be correct — maximum over roles is
idempotent, so duplicates change nothing. What it would not be is bounded: the
join produces the cross product of the two branches, so an identity with 12 direct
grants in 4 groups holding 9 grants each returns 432 rows to compute a handful of
roles from. That cost grows multiplicatively with something operators are
encouraged to do more of.
UNION ALL returns direct + group rows instead of direct × group, and it is
still exactly one prepared statement — which the authorization suite asserts by
counting prepare() calls through a binding proxy, the same way the write path
asserts that a bulk write is one batch(). Adding groups did not add a query.
The three role maps in the snapshot are the maximum over the identity’s own grants
and its groups’, merged before the snapshot type exists. That is deliberate rather
than lazy: nothing downstream — resolveEffectiveRole, the visible-project query,
the audit view — should have to remember “and also check the groups”, because the
one that forgets is the one that becomes a privilege bug. Provenance is a separate
question with its own query behind it, asked only by the screen that exists to
answer it (GET /identities/{id}/effective-permissions).
The snapshot is memoised per request in a WeakMap keyed by the context object —
not stored as a field on the context, so it cannot outlive the request and cannot
be serialised into anything. The in-flight promise is what is cached, so two
concurrent checks on one request share a single query rather than racing to issue
two.
A 200-secret operation therefore performs one authorization query, not two hundred.
The algorithm
- If the identity is disabled, resolve to nothing. Full stop.
- Compute the effective global role: the maximum of any global grant — the
identity’s own or one held by a group it is in — and, if the subject is named
in
BOOTSTRAP_ADMINS,admin. - For a project scope, take the maximum of the global role and any grant on that project.
- For an environment scope, take the maximum of the global role, any grant on the environment’s project, and any grant on the environment itself.
Grants inherit downwards only. An environment admin is not a project admin.
Group grants are purely additive: they enter the same maximum as everything else, so a group can only ever raise a role and never lower one. There is no deny variant. A deny rule that silently overrode an explicit grant would make the access graph unreadable in exactly the situation — an incident, at 3am — where somebody has to be able to read it. Removal is what revocation is for.
Expiry is compared against the request’s injected clock rather than
Date.now(), so a grant cannot be live for one check and expired for the next
within the same request.
The environment’s project is needed to resolve step 4. Callers that already loaded the environment pass it and save a query; callers that did not omit it, and it is looked up once and memoised per request.
There is no god mode
Nothing in the resolution branches on actor.kind. A global administrator is an
ordinary grants row with scope_type = 'global' — same query, same audit
trail, same revocation.
The shortcut this replaces is a real bug class: if (actor.kind === 'user') return true means every human credential bypasses every scope check in the
system, and it reads as an optimisation on the way in.
Denials are recorded
assertCan writes an audit row with outcome: 'denied' before it throws.
The action is recorded as authz.<scope-type>.<required-role>.
One other path writes a denial: the audit log’s own ?project= filter, when the
slug names a project the caller may not audit. That row is recorded as
access.denied. Both carry outcome: 'denied', which is what
GET /access/unknown-identities reads, so filtering on the outcome rather than on
the action is the reliable way to find refusals.
That is what populates the “seen but not granted” screen, and it is the only way
an operator ever learns that a particular service token exists — common_name is
an opaque hex string, and nobody maps
e367826f93b8d71185e03fe518aff3b4.access to “staging deploy” by looking at it.
The denial row is the introduction.
The denial audit is deliberately best-effort: an audit failure must not
convert a 403 into a 500, because that would let a caller distinguish “denied”
from “denied and the log broke”. Mutations are the opposite — their audit row
rides inside the same transaction, and a failed audit fails the write.
Absent and invisible are the same answer
A reader with one environment-scoped grant asking for a project they cannot see
gets exactly what they get for a project that does not exist: 404.
Returning 403 for the first and 404 for the second turns the API into an
oracle for which project names are in use, which is information the actor was
denied by design. The helper that constructs the error takes no argument that
could distinguish the two cases — the shape is the enforcement, so a handler
cannot leak the difference by picking the wrong overload under time pressure. It
names the kind of thing (“project”) and never the identifier that was looked
up.
Bootstrap
BOOTSTRAP_ADMINS is a comma-separated vars entry of email addresses,
lower-cased and de-duplicated, evaluated live on every request. Removing an
address and redeploying takes effect on the next request: there is no cached copy
to invalidate and no seeded row to hunt down.
The justification is that the real root of trust is already “whoever can run
wrangler deploy”. That person can read MASTER_KEY and decrypt everything
regardless of what any grant says, so anchoring the bootstrap to the same
authority adds no exposure — and unlike a one-time token, there is no window in
which a printed credential is valid and unrevoked.
On the first authenticated request from a listed address, the implicit admin
self-heals into a real grants row, and the grant insert and its audit row ride
in the same transaction. The grant is recorded as created by the system, not by
the person it promotes: “alice@example.com granted alice@example.com global
admin” would be a false account of how that row came to exist.
The insert is ON CONFLICT DO NOTHING against the partial unique index, so two
concurrent first requests cannot produce two global grants. Under that race both
may write an audit row while only one writes a grant — an append-only log
recording two attempts is an honest account of what happened, and strictly better
than moving the audit outside the transaction so it could be made conditional.
The two guards
| Condition | Response |
|---|---|
BOOTSTRAP_ADMINS empty and no usable global admin grant |
503 NO_ADMINS_CONFIGURED |
| Revoking the last global admin grant while the var is empty | 409 LAST_ADMIN |
“Usable” excludes grants belonging to disabled identities and grants that have expired. Counting them would report an install as administrable when nobody can actually log in and fix it.
If the var is set, revoking the last grant is allowed, because the recovery path exists: edit the var and redeploy.
Disabled outranks everything
A disabled identity resolves to nothing even when it is named in
BOOTSTRAP_ADMINS. Letting the var override the flag would mean an operator who
disables an identity gets no guarantee that it stopped working, which makes the
kill switch worthless exactly when it is being used in anger.
Recovery from disabling your only bootstrap admin is a wrangler d1 execute,
which is available to the same person who can edit the var.
Groups split two capabilities that look like one
Curating a group’s membership requires global admin. Deciding what a group may do at a scope requires admin at that scope, resolved by the same code as granting an identity.
That split is the whole security argument for the feature. Suppose platform
holds admin on payments and on billing. The admin of billing may grant to
platform — their scope, their decision. If they could also edit its membership
they could add themselves, and walk out with admin on payments, a project
they have nothing to do with, without anybody granting them anything. The two
capabilities are individually reasonable and jointly a way to grant yourself
access.
Granting to a group you are already in is not an escalation, because the role you can confer is bounded by the role you already hold at that scope. Adding somebody else is the operation that widens reach, and that one is global-admin only.
Next steps
- Access control — the operator-facing guide.
- Threat model