Capabilities

Channel pattern to allowed operations — the matching rules, the operations, and how to scope a browser token.

The shape

A capability map is channel pattern → operations:

{
  "chat:*": ["subscribe", "publish", "presence"],
  "metrics.>": ["subscribe", "history"],
  "*": ["history"]
}

Every operation is checked against this map, on the channel it names. Nothing is implied: a pattern granting subscribe grants only subscribe, and a channel matching no pattern is refused.

The operations

operationwhat it covers
subscribereceiving messages on the channel, over the socket
publishsending messages to it, over the socket or REST
presenceenter, leave, update, and reading the member list
historyreading past messages and past presence events
amqp-subscribethe account's AMQP queue endpoints

history covering presence history too is deliberate: reading the past is one permission whatever it is the past of.

Matching

Two wildcards, the same engine the message router uses:

patternmatchesdoes not match
chat:*chat:lobby, chat:room-7chat:room-7:typing
metrics.>metrics.cpu, metrics.cpu.node1metrics
*everything
chat:lobbyexactly thatanything else

* is one segment. > is the rest of the tree, one segment deep or more.

Scoping a browser

The default when you pass no capability is * — every channel, every operation. That is right for a server key and wrong for anything a user can read.

await rest.auth.createTokenRequest({
  clientId: String(user.id),
  ttl: 3_600_000,
  capability: {
    [`chat:team-${user.teamId}`]: ['subscribe', 'publish'],
    [`presence:chat:team-${user.teamId}`]: ['presence'],
    [String(user.id)]: ['subscribe', 'publish'],
    'announcements:global': ['subscribe'],
  },
});

Read it as a sentence: this user may talk on their own team's channel, appear in its presence, receive messages addressed to them personally, and read the global announcements. Nothing else — including any other team's channel, which is the whole point of interpolating the id rather than granting chat:*.

A token scoped like this is worth very little to whoever lifts it out of the browser it was minted for. A key in the same place is worth your account.

Subsets

A token's capabilities must be a subset of the issuing key's. Either the patterns match exactly, or the key holds * or a > that covers them.

key   { "chat:*": ["subscribe", "publish"] }
token { "chat:*": ["subscribe"] }             ✓ fewer operations
token { "chat:lobby": ["subscribe"] }         ✗ not an exact pattern match
token { "chat:*": ["subscribe", "history"] }  ✗ history not on the key

The second case is the one that surprises people: narrowing a pattern is not subsetting, because the check is on the pattern, not on the set of channels it would expand to. If you want per-room tokens, the key needs * or chat.> — not chat:*.

The refusal happens at issue time and names the key, which is a far better error than a 403 at connect time naming a session.

When it fails

statusmeans
401missing, malformed, unknown or revoked credential
403authenticated, but this capability does not cover this channel and operation

Over the socket, a forbidden operation comes back as a Nack with code 403 and the connection stays open — one refused publish does not cost you the session.

401 is deliberately identical for "malformed" and "wrong". Whether the format parsed is not something a caller should be able to learn from the response.

Scoping is not optional

With auth on, a channel name is scoped to the calling key's account before it reaches any store. Two accounts publishing to chat:lobby are on two different channels and cannot see each other, and there is no way to ask for another account's channel — the scope comes from the credential, never from the request.

Capabilities narrow what you may do inside your account. They are not what keeps accounts apart; that happens a layer below and is not configurable.

On this page