Documentation
GuidesAuthorizationRole-based access control

Role-based access control

Contexts, roles, scopes and grants — how a permission decision is actually made.

Most access-control bugs come from a model that answers "what is this user?" when the real question is "what is this user here?". nulvless only ever answers the second one.

There is no global "admin" flag. Permission always exists at the intersection of a user and a resource.

The four pieces

contextstring

A kind of resource — "org", "project". Roles are defined inside one.

roleRole

A named bundle of scopes, belonging to exactly one context.

scopestring

One capability — "members:write". What your code actually checks.

grantGrant

One user holding one role on one resource. The thing that exists at runtime.

Read it as a sentence:

Grace holds the owner role — a role defined in the org context, carrying the scopes members:write and billing:read — on the resource acme.

Change the resource and it is a different grant. Grace can be owner on acme and hold nothing at all on globex, with no special-casing in your code.

Nothing here is called an organization

Most identity products ship an opinion: users belong to organizations, an organization has members, and if your product is shaped differently you fight the model. nulvless has no such entity.

context and resource are strings you choose. The platform stores no schema for them and never validates that acme exists — it only records that a grant points at it. That is what makes one mechanism cover every shape:

Your conceptcontextresource
Organizationsorgthe org slug — acme
Projectsprojectthe project id — proj_16tbwq34
Workspacesworkspacethe workspace uuid
Stores in a marketplacestorethe store id
A single documentdocumentthe document id
Your own back officeplatforma constant, e.g. internal

Nothing about the platform changes between those rows. You are not modelling organizations — you are saying this role applies to things of this kind, and this grant applies to that specific one.

How the pieces connect

context  "project"          ← a kind of thing, named by you
   │
   └── role  "editor"       ← defined inside exactly one context
         │     key: 'editor'
         │     scopes: ['project:write', 'project:read']
         │
         └── grant          ← the role, a user, and one concrete resource
               user:     usr_8f21
               resource: 'proj_16tbwq34'
  • The context ties a role to a kind of thing. An editor role in project can never be granted meaningfully on an org — the roles you list for a context are the only ones that belong there.
  • The role carries the permissions. Scopes live on the role, not on the user, so changing a role moves everyone holding it at once.
  • The grant supplies the specific thing. It is the only place a concrete resource identifier appears.

A role is therefore reusable by construction: editor is defined once and granted on a thousand projects. The role has no idea which ones.

Because resources are opaque, hierarchy is yours

The platform sees acme and proj_16tbwq34 as unrelated strings — it does not know one contains the other. Grants never cascade.

That is a constraint and a feature. It means you can encode any hierarchy you like, including none, but you must check the levels you want to honour. If organization owners should reach every project inside, say so explicitly — see nested resources.

Choosing scope names

Scopes are yours too. The only rule that matters is that they name a capability, not a screen or a role:

// Good — survives a redesign.
['members:read', 'members:write', 'billing:read', 'projects:delete']

// Bad — a UI detail and a role wearing a scope's clothes.
['settings-page', 'is_admin']

A resource:action convention reads well and groups naturally, but nothing enforces it.

Context is a property of the role, not of the grant

This trips people up. When you invite someone you name a resource and a role id. The context came along with the role when the role was defined — you never pass it to invite.

// Roles are defined once, per context.
await nulvless.auth.admin.roles.create({
  name: 'Administrator',
  key: 'admin',
  context: 'org',
  scopes: ['members:read', 'members:write', 'projects:read'],
});

// Grants point at a resource and reuse a role.
await nulvless.auth.admin.grants.set({
  resource: 'acme',
  granted_to_user_id: userId,
  role_id: adminRoleId,
});

How a decision is made

One call answers both questions — is the token still valid, and does its holder have what you named:

const { data, error } = await nulvless.auth.verify(
  { resource: 'acme', roles: ['owner', 'admin'] },
  { headers: { Authorization: `Bearer ${token}` } },
);
resourcestringOptional

Which resource the question is about. Omit to check the token only.

rolesstring[]Optional

Matched against the role key. Any one of them is enough. Ignored unless resource is given.

scopesstring[]Optional

Required scopes. The holder must have all of them.

Ask, do not fetch and decide

Fetching the user's grants and comparing them in your own code re-implements the decision — and drifts the moment a role changes. Name the requirement and let verify answer.

The four ways to ask

// 1. Is this token still valid? Nothing about permissions.
await nulvless.auth.verify(undefined, { headers });

// 2. Does the holder have a capability here?
await nulvless.auth.verify({ resource: 'acme', scopes: ['members:write'] }, { headers });

// 3. Does the holder have one of these roles here?
await nulvless.auth.verify({ resource: 'acme', roles: ['owner', 'admin'] }, { headers });

// 4. Does the session itself carry these scopes, independent of any resource?
await nulvless.auth.verify({ scopes: ['platform:support'] }, { headers });

