# Security

> Contextator's threat model — a new project's MCP endpoint requires a token by default, but can be switched open — and the operator checklist for locking it down.

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

---
Contextator is built for a specific deployment: **a trusted team, on a private network or behind a
reverse proxy.** The dashboard has accounts and roles; the MCP endpoints have one door per project.
Read this page before exposing it anywhere else.

---

## The one thing to know

:::danger
A project's MCP endpoint **can be open**. If it is, anyone who can reach `/mcp/<project-name>` can
search and read every document indexed in that project — no account, no token.
:::

A new project is born **token required**: creating it mints a first token and shows the secret once, in
the creation dialog, so its endpoint is closed from the first second. A project created before that
became the default keeps whatever mode it already had — upgrading does not retroactively close or open
an existing project. Either way, the **MCP access** panel on the project page is where the mode is set,
and there are three of them: **open**, where the endpoint answers anyone; **token required**, after
which a client must send `Authorization: Bearer ctxm_…` or be answered `401`; or **account required**,
where the project's own memberships reach the endpoint through OAuth 2.1 and are checked on every
request. See
[Projects](/en/docs/projects/) for the switch and [Connecting AI Clients](/en/docs/connecting-ai-clients/) for
the client side.

**A token is a credential for the endpoint, not an account.** It carries no identity and no
per-document rules, so whoever holds it reads everything indexed in that project, and dashboard roles
do not reach `/mcp/*` at all.

So network position still decides most of it. An instance that leaves its projects open is readable by
anyone who can reach it, whatever the dashboard says about who may see what: keep the port private, or
put authentication in front of it.

The dashboard and the admin API are a separate door, and that one is always locked: they require a
personal account. `ADMIN_TOKEN` is not that lock — it is machine access to `/api/*` for scripts — and
neither of them protects `/mcp/*`. A project's own tokens are the only thing that does. See
[Accounts and Permissions](/en/docs/accounts-and-permissions/).

## Operator checklist

1. **Give each person their own account**, and none to anyone who does not need one. The first is
   created at `/setup` with a one-time code; the rest come from **Users** in the top-right menu. A
   `member` account reaches only the projects you add it to — see
   [Accounts and Permissions](/en/docs/accounts-and-permissions/). On a plain-HTTP LAN address set
   `AUTH_COOKIE_SECURE=0`, or the browser drops the session cookie and nobody can sign in at all.
2. **Keep `ADMIN_TOKEN` out of browsers**, and unset it if nothing scripted needs the API. It acts with
   root permissions on every endpoint; treat it like a root password.
3. **Set `SECRET_KEY`** to 32+ random characters (`openssl rand -hex 32`) before adding a private git
   repository or a Notion integration. Store it somewhere other than your database backup.
4. **Close the projects that should not be public.** **MCP access** on the project page: **token
   required**, then **New token** once per client, or **account required** if the people who may read it
   already have accounts here. A project left open is readable by anyone who can reach its URL — see
   [Projects](/en/docs/projects/).
