# Set up API authentication and authorization

> Enable JWT or custom auth, enforce thread ownership, and protect your API with role-based access control in 10xGraph.

Source: https://10xgraph.com/docs/server/auth
Last updated: 2026-10-08

Authentication answers "who is calling the API?" Authorization answers "what are they allowed to do?" In production, you need both. This guide covers JWT and custom auth, thread ownership, role-based access control, and the production safety checklist.

## The big picture

When you enable auth, every API request must carry a credential (bearer token, API key, or custom). The server verifies it, extracts a `user_id`, and carries it through the graph execution. If you also enable authorization, the server checks whether that user is allowed to touch each thread before running it. The two are separate: you can use auth without authorization (global access), or inherit the authorization backend's isolation automatically when you add auth.

The cleanest production posture is: set `"auth": "jwt"` in `10xgraph.json`, set `JWT_SECRET_KEY` in `.env`, and let the default ownership backend lock each thread to its creator.

## Quick start with JWT

JWT is the easiest path: a stateless, standard bearer token that carries the user's identity.

1. **Install the JWT extra**

   ```bash
   pip install "10xgraph-api[jwt]"
   ```

2. **Set environment variables**

   Generate a secret and put these in your `.env` file:

   ```bash title=".env"
   JWT_SECRET_KEY=<generate-with: python -c 'import secrets; print(secrets.token_urlsafe(48))'>
   JWT_ALGORITHM=HS256
   ```

   The server refuses a short HS* secret at startup when `MODE=production`, and warns otherwise. RFC 7518 requires HMAC secrets to be at least 32 bytes for HS256.

3. **Enable JWT in 10xgraph.json**

   ```jsonc title="10xgraph.json"
   {
     "agent": "graph.agent:app",
     "env": ".env",
     "auth": "jwt",
     "authorization": "ownership"
   }
   ```

4. **Send bearer tokens from clients**

   Every request must carry the token in the `Authorization` header:

   ```bash
   curl -X POST http://localhost:8000/v1/graph/invoke \
     -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}]}'
   ```

> **Keep the signing secret safe**
>
> Anyone who holds `JWT_SECRET_KEY` can mint a token for any `user_id`. Never commit it, never bake it into a container image, and inject it at deploy time from a secret manager. Use HTTPS everywhere and rotate secrets intentionally with all issuers and consumers in sync.

## How do credentials reach the server?

### HTTP requests

The server reads a standard bearer credential from the `Authorization` header:

| Situation | HTTP status | Error code |
|---|---|---|
| No header | 401 | `REVOKED_TOKEN` |
| Expired token | 401 | `EXPIRED_TOKEN` |
| Bad signature, or mismatched `iss`/`aud` | 401 | `INVALID_TOKEN` |
| Valid signature but no `user_id` claim | 401 | `INVALID_TOKEN` |
| All auth/authorization checks pass | 200 | (success) |

To pin tokens to your service and prevent replay across services, set `JWT_ISSUER` and `JWT_AUDIENCE` in your environment. When either is set, tokens must carry the matching claim, so a token minted for another service is rejected even if it shares your key.

### WebSocket connections

WebSocket routes (`/v1/graph/ws`, `/v1/graph/live`) accept auth via the `Sec-WebSocket-Protocol` header (the standard subprotocol negotiation):

```javascript
const ws = new WebSocket(
  "ws://localhost:8000/v1/graph/ws",
  ["10xgraph-bearer", TOKEN]
);
```

The subprotocol list works as: the server looks for `10xgraph-bearer` (or the legacy `agentflow-bearer`), treats the next item as the token, and responds with the matching subprotocol. The server also accepts the `Authorization` header and, as a last resort, a `?token=` query parameter, which lands in URLs and logs. If no credential is found, the connection is rejected with close code `1008` (policy violation).

## How does the user reach your code?

The server copies the verified identity into the run config as `config["user"]` (the full decoded claims) and `config["user_id"]`. A tool or node can declare a parameter named `config` to receive it. Framework-injected parameters are hidden from the tool schema the model sees.

```python title="graph/tools/orders.py"
def list_my_orders(config: dict) -> list[dict]:
    """List the caller's recent orders."""
    user_id = config["user_id"]  # Always from the verified token, never a tool argument
    return db.orders_for(user_id)
```

Never take a user ID from a tool argument. The model chooses tool arguments; a prompt-injected model could ask to see another user's data. Instead, trust the server-verified `config["user_id"]`.

## Enforce thread ownership

The simplest and most important authorization rule is "a user sees only their own threads." Set `"authorization": "ownership"` to make threads private by default:

```jsonc title="10xgraph.json"
{
  "agent": "graph.agent:app",
  "auth": "jwt",
  "authorization": "ownership"
}
```

A thread is read, continued, stopped, fixed, or deleted **only by the user who created it**. Another user's request on that thread is rejected up front with status 403 before the model runs, for every action including `invoke` and `stream`. Listing threads shows only the caller's.

If you leave `authorization` unset, the default depends on `MODE`: ownership in production (secure by default), allow-all in development (frictionless). Your explicit choice always wins.

Ownership is looked up through the checkpointer's `aget_thread_owner` method. Use a durable checkpointer such as `PgCheckpointer` so owners survive restarts. For high-concurrency workloads, set `redis` in `10xgraph.json` (or `REDIS_URL` in your environment) to cache ownership lookups across workers:

```jsonc title="10xgraph.json"
{
  "auth": "jwt",
  "authorization": "ownership",
  "checkpointer": "graph.checkpointing:pg_checkpointer",
  "redis": "redis://localhost:6379"
}
```

