Contextator
ENTR

Administration & security

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:

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

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 for the switch and 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.

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. 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.
  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.
  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 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:

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 runbook
POSTGRES_PASSWORD Applied only at cluster creation; change it later with ALTER USER and update .env — see 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.

Arrow keys to move, Enter to open.