# Authentication (/docs/admin/auth)

---
title: Authentication
description: The admin admits one kind of session. A Better Auth session whose user holds the admin role. This page covers where that role comes from, what checks it, and how the first admin gets created.
kind: guide
package: "@questpie/admin"
---

Nobody has an account yet and the panel needs one. Then a second person joins
and needs one too. Both have a built-in answer. Neither one is a row you insert
by hand.

## The rule

The panel reads one field. `session.user.role`.

| The session      | `/admin`                                 |
| ---------------- | ---------------------------------------- |
| `role: "admin"`  | In.                                      |
| `role: "user"`   | Redirected to the login page.            |
| No `role` at all | Redirected to the login page.            |
| No session       | Redirected to the login page.            |
| A banned user    | Better Auth refuses to open the session. |

A signed-in user is not an admin user. Every check in the panel draws that line
the same way. The client guard sends the browser back to the login page. The
panel boots from one route, `getAdminConfig`. That route allows the admin role
only. Your own code calls the helpers in
[Protecting your own routes](/docs/admin/auth/protecting-routes).

## Turn it on

Register the module, then declare your auth config.

```ts title="src/questpie/server/modules.ts"
import { adminModule } from "@questpie/admin/modules/admin";

export default [adminModule] as const;
```

```ts title="src/questpie/server/config/auth.ts"
import { admin, bearer } from "better-auth/plugins";
import { authConfig } from "questpie/app";

export default authConfig({
	plugins: [admin(), bearer()],
	emailAndPassword: {
		enabled: true,
		requireEmailVerification: false,
	},
});
```

```bash
questpie generate
```

The generator folds the two together and rewrites `AppSession` and
`AppSessionUser`. After that `session.user.role` is typed everywhere. In route
handlers, hooks, access rules and services.

The `admin()` plugin contributes `role`, so keep it on. It also stamps
`role: "user"` on every user Better Auth creates, unless the caller names a
role. `bearer()` is what lets the panel's own user actions forward your session
as a token.

<Callout type="info" title="What runs with no `config/auth.ts` at all">
	`adminModule` pulls in `starterModule`. That module already ships `admin()`,
	`bearer()` and `openAPI()`, with email and password on and email verification
	required. Your own file merges on top. Plugins dedupe by id, so repeating one
	is safe.
</Callout>

## The first admin

The login page asks the server one question before anything else. Does any user
hold the admin role? If none does, it sends you to `/admin/setup`.

Setup wants a name, an email and a password of at least eight characters. It
creates the user through Better Auth, sets `role` to `"admin"`, and marks the
email verified. That last step is why a strict `requireEmailVerification` cannot
lock you out of your own panel. Then it returns you to the login page. You are
not signed in yet. Sign in.

Once one admin exists, setup is shut. `createFirstAdmin` counts admin users
first and refuses when the count is above zero. The route itself is public, and
that count is the only thing guarding it.

## The auth pages you get

| Path                     | What it is                           |
| ------------------------ | ------------------------------------ |
| `/admin/login`           | Sign in.                             |
| `/admin/setup`           | The first admin, while none exists.  |
| `/admin/forgot-password` | Asks for a reset email.              |
| `/admin/reset-password`  | Completes the reset from the link.   |
| `/admin/oauth/consent`   | Approves scopes for an OAuth client. |

The first four render on their own. No sidebar, no header, no session needed.
The consent screen is different. It sits behind the same admin check as the rest
of the panel. [MCP OAuth](/docs/agents/mcp-oauth) covers what it is for.

All five share one layout. To replace it, put a component at
`src/questpie/admin/components/admin-auth-layout.tsx`. The generator registers
it under `adminAuthLayout`, and every page in the table picks it up.

<Callout
	type="warn"
	title="Forgot password does nothing until you wire the email"
>
	Better Auth rejects the request with `RESET_PASSWORD_DISABLED` unless
	`emailAndPassword.sendResetPassword` is set. QUESTPIE does not set it for you.
	Add it in `config/auth.ts` and send through your own email adapter.
</Callout>

## Everyone after the first

Setup is shut, so the next admin comes from the panel itself. Open
`/admin/collections/user`. The list header has a **Create user** button. It asks
for a name, an email, a password and a role. Pick the admin role and that person
is an admin.

The button is not a plain insert. It calls Better Auth's `createUser`, so the
password is hashed the way a sign-up hashes it. It forwards your own session as
a bearer token. That is why `bearer()` has to stay in your plugin list.

Better Auth leaves `emailVerified` false on a user made this way. So keep
`requireEmailVerification: false`, as the config above does, or send a
verification email. Otherwise the new admin cannot sign in.

A user's own form carries a **Reset password** action, calling
`setUserPassword`. Reach for it when someone is locked out and your project has
no reset email yet.

<Callout type="warn" title="`InvitePage` and `AcceptInvitePage` do not work">
	Both are exported from `@questpie/admin/client`, and neither is registered as
	a page. They call `authClient.admin.createInvitation`, `getInvitation` and
	`acceptInvitation`. Better Auth's `admin()` plugin has no such endpoints.
	Invitations live in its `organization()` plugin.
</Callout>

## Checking the role yourself

Inside a collection or a global, an access rule reads the session directly.

```ts
.access({
	update: ({ session }) => session?.user?.role === "admin",
	delete: ({ session }) => session?.user?.role === "admin",
})
```

Do not write `(session?.user as any)?.role`. The field is on the generated type
already. If it is missing, your module list or your `config/auth.ts` is wrong.
A cast hides that instead of fixing it.

## Where each topic lives

| Topic                                           | Page                                                                 |
| ----------------------------------------------- | -------------------------------------------------------------------- |
| Guarding a route or a page of your own          | [Protecting your own routes](/docs/admin/auth/protecting-routes)     |
| What the auth tables expose, and what they shut | [Identity tables](/docs/admin/auth/identity-tables)                  |
| An auth write and a verification job as one     | [Auth writes that queue a job](/docs/admin/auth/transactional-queue) |
| Rules that allow, deny or filter a collection   | [Access control](/docs/schema/access-control)                        |
| Mounting the panel, branding and navigation     | [Configuration](/docs/admin/configuration)                           |
| The consent screen and the scopes behind it     | [MCP OAuth](/docs/agents/mcp-oauth)                                  |
| Who changed what, once they are in              | [Audit](/docs/admin/audit)                                           |

## Next

**[Protecting your own routes](/docs/admin/auth/protecting-routes)** covers the
guards in `@questpie/admin/server`, and the one case where they let a request
through instead of stopping it.