Ownership is immutable and cached (in-process L1 + optional Redis L2), so after the first lookup a check is an in-memory hit, not a database round-trip per request.

## Custom authentication

Use custom auth when you already have an identity provider, API keys, or internal identity systems. Subclass `BaseAuth` and return a dict with at least `user_id`:

```python title="auth/custom_auth.py"
from tenxgraph_api import BaseAuth
from fastapi import HTTPException

class ApiKeyAuth(BaseAuth):
    def authenticate(self, request, response, credential):
        api_key = request.headers.get("X-API-Key")
        if not api_key or not verify_key(api_key):
            raise HTTPException(status_code=401, detail="Invalid API key")
        return {"user_id": "service-account", "roles": ["service"]}
```

Point the config at it with the object form:

```json title="10xgraph.json"
{
  "auth": { "method": "custom", "path": "auth.custom_auth:ApiKeyAuth" }
}
```

Every key you return (e.g. `roles`, `scopes`, `email`) is merged into `config["user"]`. Derive the `user_id` only from the verified credential, never from a client-set header or query parameter.

## Role-based access control

For finer-grained control without writing code, use the RBAC config block. It layers scope enforcement on top of thread ownership:

```json title="10xgraph.json"
{
  "authorization": {
    "backend": "rbac",
    "roles": {
      "admin": ["*"],
      "member": ["graph:invoke", "graph:stream", "checkpointer:read"],
      "viewer": ["graph:read"]
    },
    "default_scopes": ["graph:read"]
  }
}
```

Each endpoint requires a scope like `"graph:invoke"` or `"store:write"`. A role granting `"*"` gets every scope. `default_scopes` applies to everyone, including users with no role. If a user's resolved scopes is empty, they are locked out of everything (commonly a mistake with RBAC). Give `default_scopes` a sensible floor and test on staging first.

Available scopes: `graph:{invoke,stream,stop,fix,setup,read}`, `checkpointer:{read,write,delete}`, `store:{read,write,delete}`, `files:{upload,read}`, `config:read`.

## Production deployment checklist

### Verify auth is enforced

Test without a token:

```bash
curl -X POST http://127.0.0.1:8000/v1/graph/invoke \
  -H "Content-Type: application/json" \
  -d '{"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}]}'
```

Expected: HTTP 401, code `REVOKED_TOKEN`, before any graph execution.

Then test with a valid token:

```bash
curl -X POST http://127.0.0.1:8000/v1/graph/invoke \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}]}'
```

Expected: HTTP 200, normal graph response.

### Common failures to expect

| Error code | Likely cause | Fix |
|---|---|---|
| `REVOKED_TOKEN` | No credential presented | Attach `Authorization: Bearer <token>` |
| `EXPIRED_TOKEN` | Token `exp` is in the past | Refresh the token; check server/issuer clock skew |
| `INVALID_TOKEN` | Bad signature or no `user_id` claim | Verify `JWT_SECRET_KEY`, `JWT_ALGORITHM`, and that issuer emits `user_id` |
| Startup `ValueError`: "JWT_SECRET_KEY and JWT_ALGORITHM must be set" | One of the two env vars is missing with `"auth": "jwt"` | Set both `JWT_SECRET_KEY` and `JWT_ALGORITHM` |
| `Missing required scope: <resource>:<action>` | Identity lacks scope for this endpoint | Add scope to the role, or widen `default_scopes` |

### Boot guard

Every non-public route is checked at startup to carry a `RequirePermission` guard. If a route lacks one, the server refuses to start:

```
RuntimeError: Refusing to start: the following routes are not protected:
  - POST /v1/my-new-endpoint
```

This is intentional: a forgotten guard becomes a deploy-time error, not a silent open endpoint.

### Eval endpoints are unauthenticated

`/v1/evals/runs` and `/v1/evals/runs/{run_id}` serve eval results without auth. For that reason they are **not mounted when `MODE=production`**, and the generated `.dockerignore` keeps `eval_reports/` out of the image. On any other deployment, block these routes at your ingress or delete the eval files before serving.

## Production recommendations

1. Always set `"auth"` in production; never leave it `null`
2. Use HTTPS everywhere
3. Keep `JWT_SECRET_KEY` outside version control; inject it at deploy time
4. Use short-lived tokens and rotate secrets intentionally
5. Block or remove the eval endpoints on public deployments
6. Set `MODE=production` to disable `/docs` and `/redoc` (enable only if intentional)
7. Test both successful and rejected requests before release, asserting on the error code

## See also

- [Configure the API server](/docs/server/configure): Set auth, authorization, and other keys in `10xgraph.json`
- [Troubleshooting auth](/docs/troubleshooting/api-server): Debug auth and CORS failures
- [REST API: auth reference](/docs/reference/api-cli/auth): Full interface and status codes

## Frequently asked questions

### Which claims must my JWT contain?

Every token needs `exp` (expiration) and `user_id` claims. If you set JWT_ISSUER or JWT_AUDIENCE, the token must also carry matching `iss` or `aud` claims. A token without `user_id` is rejected with status 401, code `INVALID_TOKEN`.

### Why do I get 401 with code REVOKED_TOKEN?

The request had no Authorization header, or it was not a Bearer credential. Send `Authorization: Bearer <token>` on every HTTP request, or use the `10xgraph-bearer` subprotocol on WebSocket.

### Does ownership checking work with the in-memory checkpointer?

Yes, but in-memory data disappears on restart. Use PgCheckpointer in production so thread ownership persists across restarts.
