# Authorization (/docs/client/channels/authorization)

---
title: Authorization
description: 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.
kind: guide
package: questpie
---

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.

```ts title="src/questpie/server/channels/chat-room.ts"
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.

```ts
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.

<Callout type="warn" title="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.
</Callout>

## 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](/docs/client/channels/presence)** is the API `.authorize()` unlocks.
It answers who is in the room right now.
