Access control & roles
Entitlements aren’t only for paid features. An entitlement answers one question — “does this customer have this capability?” — and its source can be a paid Product or a manual grant. This guide is about the second kind: capabilities you grant to specific people rather than sell — admin access, internal tooling, beta flags, team roles. It reuses the same entitlement primitive you already know, with one rule inverted.
Paid features (see Entitlements & gating) fail open: you never lock a paying customer out on a glitch. A security boundary like admin fails closed: on any doubt, deny. Keep the two mental models apart — that is exactly why this is a separate page.
Entitlements as an access-control primitive
A role is just an entitlement key you grant by hand instead of selling — admin, support, billing_manager. No separate RBAC system, no roles table, no custom auth claims to keep in sync: the same grant / revoke / reconcile machinery you run for pro, now for roles. Granting lights the capability up live; revoking pulls it instantly.
The recipe — grant a user admin access
- Create the entitlement key. Dashboard → Entitlements → New entitlement →
admin. Do not attach it to a Product or a rail SKU — it isn’t sold, it exists purely to be granted. (This is the one place the paid flow differs: no SKU→Product mapping, because no money is attached.) - Grant it by hand to the people who should have it:
Theawait crossdeck.grantEntitlement({ customerId: "cdcust_jane", entitlementKey: "admin", duration: { lifetime: true }, // or { days: 7 } for a contractor reason: "Admin — Jane Doe, granted by wes@ 2026-07-28", });reasonis your access-grant audit trail — who has admin, and why. - Show the door, client-side. Gate the admin nav / area UI on
isEntitled("admin"). It is reactive: a grant reveals the admin menu live, a revoke hides it — no re-login. - Lock the door, server-side. Enforce every admin action and every admin data read with the server helper. This is the actual gate — and it fails closed (next section).
Fail closed — the rule that makes this safe
A security boundary grants access only on a confirmed positive. A .resolving state, a thrown error, an unreachable backend — anything that is not a definite yes — must deny. A wrongly-granted admin session is a breach; a wrongly-denied admin just retries.
// Admin gate — FAIL CLOSED. Only a confirmed "yes" passes.
async function requireAdmin(userId) {
let ok = false;
try {
ok = await crossdeck.isEntitled({ userId }, "admin");
} catch {
ok = false; // unreachable / error -> DENY, never allow on doubt
}
if (!ok) throw new HttpError(403, "admin required");
}
For pro, a .resolving state is handled as neutral / last-known-entitled so a payer is never locked out. For admin, that same neutral state is a denial. A developer who reuses the paywall’s fail-open handling for an admin gate has built a fail-open security boundary — a vulnerability.
Client shows, server enforces
isEntitled("admin") on the client is UX — it shows or hides the admin menu — and it is trivially bypassable from dev tools. It is never the only thing between a user and your admin area. Every privileged action and every admin data read is checked server-side, fail-closed. Same rule as “client isEntitled() is a UX gate, not a security boundary” — it just matters most when the capability is the boundary.
Revoke is live; grants can be time-boxed
revokeEntitlement({ customerId, entitlementKey: "admin", reason }) pulls admin the instant it lands — no session to expire, no token to rotate. Off-boarding an admin is one call. For temporary access, grant with duration: { days: 7 } and it self-revokes on schedule. Every grant and revoke is audited by the reason you wrote.
More than one role
Every role is its own entitlement key — admin, support, billing_manager, beta. A user can hold several; gate each capability on its own key. They are all manual, all live, and all fail closed. That is your whole role model — no roles table required.
Related
- Entitlements & gating — paid features. Note the opposite failure mode: paid gating fails open.
- Manual grants and server-side resolution — the mechanics this guide builds on.
Using an AI assistant? Read this page as clean markdown — index.md — or the whole docs index at /docs/llms.txt.