# How 10xGraph keeps your customers' data apart

> How 10xGraph keeps users and tenants apart for teams shipping AI to their customers: verified identity, roles with reach, 404 outside reach and tenant columns.

Source: https://10xgraph.com/blog/why-a-user-cannot-read-another-users-thread
Last updated: 2026-11-10

You are shipping an AI feature to your own customers. Each customer has users, and each user has conversations, memories and uploaded files. The first question a security reviewer asks is plain: can one customer read another customer's data? The second is whether you can show the answer rather than promise it.

10xGraph is open-source, self-hosted infrastructure for AI applications. You write the agents, tools and tasks; 10xGraph runs them behind an API server with authentication, role-based access, tenant isolation and durable state. This post walks through how the isolation part works, what you configure, and where the limits are. Every claim below maps to code you can read.

## Identity comes from the verified token

The server uses one identity per request, and it comes from the credential: a verified JWT, or the claims your own `BaseAuth` subclass returns. It becomes `config["user"]` for the run.

A client cannot supply its own. The config a client sends is filtered before use, and server-owned keys such as `user` and `user_id` are dropped (`RESERVED_CONFIG_KEYS` in `request_config.py`). Client messages are user messages only; a forged assistant or system turn is refused.

Identities that start with the reserved `__system__` prefix are refused at authentication, whichever auth method produced them. Shared data, such as agent memories, is owned by such identities, so no caller can authenticate as the owner of shared data.

## One `auth` object: roles, permissions and reach

All access control sits under one `auth` key in `10xgraph.json`. `method` is `none`, `jwt` or `custom`. Next to it you declare roles. Each role has permissions (`graph:invoke`, `threads:delete`, `tasks:trigger`, wildcards) and a required `reach`.

```json title="10xgraph.json"
{
  "auth": {
    "method": "jwt",
    "roles_claim": "roles",
    "tenant_claims": ["org_id"],
    "roles": {
      "member": {
        "permissions": ["graph:*", "threads:*", "store:*", "files:*"],
        "reach": "own"
      },
      "org_admin": {
        "inherits": ["member"],
        "permissions": ["admin:read"],
        "reach": "tenant"
      },
      "support": {
        "permissions": ["graph:read", "threads:read"],
        "reach": "all"
      }
    },
    "default_role": "member"
  }
}
```

Reach answers "whose data may this caller see?":

- `own`: the caller's own rows.
- `tenant`: every row that shares the caller's tenant values, such as the same `org_id`.
- `all`: every row of every tenant. The server logs a warning at boot for each role that has it, because it is the widest setting you can write.

Reach is required on every role; it has no default, so you cannot widen access by leaving a field out. A role with reach `tenant` is refused unless `tenant_claims` is set (`rbac.py`). A token that lacks a tenant claim is a 401, not a fallback to a default.

Two rules keep the model easy to reason about. Reach widens reads, lists, deletes and stop only. Runs and writes always stay with the owner, admins included, so an `org_admin` can review a teammate's thread but cannot run the agent inside it or edit its state.

## A thread outside your reach answers 404

When the permission check passes, the route still has to find the thread inside the caller's reach. A thread outside it answers 404 before any lock is taken and before the graph runs. A missing memory and a memory outside reach produce the same answer, so the response does not reveal which threads exist.

This applies across the surface: thread reads and deletes, message and state writes, stop, store memories, and uploaded files, which follow the same reach as the thread and memory data. With auth off, the same code runs with no identity and no owner (see the limits below).

## No route without a guard

Isolation is only as good as the weakest endpoint. Every route that is not public must carry a permission guard, and the server checks this at startup: if any non-public route is missing one, it refuses to boot and lists the routes. The only public paths are `/ping` and `/health/ready`. A developer who adds an endpoint and forgets the guard finds out at deploy time, not from an incident.

## Tenant columns you declare

Role checks sit in the API. The second layer sits in storage: every checkpointer and store query is filtered by tenant columns, and with row-level security on, Postgres enforces the same boundary itself.

You declare tenant columns on the checkpointer and the store. They are real indexed columns, filled from `config["user"]` when a thread or memory is created and never changed afterwards. They are never read from client input.

```python
from tenxgraph.storage.checkpointer import PgCheckpointer

checkpointer = PgCheckpointer(
    postgres_dsn=DATABASE_URL,
    redis_url=REDIS_URL,
    tenant_columns=["org_id"],
    row_level_security=True,  # optional
)
```

`PgVectorStore` takes the same `tenant_columns`, so memory and RAG data in the same Postgres are isolated the same way. A write without the tenant value raises, and a filter whose value is missing raises instead of being dropped.

The names must match. `tenant_claims` in the `auth` object and the tenant columns on the checkpointer and store are checked against each other at boot. A mismatch is an error in production and a warning elsewhere: a claim with no column leaves that data unisolated by it, and a column with no claim would fail every request.

`row_level_security=True` is opt-in. It adds native Postgres row-level security on the thread tables, with policies on the tenant columns, so the database itself enforces the boundary for the connection the server uses. The SQLite and in-memory checkpointers reject tenant options with a `ValueError`; they exist for local work, and silently ignoring the option would give a false sense of isolation.

## What this does not do

- **Auth is off until you configure it.** The bare server has no owner for any thread. The production template (`10xgraph init --template production`) turns it on. Running in production mode with auth off gives a warning, because any caller can then use every endpoint.
- **Tenant isolation needs columns you declare.** 10xGraph is not multi-tenant by default. Reach `tenant` and the boot check are only as strong as the columns you declare and the claims your tokens carry. Threads created before a column existed hold NULL there and stay invisible to tenant scopes until you backfill them.
- **Permissions apply per route, not per tool.** You can say a role may call `graph:invoke`. You cannot yet say user A may call your `refund_order` tool and user B may not. Tool-level rules belong in the tool, which receives the verified identity through dependency injection.
- **Your tools are your code.** The server isolates its own storage. A tool that queries your orders database without filtering by the caller's tenant can still leak. Use the identity you are given.
- **Identity provider and database roles stay with you.** Token issuing, key storage and Postgres credentials are part of your environment.

## Where to go next

The control-mapping page shows which controls the platform supports and which stay with you, for teams going through SOC 2 and ISO 27001 audits. The guides cover the details:

- [Tenancy and access](/docs/concepts/tenancy-and-access)
- [Multi-tenant data](/docs/guides/multi-tenant-data)
- [Authentication and authorization](/docs/server/auth)
- [Control mapping](/docs/production/control-mapping)

## Frequently asked questions

### What happens when user B asks for user A's thread?

The server answers 404 before the graph runs, the same answer as for a thread that does not exist. Storage filters every query by the caller's reach, so a foreign thread is invisible. This holds for invoke, stream, stop and every thread read, write and delete.

### Can a client choose its own user id or tenant?

No. Identity comes from the verified token. Server-owned keys such as user and user_id are dropped from the config a client sends, and an identity starting with __system__ is refused at authentication.

### Does an admin with reach all run inside another user's thread?

No. Reach widens reads, lists, deletes and stop only. Runs and writes stay with the owner of the thread, admins included.
