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.
-
Install the JWT extra
Terminal pip install "10xgraph-api[jwt]" -
Set environment variables
Generate a secret and put these in your
.envfile:.env JWT_SECRET_KEY=<generate-with: python -c 'import secrets; print(secrets.token_urlsafe(48))'> JWT_ALGORITHM=HS256The 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. -
Enable JWT in 10xgraph.json
10xgraph.json { "agent": "graph.agent:app", "env": ".env", "auth": "jwt", "authorization": "ownership" } -
Send bearer tokens from clients
Every request must carry the token in the
Authorizationheader: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):
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.
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:
{
"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:
{
"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:
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:
{
"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:
{
"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:
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:
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-endpointThis 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
- Always set
"auth"in production; never leave itnull - Use HTTPS everywhere
- Keep
JWT_SECRET_KEYoutside version control; inject it at deploy time - Use short-lived tokens and rotate secrets intentionally
- Block or remove the eval endpoints on public deployments
- Set
MODE=productionto disable/docsand/redoc(enable only if intentional) - Test both successful and rejected requests before release, asserting on the error code
See also
- Configure the API server: Set auth, authorization, and other keys in
10xgraph.json - Troubleshooting auth: Debug auth and CORS failures
- REST API: auth reference: 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.