Skip to content

Abilities (Scopes) ​

Coarse, stateless authorization carried in the access token. Lukk::abilitiesUsing() decides what a user's tokens may do, lukk.ability: gates a route on it, and $user->tokenCan() answers the same question in your own code.

It is inert until you configure it: without a callback no scope claim is minted for an ordinary sign-in, and no pin claim either — only a session you pin yourself carries one. Once configured it is deny by default — a token with no scope grants nothing.

The wire format is the registered scope claim, space-delimited (RFC 6749 §3.3, RFC 9068 §2.2.3), so an API gateway or a non-lukk verifier can read it without knowing anything about lukk.

WARNING

Scope says what a token may do, never which records it may touch. orders.read does not mean "these orders". Per-object authorization stays your Policies and Gates — this is deliberately the coarse half, and confusing the two is OWASP API1 (BOLA).

Setup ​

php
// app/Providers/AppServiceProvider.php
use Lukk\Lukk;

public function boot(): void
{
    Lukk::abilitiesUsing(fn ($userId, $context) => User::find($userId)->permissionNames());
}

Then gate a route:

php
Route::middleware(['auth:api', 'lukk.ability:orders.read,orders.write'])   // ANY of them
    ->get('/orders', [OrderController::class, 'index']);

Route::middleware(['auth:api', 'lukk.abilities:orders.read,orders.write']) // ALL of them
    ->post('/orders', [OrderController::class, 'store']);

The two aliases mirror Sanctum's split, so the semantics are the ones you already expect. In your own code, add the HasTokenAbilities trait to the user model — it is not part of HasRefreshTokens:

php
use Lukk\Concerns\HasTokenAbilities;

class User extends Authenticatable
{
    use HasRefreshTokens;
    use HasTokenAbilities;
}
php
if ($request->user()->tokenCan('orders.write')) {
    // ...
}

The trait adds tokenCan(), tokenCannot() and tokenAbilities(), all read from the current request's token rather than from the model, so the same user on two devices can hold different grants. Outside an authenticated request (a queued job, a console command) nothing is granted. It satisfies the Lukk\Contracts\HasTokenAbilities interface; implement that too (aliased, since the names match — use Lukk\Contracts\HasTokenAbilities as HasTokenAbilitiesContract;) if you want to type-hint it or let static analysis check it.

Naming abilities ​

IMPORTANT

Name abilities after resources, never after facts about a person.

An ability name is a public identifier. It travels in the scope claim of every access token — so every proxy, API gateway, APM and access log on the path sees it — it is published on the user resource, and it appears as a literal string in your JavaScript bundle, which anyone can download.

hiv_clinic.records.read is a special-category disclosure at each of those hops. clinic_a.records.read is not. lukk validates the syntax of a name and can say nothing about its meaning.

Names must be valid RFC 6749 scope tokens: no space, ", \, control characters or non-ASCII bytes. lukk also rejects a comma, because lukk.ability:a,b uses it as a list separator — an ability named orders,read could be minted but never required. A malformed name throws at issue time rather than being dropped, because the claim is space-delimited and ['orders.read admin'] would otherwise parse back as two abilities and grant an admin nobody issued.

Abilities are bounded at 128 bytes each and 2048 bytes per claim. The claim rides in an Authorization header on every request; past roughly 8 KB proxies start rejecting it, and that failure only shows up in production.

Wildcards ​

Two, and both only ever appear in the grant:

GrantMatchesDoes not match
*everything—
orders.*orders.read, orders.read.allorders, ordersX.read

A wildcard is never honoured in the check: tokenCan('orders.*') asks whether the literal ability orders.* was granted, so a caller cannot widen their own question. Matching is case-sensitive — scope tokens are opaque strings, so Orders.Read and orders.read are different abilities.

orders.* deliberately excludes the bare orders: a prefix grant is about what lives under the namespace, and including the namespace itself would make orders.* and orders indistinguishable to anyone reading a policy.

