# Access control & roles

> Source: https://cross-deck.com/docs/access-control/ · Crossdeck developer docs (generated from the published page).

Entitlements aren&rsquo;t only for paid features. An entitlement answers one question — *&ldquo;does this customer have this capability?&rdquo;* — 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**.

This is not the paywall doc — and the failure mode is the opposite.

Paid features (see [Entitlements & gating](https://cross-deck.com/docs/entitlements/index.html)) **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 &rarr; *Entitlements* &rarr; *New entitlement* &rarr; `admin`. **Do not attach it to a Product or a rail SKU** — it isn&rsquo;t sold, it exists purely to be granted. (This is the one place the paid flow differs: no SKU&rarr;Product mapping, because no money is attached.)

- **Grant it by hand** to the people who should have it: 

```
await 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",
});
```

 The `reason` is 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");
}
```

This inverts the paid-feature default — do not copy the Pro pattern here.

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&rsquo;s fail-open handling for an admin gate has built a fail-*open* security boundary — a vulnerability.

## Client shows, server enforces

The client gate hides the door; the server check is the lock.

`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 &ldquo;client `isEntitled()` is a UX gate, not a security boundary&rdquo; — 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](https://cross-deck.com/docs/entitlements/index.html) — paid features. Note the opposite failure mode: paid gating fails *open*.

- [Manual grants](https://cross-deck.com/docs/entitlements/index.html#manual-grants) and [server-side resolution](https://cross-deck.com/docs/entitlements/index.html#server-resolution) — the mechanics this guide builds on.