5. **Keep the port off the public internet**, or terminate authentication in a reverse proxy — including
   for the projects you deliberately leave open. The default (`CONTEXTATOR_BIND=127.0.0.1`) already
   publishes the port on this machine only; `0.0.0.0` is the one setting that changes that, and is the
   moment to put a reverse proxy or a VPN in front. See
   [Configuration](/en/docs/configuration/#server).
6. **Keep `ALLOWED_DOC_ROOTS` narrow.** It is the boundary that decides which directories can be indexed.
7. **Mount documentation read-only** (`:ro`), as the shipped compose file does.
8. **Scope source tokens tightly.** A git token needs read access to the documentation repositories, nothing more.
9. **Do not index secrets.** Everything indexed is readable by every agent that can reach the endpoint —
   and by every holder of that project's MCP tokens, which carry no per-document rules.

## What is protected, and how

| Area | Control |
|------|---------|
| Dashboard and API | A personal account, or `ADMIN_TOKEN` for scripts. Every rule — instance role, per-project membership — is decided server-side from one policy table, so a route is protected the moment it is registered and a button restored in the browser still answers `403`. `/api/health` is exempt, and says less to a caller it does not know. The last active `root` account can never be deleted, demoted or disabled, and an `admin` cannot touch a `root` account or hand out the `root` role — see [Accounts and permissions](/en/docs/accounts-and-permissions/) for the full role model |
| Passwords | Salted `scrypt` (`node:crypto`, N=2¹⁵), never logged, never returned, not reversible. An unknown username is refused with the same message, and after the same work, as a wrong password |
| Sign-in attempts | Rate limited per IP address, and per account with a lockout that doubles from `AUTH_LOGIN_WINDOW_MIN` up to an hour |
| Dashboard sessions | A row in the database, not a signed cookie: only a hash of the token is stored, and the cookie is `HttpOnly`, `SameSite=Lax` and `Secure` over HTTPS. Bounded by `AUTH_SESSION_IDLE_MS` and `AUTH_SESSION_TTL_DAYS`, and revocable — changing a password ends that account's other sessions, and disabling, deleting or resetting an account ends all of them |
| Requests that change something | A cookie-authenticated write must come from this site (`Sec-Fetch-Site`, falling back to `Origin`/`Referer`) or it is refused `403`. CORS is deliberately left without credentials, so `ALLOWED_ORIGINS` cannot be used to read the API as a signed-in user |
| Projects you cannot see | A project a member has no access to answers `404`, the same as one that does not exist, so project ids cannot be probed |
| Webhook endpoint | Each git source has its own secret; the provider's signature is verified against the raw body **before** anything is queued |
| Stored source tokens | Git, Notion and Confluence credentials are encrypted with AES-256-GCM under `SECRET_KEY`; never returned by the API, never shown again. Credentials pasted into a repository URL are stripped before storage |
| Filesystem access | Local source directories must resolve inside `ALLOWED_DOC_ROOTS`; `..`, escaping symlinks and non-directories are rejected. Git subdirectories are resolved inside the checkout |
| `read_document` | Serves only paths that were indexed for that project — never an arbitrary filesystem path — capped at `max_tokens` (200–20000, default 4000) |
| Uploads and archives | Extracted into a scratch directory first, then copied with traversal rejection, dot-directory removal, portable-name checks, an extension filter and entry/size caps against decompression bombs |
| MCP endpoints | Per project: **token required** (the default for a new project), **open** or **account required**. In `token` mode a request without a live `ctxm_…` bearer is answered `401` with `www-authenticate: Bearer realm="<project>"`; in `account` mode a static token names nobody and is refused, and the client signs in over OAuth 2.1 instead. A credential that names an account is checked against that account's membership on **every** request in **every** mode, `open` included, and a non-member is answered `403` |
| OAuth discovery and registration | `/.well-known/oauth-*` and `/oauth/*` are reachable with no credential **by design** — the two discovery documents exist to be fetched by a client that has none yet, and registering a client grants that client nothing on its own. Approving a client is a signed-in, same-site-checked browser action, same as any other write in the dashboard |
| MCP tokens | 256 bits of randomness, stored only as a sha256 hash — shown once when minted and never recoverable. Scoped to one project, revocable, and the list keeps only the name, the leading characters and a last-used time |
| Browser requests to `/mcp/*` | `Origin` validated in **every** mode (DNS-rebinding protection). Command-line clients send no `Origin` and are always allowed |
| Project isolation | Every query is scoped by project; a session issued for one project is rejected on another. Deleting a project, revoking one of its tokens, or closing it to **token required** or **account required** closes its open sessions at once, rather than at the client's next request |
| Database | The embedded PostgreSQL listens on `127.0.0.1` inside the container and is not published |
| Process | The application runs as an unprivileged user; the entrypoint is root only long enough to fix volume ownership |
| The `/about`, `/privacy`, `/cookies`, `/terms` and `/license` pages | **Deliberately open.** They are static, read nothing from the database and carry no secret: a legal notice only the operator can read is not a notice. The dashboard itself is not among them — an anonymous visitor to `/` is redirected to `/login` and is never sent the application at all |

## Putting authentication in front

Example with nginx Basic authentication, leaving the streaming transports intact:

```nginx
location / {
    auth_basic           "Contextator";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass         http://127.0.0.1:3444;
    proxy_http_version 1.1;
    proxy_set_header   Host $host;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_buffering    off;
    proxy_read_timeout 1h;
}

# the webhook endpoint must stay reachable by the git host
location /api/webhooks/ {
    auth_basic off;
    proxy_pass http://127.0.0.1:3444;
}
```

Note that most MCP clients cannot send Basic credentials, so a proxy like this serves the dashboard
more than it serves agents — and the dashboard already asks for an account, so a second prompt in
front of it buys little. For `/mcp/*` itself a project token is the better fit, because it is an
ordinary `Authorization` header that MCP clients do know how to set. Keep the proxy, or a mutual-TLS
or VPN boundary, for the projects you deliberately leave open.

Behind a terminating proxy, set `AUTH_COOKIE_SECURE=1` rather than relying on `X-Forwarded-Proto`.

## Rotating things

| What | How |
|------|-----|
| Your own password | Account menu → **Change password**. It ends every other session of your account and leaves the one you are using |
| Someone else's password | Users page → **Reset password**. It hands out a temporary one, shown once, and signs that account out everywhere; they must replace it at their next sign-in |
| `ADMIN_TOKEN` | Change it in `.env` and restart. Update the scripts that carry it; dashboard sign-ins are unaffected, because they never used it |
| The setup code | Nothing to rotate. It stops working the moment the first account is created. While no account exists, a generated one is replaced on every restart |
| A git, Notion or Confluence token | Edit the source and paste the new token; the old ciphertext is overwritten |
| A webhook secret | Git: **Regenerate** in the source dialog, then update the repository settings. Confluence: **New secret** in the source dialog, then paste it into the existing webhook under Confluence Administration → Webhooks. Notion: nothing to regenerate — Notion mints that secret, so open a fresh verification window in the source dialog and have Notion send its token again |
| An MCP token | Mint a new one, move the client onto it, then **Revoke** the old one. Revoking closes that project's live MCP sessions immediately |
| `SECRET_KEY` | A four-step rotation, not a single edit: set `SECRET_KEY_PREVIOUS` to the key you are retiring, set `SECRET_KEY` to the new one and restart, run `npm run rotate-secret` (`docker exec contextator npm run rotate-secret` for a Docker install) to re-encrypt stored tokens under the new key, then remove `SECRET_KEY_PREVIOUS` and restart once it reports nothing left under it. No stored token needs re-entering. See the wiki's [Rotating `SECRET_KEY`](https://github.com/Contextator/Contextator/wiki/Security#rotating-secret_key) runbook |
| `POSTGRES_PASSWORD` | Applied only at cluster creation; change it later with `ALTER USER` and update `.env` — see [Backup and Data](/en/docs/backup-and-data/) |

## Things Contextator does not defend against

- **Prompt injection through documentation.** If a document says *"ignore your instructions and…"*,
  the agent may read it. Contextator returns text faithfully; it does not sanitise intent. Treat your
  indexed corpus as trusted input.
- **Rate limiting.** Signing in is limited, per IP address and per account. Nothing else is: not the
  rest of `/api/*`, and not `/mcp/*`, where a token-protected project is no exception — a client
  holding a token can ask as often as it likes. The network boundary is the control.
- **Two-factor authentication.** There is none; a local account is a username and a password. Single
  sign-on is separate — an instance with an OIDC provider configured offers it alongside a password —
  and the record of who did what is the audit log at `#/~audit` in the dashboard, root and admin only,
  not just the server log.
- **Per-user or per-document rules on `/mcp/*`.** A project token is a credential for the whole
  endpoint, not an identity. It grants every document in the project, to whoever holds it, with no
  audit trail beyond *this token was last used at*. Dashboard roles do not reach `/mcp/*` — a `viewer`
  membership grants nothing there, and nothing narrows a token to part of a project. Bridging the two
  is planned and not built; until it is, give each client its own token and revoke the ones you no
  longer recognise.
- **Hiding which projects exist.** A token-protected project answers `401` where an unknown name
  answers `404`, so anyone who can reach the server can still learn a project name.

## Reporting a vulnerability

Report it privately to the repository owner rather than in a public issue.