The context argument ​

The second argument is a TokenContext:

php
Lukk::abilitiesUsing(function (int|string $userId, TokenContext $context) {
    // $context->guard     — which guard this token is being minted for
    // $context->userId    — the subject
    // $context->familyId  — the session, stable across every rotation
    // $context->pinned    — always false here: a pinned grant never asks the callback

    return $context->guard === 'admin'
        ? Admin::find($userId)->permissionNames()
        : Customer::find($userId)->permissionNames();
});

A multi-guard install serves different audiences often enough that ['*'] for a customer token and ['*'] for an admin one are not the same grant. It is an object rather than more positional parameters so a future field won't break callbacks already written.

WARNING

The callback runs on every mint — every login and every refresh — and must be a fast, local, side-effect-free read.

It runs outside lukk's refresh transaction, so a slow lookup can't extend a row lock and an error it swallows can't poison lukk's transaction. It may still throw: that fails the refresh cleanly, before the presented token has been consumed. Read your permission store, return names, do nothing else.

Returning an array, a Collection, or a single string all work. Return ['*'] for an unrestricted token.

Derived vs pinned grants ​

By default abilities are derived — re-evaluated on every mint. Revoking one takes effect within access_ttl rather than lasting the life of the refresh token, which is the reason they aren't frozen at login.

A session can instead own a fixed grant, for the cases where the token is the principal rather than the user:

php
use Lukk\Actions\StartSession;
use Lukk\Support\Abilities;

// A personal access token: this token can deploy, and nothing else — forever.
$pair = app(StartSession::class)($user->getKey(), [], ['ci.deploy']);

// An impersonation session capped below what the target user can actually do.
$pair = app(StartSession::class)($user->getKey(), [], ['orders.read']);

A pinned grant is stored on the refresh_tokens row and replayed verbatim through every rotation, so it never widens to the subject's current permissions. It also stamps a pin claim, which is how lukk's own routes tell a machine token from a human session.

Derived (default)Pinned
SourceabilitiesUsing, per mintthe list you passed to StartSession
Revoking an abilitytakes effect within access_ttlnever — the grant is fixed
Storagenonerefresh_tokens.scope
pin claimabsenttrue
lukk's own session routesungatedgated (below)

Pinning requires the scope column — see Upgrading.

lukk's own routes ​

A token pinned to ['ci.deploy'] is refused by every gated route in your application — but lukk's own routes were gated on authentication alone, so it could still log the account out everywhere. Three of lukk's abilities close that:

AbilityRequired byFor
lukk.sessionsDELETE /auth/sessions, DELETE /auth/sessions/othersrevoking other sessions
lukk.accountPOST /auth/confirm-password, POST /auth/confirm-passkey, POST /auth/confirm-passkey/options, POST /auth/password, GET /auth/two-factor/recovery-codes, GET /auth/passkeysstep-up, changing the password, and reading the account's authenticators
lukk.account.deleteDELETE /auth/account, GET /auth/account/exporterasure and export — deliberately not covered by lukk.account

The two-factor and passkey routes are mounted only when their feature is on.

Both step-up routes are gated, not just the password one — confirm-passkey is the other way in, and gating only the password path would make the gate decorative for any account with a passkey enrolled.

lukk.account covers step-up because step-up is the gateway to everything that takes an account over permanently — enrolling a passkey, disabling two-factor, regenerating recovery codes. All of it needs the password, so a pinned token could never do it silently; but "a machine token must not log the account out everywhere" and "a machine token may enrol a permanent authenticator" cannot both be the rule.

Only pinned tokens are gated. A derived grant is a live human login and is never affected, so enabling abilities breaks nothing — and you don't need to add lukk.sessions to a normal user's grant. If a machine token genuinely needs these, pin them:

php
$pair = app(StartSession::class)($user->getKey(), [], ['ci.deploy', Abilities::SESSIONS]);

