SenseCrypt Docs
Guides

RBAC: roles and permissions

Emit fine-grained authorization for your own APIs with SenseCrypt's Auth0-style RBAC — resource servers, permissions, roles, and the permissions claim your API enforces.

When your API needs fine-grained authorization, SenseCrypt can compute and emit a permissions claim describing what the signed-in user may do. This guide walks through setting that up end to end.

The model is Auth0-style and has four pieces: resource servers, permissions, roles, and assignments. A key principle up front:

SenseCrypt emits permissions; it never enforces them. Your API validates the token and authorizes each request against the permissions array. SenseCrypt's job is to compute the right set and put it in the token.

When you need this

You need RBAC only if you want SenseCrypt to tell your API what the user may do. If you just need to know who the user is (authentication) and gate sign-in access, the access gate is enough — you can skip RBAC entirely. Add RBAC when your backend wants to authorize per-action (read:invoices vs write:invoices).

Step 1 — Define a resource server (your API)

A resource server represents one of your APIs. It's identified by an immutable audience string — a URI you choose, for example https://api.acme.com. Create it in the admin console (or via the Management API) with:

  • The audience (immutable once set; this becomes the token aud).
  • Optionally, a per-API access-token TTL.
  • The enforce_rbac flag — leave it on if you want a permissions claim (more below).

Step 2 — Define permissions

A permission is a single action string scoped to exactly one resource server. The grammar is strict:

  • Lowercase [a-z0-9_] segments, colon-separated: read:invoices, write:billing.
  • A * is allowed only as a whole trailing segment: write:billing:*.
  • Up to 200 characters.

Define the permissions your API understands on that resource server — for example read:invoices, write:invoices, read:reports.

Step 3 — Bundle permissions into roles

A role is a per-tenant named bundle of permissions (for example Billing Manager = read:invoices + write:invoices). A role may span multiple resource servers — but a token only ever carries the permissions for the single API it targets, so a broad role is safe.

Step 4 — Assign roles to users

A role reaches a user in one of two ways:

  • Directly — assign the role to an individual user.
  • Through a group — confer the role on a group, so every member inherits it. This is usually the cleaner approach: manage people in groups, and let roles flow through group membership.

See Groups and access for managing groups.

Step 5 — Request the audience from your app

Your application requests the resource server's audience at authorization time, using either spelling:

GET /v1/idp/oidc/authorize?client_id=...&audience=https://api.acme.com&...
# or the RFC 8707 form:
GET /v1/idp/oidc/authorize?client_id=...&resource=https://api.acme.com&...

(If both are supplied, resource wins.) Two gates apply, and both fail closed:

  • Your app must hold a grant to request that audience at all. Grant the application access to the resource server in its configuration. An unknown API and an ungranted API are indistinguishable — both return invalid_target.
  • The token endpoint is the canonical enforcement point. The audience rides through /authorize as a query parameter and is re-validated at the exchange, so it can't be tampered with.

What the access token carries

The shape of the access token depends on your request:

Requestaudazppermissions
No audienceyour client_idabsentabsent
Audience, enforce_rbac = falsethe API audienceyour client_idabsent (pure audience binding)
Audience, enforce_rbac = truethe API audienceyour client_idthe user's effective permissions (possibly [])

So permissions appears only when you target an audience whose resource server has enforce_rbac on. Turning enforce_rbac off gives you audience binding without permissions — useful when your API does its own authorization but still wants correctly-audienced tokens.

Step 6 — Enforce in your API

Your API:

  1. Validates the JWT signature against the tenant's JWKS, selecting the key by kid.
  2. Checks aud equals its own audience.
  3. Authorizes each request against the permissions array.
// pseudo-code in your API
const claims = verifyJwt(token, tenantJwks);      // 1. signature + iss + exp
assert(claims.aud === "https://api.acme.com");    // 2. audience
if (!claims.permissions?.includes("write:invoices")) {
  return deny();                                   // 3. per-action check
}

Permissions recompute on every mint

The user's effective permissions are the union of every role held directly or via a live group, filtered to the API being targeted, and recomputed on every token — including every refresh. That means:

  • Revoking a role, or removing a user from a group, shrinks the next access token. You don't wait for expiry.
  • An empty permissions: [] is legitimate — it means the user passed the access gate but has no permissions on that specific API.

Keep access-token lifetimes reasonably short so permission changes propagate promptly.

Don't confuse the two RBAC axes

SenseCrypt has a second, unrelated capability system that governs who may administer the IdP itself (console admins and M2M apps). It also uses the words "role" and "permissions" — but it's a completely separate axis with its own tables and enforcement.

The RBAC in this guide governs your end users in your applications. Administering SenseCrypt and machine access to its Management API use a separate model — see Machine-to-machine.

A quick tell: if a permissions claim's aud is your own API audience, it's this axis (end-user RBAC). If the aud is the Management API and sub ends in @clients, it's the console-capability axis in an M2M token.

Using the Management API

Everything in the steps above can be driven with curl against the tenant Management API instead of the admin console — useful for automation and infrastructure-as-code.

These calls need an M2M access token (see Machine-to-machine) whose bound console role grants the relevant capability — each call below names the one it requires. Pass the token as Authorization: Bearer {mgmtToken}. The base path is the tenant admin API on the tenant issuer host: https://{tenant-host}/v1/admin. A token only ever acts within the single tenant it was minted for.

The calls follow the same order as the steps above. Every PUT set-membership call is replace-set: send the complete desired set, not a delta.

Step 1 — Create the resource server

POST /v1/admin/resource-servers — requires resource_servers:create. Only name and audience are required; audience is immutable once set. token_lifetime_seconds (1–86400) and enforce_rbac (default false) are optional.