The two dimensions do not combine the same way:

Semantics
scopesAll of them are required.
rolesAny one of them is enough.

So scopes: ['members:read', 'members:write'] demands both, while roles: ['owner', 'admin'] accepts either.

Two things that surprise people

roles matches the role's key, not its name. A role created with key: 'admin' and name: 'Administrator' is checked as 'admin'.

roles is ignored when you omit resource. Without a resource there is no grant to read a role from, so only scopes are evaluated — against the session's own scopes. A roles-only check with no resource silently passes.

Telling "not signed in" from "not allowed"

verify separates the two, and your API should preserve the difference:

const { data, error } = await nulvless.auth.verify(
  { resource, scopes: ['members:write'] },
  { headers: { Authorization: `Bearer ${token}` } },
);

if (error?.status_code === 401) {
  // The token is missing, expired or revoked → send them to sign in.
}

if (error?.status_code === 403) {
  // Authenticated fine, but does not hold it → show a forbidden state.
}

Collapsing both into 401 is a common bug: it bounces a signed-in user back to the login screen, where signing in again changes nothing.

A 403 also carries a code, and one of them is worth handling on its own:

forbidden_resource_access403

No grant on this resource, or it is blocked, or a required scope or role is missing.

forbidden_access_waiting_accept403

A grant exists but is still invited or requested — the invitation was never accepted.

That second code is the difference between "you have no access" and "you have an invitation sitting in your inbox", and it is worth saying so in the UI.

What comes back

{
  user: { id },
  session: {
    id,
    scopes,             // the effective scopes on this resource
    amr,                // how they authenticated: password, otp, totp…
    sudo,               // whether sudo is currently active
    sudo_expires_at,
  },
}

Only a grant whose status is granted ever passes. An invitation that was never accepted is inert, and a blocked grant denies.

session.scopes is the resolved set for that resource — useful for driving a UI that hides what the user cannot do, after the server has already decided.

Roles versus scopes in the check

Check scopes, not roles, wherever you can. A role is a label your customers rename; a scope is the capability your code depends on.

// Brittle — breaks when a role key changes or a new role should also pass.
await nulvless.auth.verify({ resource, roles: ['admin'] });

// Durable — keeps working as roles are reorganised.
await nulvless.auth.verify({ resource, scopes: ['members:write'] });

Roles are the right check when the rule genuinely is about seniority — "only an owner can delete the organization".

One-off permissions

A grant does not need a role. Omit role_id and it carries bare scopes — useful for access that no role should describe, like a single automation or a one-time exception:

await nulvless.auth.admin.grants.set({
  resource: 'acme',
  granted_to_user_id: botUserId,
  scopes: ['projects:read'],
});

You can also add scopes on top of a role for one person:

await nulvless.auth.grants.update(grantId, {
  scopes: ['members:read', 'billing:read'],
});

Both are exceptions, and exceptions are invisible on a roles screen. If you cannot answer "who can see billing?" by reading the roles alone, the model has started to drift — prefer a new role once the same exception appears twice.

The grant list is your tenant list

A pattern worth naming, because it removes a table most teams build: to list the organizations a user belongs to, list their grants in that context.

const { data: grants } = await nulvless.auth.grants.list('org');
const slugs = grants.map((grant) => grant.resource);

const organizations = await db.organizations.findBySlugs(slugs);

There is no membership table to keep in sync, because membership is the grant — one less thing in your schema that can disagree with permissions.

Seeding the first grant

The person who creates a resource should not have to accept an invitation to it. Create the grant as the platform, in the same transaction that creates the resource:

await db.transaction(async (tx) => {
  const org = await tx.organizations.create({ slug, name });

  await nulvless.auth.admin.grants.set({
    resource: org.slug,
    granted_to_user_id: creatorId,
    role_id: OWNER_ROLE_ID,
  });

  return org;
});

admin.grants.set skips the invitation entirely — the grant exists immediately. Keeping it in the transaction means a failure here leaves no orphaned resource that nobody can administer.

Cleaning up

When a resource is deleted, its grants must go too, or they outlive what they pointed at:

await nulvless.auth.admin.grants.deleteByResource(org.slug);

Acting as the user, or as the platform

Two namespaces do overlapping things, and picking the wrong one is a real mistake:

Acts asUse for
auth.grantsThe signed-in userAnything a person does — inviting, accepting, leaving
auth.admin.grantsThe platformSeeding owners, cleanup, back-office tooling

auth.grants enforces the caller's own permissions and records them in the audit trail, so forward their headers:

import { forwardUserHeaders } from '@nulvless/nulvless-js';

await nulvless.auth.grants.invite(params, {
  headers: forwardUserHeaders(req),
});

auth.admin.grants bypasses that by design. It is a back-office tool, not a shortcut for user actions — a grant it creates carries no record of who asked.

Next

On this page