# Accounts and Permissions

> How sign-in, roles, project membership, passwords and sessions work in the dashboard — and what to do when nobody can sign in.

- Updated: 2026-09-25
- Source: https://contextator.com/en/docs/accounts-and-permissions/
- Language: en-US
- Author: Muhammet Şafak

---
The dashboard and the admin API are behind a personal account. Everyone who uses this instance signs
in as themselves, and what they can do follows from their role.

- [First run: the setup code](#first-run-the-setup-code)
- [Signing in](#signing-in)
- [Roles](#roles)
- [The Users page](#the-users-page)
- [Members of one project](#members-of-one-project)
- [Passwords](#passwords)
- [Sessions](#sessions)
- [`ADMIN_TOKEN`: access for scripts](#admin_token-access-for-scripts)
- [When nobody can sign in](#when-nobody-can-sign-in)
- [What accounts do not cover](#what-accounts-do-not-cover)

---

## First run: the setup code

A brand-new instance has no accounts, so the first thing it asks for is a **setup code**. Its only job
is to make sure the operator, and not whoever reaches the server first, claims the first account.

Start the server and watch the log:

```bash
docker compose logs -f
```

While no account exists, every start prints a box:

```
┌─ Contextator first-run setup ───────────────────────────────────────────┐
│ No user accounts exist yet; the dashboard is waiting for its first one. │
│                                                                         │
│   Open   http://localhost:3444/setup                                    │
│   Code   KRTW-9MHD-2PQF                                                 │
│                                                                         │
│ A new code is printed on every start until that first account exists.   │
└─────────────────────────────────────────────────────────────────────────┘
```

Open **http://localhost:3444/setup** — the dashboard redirects you there anyway — and fill in the form:

| Field | What to enter |
|-------|---------------|
| **Setup code** | The code from the box. Case, dashes and spacing are ignored, so type it however it reads |
| **Username** | Lowercase letters, digits, `.`, `-` or `_`, 2–63 characters, starting with a letter or digit |
| **Display name** | Optional; what the top bar shows instead of the username |
| **Password** | At least 12 characters. Nothing else is required of it |

**Create the root account** makes the account, signs you in, and closes setup. From then on `/setup`
redirects to `/login` and the code means nothing — there is nothing here to rotate later.

### Choosing the code yourself

Set it in `.env` before the first start and you never have to go looking for it:

```bash
SETUP_CODE=ekip-kurulum-2026
```

A code you chose is never echoed into the log; the box says to use the one from your `.env`. Clear the
variable and restart to have a fresh one generated and printed instead.

:::tip
Lost the code? Restart the server. A new one is printed on every start until the first account exists
— that is the whole recovery story.
:::

## Signing in

`/login` asks for a username and a password, and nothing else. A few things it does on purpose:

- **A wrong password and an unknown username give the same answer** — *Wrong username or password* —
  after the same amount of work, so the form cannot be used to find out who has an account here.
- **A disabled account is told so** (*This account is disabled*) once the password is right.
- **Too many attempts are slowed down twice over.** Per IP address, `AUTH_LOGIN_MAX_ATTEMPTS` failures
  inside `AUTH_LOGIN_WINDOW_MIN` minutes get a `429` with a `Retry-After`. Per account, the same
  threshold starts a lockout that doubles each further attempt, capped at an hour.
- **A temporary password sends you straight to `/change-password`** and nothing else works until you
  replace it.

Signing in sets one cookie, `contextator_session`. It is `HttpOnly`, `SameSite=Lax`, scoped to `/`, and
`Secure` whenever the instance is served over HTTPS.

:::warning
On a plain-HTTP LAN address, set `AUTH_COOKIE_SECURE=0`. Otherwise the browser is told to keep the
cookie for HTTPS only, drops it, and the dashboard bounces back to `/login` forever.
:::

## Roles

There are three **instance roles**, and on top of them a per-project role for members:

| | root | admin | editor | viewer |
|---|:--:|:--:|:--:|:--:|
| See the project list | all | all | its own | its own |
| Create / delete a project | ✓ | ✓ | – | – |
| Re-index (incremental or full) | ✓ | ✓ | ✓ | – |
| See a project's sources | ✓ | ✓ | ✓ | ✓ |
| Add, edit, delete, sync or test a source | ✓ | ✓ | ✓ | – |
| Upload and delete files | ✓ | ✓ | ✓ | – |
| See and regenerate a webhook secret | ✓ | ✓ | ✓ | – |
| See a project's MCP tokens | ✓ | ✓ | ✓ | ✓ |
| Mint and revoke an MCP token | ✓ | ✓ | ✓ | – |
| Change MCP access — require a token or an account, or make the endpoint open again | ✓ | ✓ | – | – |
| See a project's members | ✓ | ✓ | ✓ | ✓ |
| Add, change or remove a member | ✓ | ✓ | – | – |
| Manage accounts | ✓ | ✓ (not root ones) | – | – |

`editor` and `viewer` are **memberships, not roles**: the third instance role is `member`, and a
`member` account gets `viewer` or `editor` on each project it is added to. `root` and `admin` reach
every project without being listed on any of them.

Three rules hold no matter who asks:

- **The last active root account cannot be deleted, demoted or disabled.** An instance can never lock
  itself out of its own user management.
- **An admin cannot touch a root account** and cannot hand out the `root` role. Only another root can.
- **Nobody can disable, demote or delete themselves.**

### A project you cannot see does not exist

A `member` sees only the projects it is listed on — in the project list, and in every URL. Asking for
one it has no access to answers `404`, the same as a project that was never created, so project ids
cannot be probed by trying them.

### The buttons are not the rule

The dashboard hides what you may not do, but every rule is enforced again on the server, from one
table that covers routes by their method and shape rather than one by one. A button brought back with
the browser's developer tools still answers `403`.

## The Users page

Open the account menu in the top right and choose **Users** (`#/~users`). It is there for `root` and
`admin` accounts only.

One row per account: its username and display name, its role, whether it is **active**, **disabled** or
has a **password change pending**, how many projects it reaches, and when it last signed in.

| Button | What it does |
|--------|--------------|
| **New user** | Creates an account — see below |
| **Edit** | Display name, e-mail, role, and the active switch |
| **Reset password** | Hands out a new temporary password and signs that account out everywhere |
| **Disable** / **Enable** | A disabled account cannot sign in, and its open sessions end at once |
| **Delete** | Removes the account, its sessions and its memberships. Click twice to confirm |

A button you are not allowed to press is greyed out and says why when you hover it: *the last active
root account cannot be removed, demoted or disabled*, *you cannot change your own role or disable
yourself*, *only a root account can change another root account*.

### Creating an account

**New user** asks for:

| Field | Notes |
|-------|-------|
| **Username** | Lowercase, 2–63 characters. It cannot be changed afterwards |
| **Display name**, **E-mail** | Optional |
| **Role** | `member` or `admin`. `root` appears only if you are root yourself |
| **Password** | Leave it empty and the server generates one; **Generate** fills one in for you |
| **Must change password at first sign-in** | On by default |

The password is shown **once**, in a dialog with a copy button, and is never recoverable afterwards.
Send it to the person however you send such things; they replace it the first time they sign in.

For a `member`, creating the account is only half the job — it reaches nothing until you add it to a
project.

## Members of one project

Select a project and scroll to **Members**. This panel is about `member` accounts: `root` and `admin`
are not listed there because they already reach every project.

- **Add member** offers the active `member` accounts that are not on this project yet. If the list is
  empty, create one on the [Users page](#the-users-page) first.
- The role dropdown on each row switches between **viewer** and **editor**:
  - **viewer** — reads the project: its sources, its documents, its index history, its MCP tokens.
  - **editor** — everything a viewer does, plus adding, editing, syncing and deleting sources,
    uploading and deleting files, re-indexing, and minting or revoking MCP tokens.
- **Remove** takes the account off the project. It stops seeing the project at all.

Only `root` and `admin` can add, change or remove a member; anyone else sees the list read-only.

:::warning
Membership reaches `/mcp/*` whenever the caller has an identity at all. A credential that names an
account is checked against that account's membership in **every** mode, **open** included — so someone
who signs a connector in as themselves reads the project only if they are a member of it, and is
answered `403` otherwise, on an open project as much as a closed one.

What the mode decides is the caller who names nobody. While a project is **open**, a stranger with its
URL reads it anonymously, exactly as a `viewer` does; **token required** narrows that to whoever holds
a token, still without an identity; **account required** refuses both, so an identity is the only way
in. See [Projects](/en/docs/projects/).
:::

## Passwords

`PASSWORD_MIN_LENGTH` (12 by default) is the only rule, along with two obvious refusals: a password
cannot be the username, and a new one cannot be the old one. No composition rules, no blocklist, 128
characters at most.

Passwords are stored as salted `scrypt` hashes. They are never written to the log, never returned by
the API, and cannot be turned back into the password — not by you, not by anyone with a copy of the
database. That is why a forgotten password is replaced rather than looked up.

### Changing your own

Account menu → **Change password**. It asks for the current one, then the new one twice. Saving it
signs out **every other session of your account** and keeps the one you are using.

### Temporary passwords

An account created with a temporary password, or reset with one, carries `must change password` until
it is replaced. While that stands, the person can sign in and change the password and nothing else —
every other page and endpoint answers `403 password_change_required` (*Choose a new password before
using the dashboard*).

## Sessions

A sign-in is a row in the database, not just a cookie, which is what makes signing someone out
actually take effect. Only a hash of the session token is stored.

| | Default | Setting |
|---|---|---|
| Idle before you must sign in again | 12 hours | `AUTH_SESSION_IDLE_MS` |
| Longest a session can live, however actively used | 30 days | `AUTH_SESSION_TTL_DAYS` |

Sessions end early when:

| What happened | Which sessions end |
|---------------|--------------------|
| You changed your own password | Every other session of your account |
| An admin reset an account's password | All of that account's |
| An account was disabled or deleted | All of that account's |
| An account was demoted to a lower role | All of that account's (a promotion does not) |
| **Sign out** in the account menu | The one you are using |

A role change applies immediately in any case — the role is read from the database on every request,
not frozen into the session.

The dashboard has no page listing your own open sessions; the API does, at `GET /api/auth/sessions`,
along with `DELETE /api/auth/sessions?scope=others|all` to end them. See
[Admin API](/en/docs/admin-api/).

## `ADMIN_TOKEN`: access for scripts

`ADMIN_TOKEN` is still there and still works, but it is not the dashboard's lock any more. It is
**machine access to `/api/*`** — `Authorization: Bearer <token>` — acting with `root` permissions, for
scripts, CI and cron jobs that have no person behind them.

```bash
curl -s http://localhost:3444/api/projects -H "Authorization: Bearer $ADMIN_TOKEN" | jq
```

- **Do not paste it into a browser.** The dashboard does not accept it and has nowhere to put it;
  people sign in with an account. Treat the token like a root password.
- It has no account behind it, so the endpoints that are about *your* account — changing a password,
  listing your own sessions — refuse it with `403 token_has_no_account`.
- It is exempt from the same-site check that guards cookie requests, because a bearer token is not
  something another site can make your browser send.

Leave it unset if nothing scripted needs the API.

## When nobody can sign in

In order of how much you have to reach for:

1. **Another root or admin account.** Users page → **Reset password** on the locked-out account.
2. **`ADMIN_TOKEN`.** Set it in `.env`, `docker compose up -d`, and hand out a new password over the
   API — this is the escape hatch that needs no account at all:

   ```bash
   export API=http://localhost:3444/api
   ID=$(curl -s $API/users -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.username=="alice") | .id')
   curl -s -X POST $API/users/$ID/password -H "Authorization: Bearer $ADMIN_TOKEN" \
     -H 'content-type: application/json' -d '{}' | jq -r .temporaryPassword
   ```

   The same token can create a fresh `root` account with `POST /api/users` if every account is gone.
   See [Admin API](/en/docs/admin-api/).
3. **The reset script**, on an installation run from source:

   ```bash
   npm run reset-password -- alice
   ```

   It takes the username as its only argument and asks nothing else. It talks to the database
   directly, so it needs neither a session nor `ADMIN_TOKEN` — only the `.env` that points at the
   database. It prints the account and a new password once, marks it as needing a change, re-enables
   the account if it was disabled, clears any lockout, and revokes every session it had. Given a name
   that does not exist, it lists the accounts that do.

   ```
     Account:   alice (admin)
     Password:  hT4mQpbWnK9rXdzLvCuA

     Shown once. Sign in with it; the dashboard will ask for a new one straight away.
   ```

   The script is not part of the Docker image — it needs the repository and its development
   dependencies — so on a container install use `ADMIN_TOKEN` above.

If an instance has no accounts at all, it goes back to asking for a setup code: restart it and open
`/setup`.

## What accounts do not cover

Accounts govern the dashboard and the admin API, and they reach `/mcp/*` too — but only for a caller
that names one. An anonymous request to an **open** project, and a `ctxm_…` token on a closed one, name
nobody: neither knows anything about who you are here, so a `viewer` membership grants nothing there and
a token grants everything in its project to whoever holds it. A credential that does name an account is
a different thing, and it is checked against that account's membership on every request in every mode —
being a member is what admits it, not the mode. **account required** is the mode that leaves no other
way in. `ADMIN_TOKEN` is not an account and reaches none of it.

Both doors, side by side: [Security](/en/docs/security/).