POST /auth/logout, POST /auth/refresh and POST /auth/session/claim are never gated: they act on the calling session alone and grant nothing, and a pinned token has to be able to end, renew and claim itself. Set features.gate_auth_routes to false to switch off the lukk.sessions and lukk.account gates — but not the lukk.account.delete one: erasure and export didn't exist before abilities, so switching it off would hand a narrow machine token an irreversible new power rather than restore an old one (why).

Responses ​

SituationStatus
No credential reached the gate401so a client learns it needs to log in, rather than giving up
Authenticated, insufficient scope403with WWW-Authenticate: Bearer error="insufficient_scope", error_description="…", scope="…" (RFC 6750 §3.1)
A pinned token on lukk's own routes403body message "This token was issued with a fixed set of abilities, and none of them is lukk.account." (naming the route's ability), with error_description="This token may not perform account-security operations." and scope set to the required ability

A refusal dispatches Lukk\Events\TokenAbilityDenied, carrying the subject, guard, family and the route's requirement — never the caller's granted list. A token probing for what it can still reach looks exactly like a run of these, and it is the only lukk-side signal that one is. Deliberately an event rather than a log line: ability names travel widely enough already.

On the client ​

lukk's UserResource publishes the token's abilities, which is the only channel a BFF-mode client has — the browser never sees the access token that carries the claim.

ts
const {
  can, cannot, canAny, canAll, // (ability | list) => boolean
  abilities,                   // ComputedRef<string[] | undefined> — the normalized grant, undefined when abilities aren't in use
  enforced,                    // ComputedRef<boolean> — the server uses abilities
  pinned,                      // ComputedRef<boolean> — this session's grant is pinned (a machine token)
  canManageSessions,           // ComputedRef<boolean> — lukk.sessions routes will be accepted
  canManageAccount,            // ComputedRef<boolean> — step-up and change-password will be accepted
  canDeleteAccount,            // ComputedRef<boolean> — erasure and export will be accepted
} = useLukkAbilities()
vue
<button v-if="can('orders.write')">New order</button>

WARNING

These are UI hints, never enforcement. The server gates every request on the token itself; this only lets you avoid rendering a button that comes back 403. A client-side check is one devtools console away from being true.

The list follows a refreshed token: a rotation re-derives the grant server-side, so the client reloads the user when one lands. That costs one request per refresh, and only for apps whose user resource actually publishes abilities.

Absent and empty mean different things. No abilities key means "this server doesn't use abilities" and can() returns true — so an app that upgrades without opting in doesn't find its UI blanked. An empty array means "in use, and this token was granted nothing", and can() returns false. enforced tells the two apart. A malformed value fails closed, and a logged-out visitor is granted nothing.

Gate lukk's own actions on canManageSessions / canManageAccount / canDeleteAccount, not on can('lukk.sessions'): lukk's gates apply to pinned tokens only, and a human login's derived grant never contains those names, so can() would hide "sign out other devices" from every ordinary user. The three are true for any signed-in non-pinned session, and for a pinned one only when it holds the ability (lukk.account does not satisfy canDeleteAccount).

The client matcher mirrors the server's rules exactly, and lukk's conformance suite runs both against the same gates on a live instance and requires them to agree, so the two cannot drift.

Multiple guards ​

features.abilities and features.gate_auth_routes are read through the active guard's resolved config, so you can set either for one guard alone — for example, publish a pinned-only grant on the admin guard:

php
'guards' => [
    'admin' => [
        // ...
        'features' => ['abilities' => true],
    ],
],

(gate_auth_routes already defaults to true, so a per-guard override is only useful to switch it off for one guard.)

Feature flag ​

Configuring abilitiesUsing turns abilities on by itself, so most installs never touch this. Set it when grants come only from pinned sessions and there is no callback at all:

php
'features' => [
    'abilities' => true,
],

Without it lukk cannot distinguish a token pinned to nothing from a server that doesn't use abilities, and a client would render the full privileged UI for the most restricted token you can issue.

Next: Account Deletion & Export