curl -X POST https://{tenant-host}/v1/admin/resource-servers \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Invoices API",
    "audience": "https://api.acme.com",
    "enforce_rbac": true
  }'

Returns 201 with the resource server:

{
  "id": "0f8c9d2e-1b3a-4c5d-8e7f-a1b2c3d4e5f6",
  "name": "Acme Invoices API",
  "audience": "https://api.acme.com",
  "description": null,
  "token_lifetime_seconds": null,
  "enforce_rbac": true,
  "permission_count": 0,
  "created_at": "2026-07-16T12:00:00Z",
  "updated_at": "2026-07-16T12:00:00Z"
}

Step 2 — Define permissions

POST /v1/admin/resource-servers/{rs_id}/permissions — requires permissions:create. One call per permission. value follows the grammar from Step 2 above; an invalid value returns 422 (permission_value_invalid). value is immutable — delete and recreate to rename.

curl -X POST https://{tenant-host}/v1/admin/resource-servers/{rs_id}/permissions \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "value": "read:invoices",
    "description": "Read invoices"
  }'

Returns 201 with the permission:

{
  "id": "3a1e7b90-2c4d-4e6f-9a0b-1c2d3e4f5a6b",
  "resource_server_id": "0f8c9d2e-1b3a-4c5d-8e7f-a1b2c3d4e5f6",
  "value": "read:invoices",
  "description": "Read invoices",
  "created_at": "2026-07-16T12:01:00Z",
  "updated_at": "2026-07-16T12:01:00Z"
}

Step 3 — Create a role, then attach permissions

Create the role with POST /v1/admin/roles — requires roles:create:

curl -X POST https://{tenant-host}/v1/admin/roles \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Billing Manager",
    "description": "Manage invoices"
  }'

Returns 201; a freshly created role has no permissions yet:

{
  "id": "7bd2c1a0-9e8f-4d6c-b5a4-3f2e1d0c9b8a",
  "name": "Billing Manager",
  "description": "Manage invoices",
  "permission_count": 0,
  "created_at": "2026-07-16T12:02:00Z",
  "updated_at": "2026-07-16T12:02:00Z",
  "permissions": []
}

Then set the role's permissions with PUT /v1/admin/roles/{role_id}/permissions — requires roles:update. This replaces the full set; every id must be a live permission in your tenant, or the call returns 422 (unknown_permissions) listing the offenders. A role may span multiple resource servers, so permission_ids can reference permissions from more than one API.

curl -X PUT https://{tenant-host}/v1/admin/roles/{role_id}/permissions \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "permission_ids": [
      "3a1e7b90-2c4d-4e6f-9a0b-1c2d3e4f5a6b"
    ]
  }'

Returns 200 with the role and its permission members:

{
  "id": "7bd2c1a0-9e8f-4d6c-b5a4-3f2e1d0c9b8a",
  "name": "Billing Manager",
  "description": "Manage invoices",
  "permission_count": 1,
  "created_at": "2026-07-16T12:02:00Z",
  "updated_at": "2026-07-16T12:03:00Z",
  "permissions": [
    {
      "permission_id": "3a1e7b90-2c4d-4e6f-9a0b-1c2d3e4f5a6b",
      "value": "read:invoices",
      "description": "Read invoices",
      "resource_server_id": "0f8c9d2e-1b3a-4c5d-8e7f-a1b2c3d4e5f6",
      "resource_server_name": "Acme Invoices API",
      "audience": "https://api.acme.com"
    }
  ]
}

Step 4 — Assign the role

Directly to a user with PUT /v1/admin/auth_users/{auth_user_id}/roles — requires auth_users:update. Replace-set of role_ids:

curl -X PUT https://{tenant-host}/v1/admin/auth_users/{auth_user_id}/roles \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "role_ids": [
      "7bd2c1a0-9e8f-4d6c-b5a4-3f2e1d0c9b8a"
    ]
  }'

Or confer it on a group so every member inherits it, with PUT /v1/admin/groups/{group_id}/roles — requires groups:update. Same replace-set body:

curl -X PUT https://{tenant-host}/v1/admin/groups/{group_id}/roles \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "role_ids": [
      "7bd2c1a0-9e8f-4d6c-b5a4-3f2e1d0c9b8a"
    ]
  }'

Both return 200 with the subject's assigned roles:

{
  "roles": [
    {
      "id": "7bd2c1a0-9e8f-4d6c-b5a4-3f2e1d0c9b8a",
      "name": "Billing Manager",
      "description": "Manage invoices"
    }
  ]
}

Step 5 — Grant the app access to the API

The grant that lets an application request an audience (see Step 5 above) is PUT /v1/admin/oidc-apps/{client_id}/resource-servers — requires oidc_apps:update. Replace-set of resource_server_ids the app may target; an id not in your tenant returns 422 (unknown_resource_servers).

curl -X PUT https://{tenant-host}/v1/admin/oidc-apps/{client_id}/resource-servers \
  -H "Authorization: Bearer {mgmtToken}" \
  -H "Content-Type: application/json" \
  -d '{
    "resource_server_ids": [
      "0f8c9d2e-1b3a-4c5d-8e7f-a1b2c3d4e5f6"
    ]
  }'

Returns 200 with the app's granted APIs:

{
  "resource_servers": [
    {
      "id": "0f8c9d2e-1b3a-4c5d-8e7f-a1b2c3d4e5f6",
      "name": "Acme Invoices API",
      "audience": "https://api.acme.com",
      "enforce_rbac": true
    }
  ]
}

The rest of Step 5 — actually requesting the audience at /authorize — is your application's own OAuth request, not a Management API call. And Step 6 (enforcing the permissions array) happens entirely in your API's code; SenseCrypt exposes no Management API for it, by design.

On this page