Authorization
Subscribe and publish are two rules on one channel. This page covers what each one defaults to, what the rule receives, why a route handler can be denied its own publish, and how to cut a subscriber off mid-session.
Most channel bugs come from one assumption: that a channel you can subscribe to is a channel you can publish to. It is not. The two verbs are decided separately, and the quiet default denies the second one.
What the builder decides
.authorize() is what makes a channel private. .presence() needs it first,
and the compiler enforces that order.
| Builder state | Visibility | Subscribe | Publish |
|---|---|---|---|
channel(…).events(…) | public | anyone | denied for a user context |
.authorize(fn) | private | fn | fn |
.authorize({ subscribe }) | private | the rule | the same rule |
.authorize({ subscribe, publish }) | private | subscribe | publish |
.presence(resolver) | presence | unchanged | unchanged |
The object form always needs subscribe. publish is the optional half.
Visibility also lands on the wire name. A private channel is delivered as
private-<pattern> and a presence channel as presence-<pattern>. A public one
carries no prefix.
Writing a rule
A rule is a boolean or a function. The function receives the generated
AppContext with typed params merged in. So session, collections, db
and your services are all there.
export default channel("chat-room-[roomId]")
.events({ message: z.object({ text: z.string() }) })
.authorize({
subscribe: async ({ params, session, collections }) => {
if (!session?.user) return false;
const member = await collections.roomMembers.findOne({
where: { room: params.roomId, user: session.user.id },
});
return member !== null;
},
publish: true,
});Two things deny the operation: returning false and running out of time. The
deadline is 5 seconds by default.
Throwing is the third way to be refused, and it is reported as its own thing. A
rule that throws fails closed exactly like a denial, so an error in your query
cannot leak access. But QUESTPIE logs it at error level with the cause and raises
channel_rule_failed instead of a verdict. Over SSE the subscription error names
the channel whose rule failed; on the channel HTTP routes the status is 500,
not 403, because the server never decided. Your rule's own message stays
server-side. Do not use a throw to mean no: return false.
Why your own handler gets denied
Publish has one bypass. A system context skips the publish rule entirely. Nothing else does.
| Context | Counts as system | Publish rule runs |
|---|---|---|
Job handler, app.createContext() | yes | no |
| HTTP request, with or without a session | no | yes |
| A hook fired during an HTTP request | no | yes |
POST /channels/publish from the browser | no | yes |
HTTP contexts are created with accessMode: "user". So channels.publish()
inside a route handler is judged by the same rule the browser is. On a public
channel there is no rule to fall back on, and the answer is no. Add
publish: true if the check belongs in your handler rather than the channel.
Revoking access mid-session
The subscribe rule runs when the subscription opens, and then not again.
Removing someone from a room later does not close the stream they already hold.
Call revokeAuthority() in the same transaction as the write that removed them.
await channels.revokeAuthority("chatRoom", {
params: { roomId },
subject: { kind: "user", id: removedUserId },
idempotencyKey: `chat-room:${roomId}:${removedUserId}:v2`,
});The subject kind is "user", "oauth" or "session", and its id must be
non-empty and at most 256 characters. The call advances a durable generation for
that exact channel and subject, then returns { generation, scope }.
scope tells you how wide the cut was. It is set by the transport, not by you.
| Transport | scope | What closed |
|---|---|---|
| SSE | "exact-subscription" | That one binding, with reason access_revoked |
| Pusher/Soketi | "principal-connections" | Every current connection for that principal |
SSE closes the single logical binding. Other channels on the same multiplexed stream keep running. Pusher's termination API is user-wide, so QUESTPIE terminates the principal's connections and lets the reconnect reauthorize. Bindings that are still allowed come straight back. The revoked one does not.
Managed providers have an in-flight window
Pusher cannot promise that a frame already accepted by the socket will not land while termination is running. The durable fence blocks new ordered dispatch, and reconnect reauthorizes. This is not zero-frame revocation.
Security settings
These live under realtime.channelSecurity in your runtime config.
| Option | Default | What it does |
|---|---|---|
trustedOrigins | none | Extra exact browser origins |
authorizationTimeoutMs | 5000 | Rule deadline. A timeout denies |
publishRatePerSecond | 10 | Token refill for each publish bucket |
publishBurst | 20 | Tokens each bucket holds |
Origins
The publish, auth and replay routes require an exact Origin header whenever
the request carries a cookie. GET /channels/config does not. Your app URL is
trusted already. Anything else must be listed. Each entry must be HTTPS, or
plain HTTP on localhost, 127.0.0.1 or [::1], and must carry no path. A
wildcard is rejected. There is no way to trust every origin.
Publish rate limits
Every client publish spends one token from two independent buckets, one keyed by
session and one by principal. Both must have a token or the request is refused.
An anonymous caller falls back to its forwarded IP. A refusal returns 429 with
a Retry-After header.
Next
Presence is the API .authorize() unlocks.
It answers who is in the room right now.
Transports
Channels run over server-sent events with no configuration at all, or over managed WebSockets with Pusher or Soketi. Switching is one edit in the runtime config and no edit at all in your application code.
Presence
A presence channel keeps a live roster of who is currently subscribed. You write one resolver, the client reads a typed member list, and the transport keeps it accurate across app instances.