Set up API authentication and authorization

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

  • 6 min read
  • 11 sections
  • Updated
  • v0.10.0
  • Markdown

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

    Terminal
    pip install "10xgraph-api[jwt]"
  2. Set environment variables

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

    .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

    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:

    Terminal
    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"}]}]}'

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.

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:

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:

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:

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:

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:

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:

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

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

plaintext
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

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.
Last updated for v0.10.0Edit this page on GitHubReport an issue