# Access control (/docs/code/routes/access)

---
title: Access control
description: One .access() call decides who may run a route. Leave it off and anyone can. That is the opposite of how a collection behaves, and it is the mistake worth checking for twice.
kind: guide
package: questpie
---

How do you require a signed-in caller? And how do you mark a route open on
purpose rather than open by accident? Both answers are `.access()`.

## Why the default is open

A collection you never wrote a rule for rejects anonymous reads. A route you
never wrote a rule for serves them. The reason is that a collection falls back
to your app's `defaultAccess`, and past that to requiring a session. Routes
have no such chain. An undefined rule resolves to `true` and the handler runs.
The HTTP handler adds no wrapper of its own.

```ts title="Require a signed-in caller"
export default route()
	.post()
	.schema(z.object({ title: z.string() }))
	.access(({ session }) => !!session?.user)
	.handler(async ({ input, session, collections }) => {
		return collections.posts.create({
			title: input.title,
			authorId: session!.user.id,
		});
	});
```

## The shapes a rule can take

| Written as                 | Means                                            |
| -------------------------- | ------------------------------------------------ |
| `.access(true)`            | Public, on purpose. Says so in the OpenAPI spec. |
| `.access(false)`           | Nobody. Useful for parking a route.              |
| `.access((ctx) => …)`      | Your predicate. May return a promise.            |
| `.access({ execute: fn })` | The same rule under its operation name.          |
| No call at all             | Public, by accident or otherwise.                |

Routes have one operation, `execute`. There is no read/create/update/delete
split like a collection has, so the object form buys you nothing except
symmetry with the rest of the schema.

<Callout type="info" title="`.access(true)` is not the same as writing nothing">
	Both let everyone in. Only the explicit `true` marks the operation as public
	in the generated OpenAPI spec, which opts it out of the spec's security
	scheme. A route with no rule inherits that scheme in the docs while answering
	everyone in reality.
</Callout>

## What the rule receives

The full app context, so `session`, `db`, `collections`, `globals` and your own
`services` are on it. Three extras come with the request:

| Field     | What it is                                            |
| --------- | ----------------------------------------------------- |
| `locale`  | The resolved locale for this request.                 |
| `request` | The `Request`, when the route ran over HTTP.          |
| `params`  | The matched URL params, when the route ran over HTTP. |

The last two are absent when you run the route directly from code, so a rule
that reads `request.headers` has to handle `undefined`.

```ts
.access(async ({ session, collections }) => {
	if (!session?.user) return false;
	const member = await collections.members.findOne({
		where: { user: session.user.id },
	});
	return member?.role === "admin";
})
```

<Callout type="warn" title="A rule that throws denies">
	The rule runs inside a `try`/`catch` and any exception resolves to `false`.
	Nothing propagates to the caller. So a typo in your rule reads as a clean
	`403`, not as a crash you can see. Keep rules small.
</Callout>

## Getting a real message out

You cannot, from the rule. A denied route always answers the same `403`, with
`code: "FORBIDDEN"` and a generic message. When the caller deserves to know
why, let the rule pass and throw from the handler instead. The `reason` you
pass becomes the message the client reads.

```ts
.access(({ session }) => !!session?.user)
.handler(async ({ session, collections }) => {
	const member = await collections.members.findOne({
		where: { user: session!.user.id },
	});
	if (member?.role !== "admin") {
		throw ApiError.forbidden({
			operation: "read",
			resource: "route",
			reason: "Admins only",
		});
	}
	// …
});
// 403 { code: "FORBIDDEN", message: "Admins only" }
```

## Related

- [Access control](/docs/schema/access-control), the collection-side rules and
  the `session` typing this reuses.
- [Input and output](/docs/code/routes/validation), why a bad payload is
  rejected before your rule ever runs.
