-
Notifications
You must be signed in to change notification settings - Fork 6
Authentication and API Keys
This guide shows you how to authenticate against a local RoboSystems stack from scripts, curl, MCP clients, and SDKs. The one rule to internalize up front: backend and programmatic access uses the X-API-Key header, while JWT/Bearer tokens are a frontend concern.
RoboSystems has two doors into the same building. Both resolve to the same User; they differ in who carries the credential and how it is issued.
| Method | Who uses it | Header | Where it comes from | Lifecycle |
|---|---|---|---|---|
| API key | Scripts, curl, MCP clients, SDKs, CI |
X-API-Key |
just demo-user writes one to .local/config.json; create more via POST /v1/user/api-keys
|
Long-lived; you revoke or expire it explicitly |
| JWT / Bearer | The browser frontends (robosystems-app, roboledger-app, roboinvestor-app) | Authorization: Bearer … |
POST /v1/auth/login issues a token |
Short-lived, auto-refreshed by the frontend session layer |
For everything in this guide — curl, MCP, SDK smoke tests — use the API key. You never need to call login for backend testing.
- Overview
- Prerequisites
- Getting Your Local API Key
- The X-API-Key Path
- Worked Examples
- API Key Lifecycle
- Graph-Scoped Access
- The JWT / Bearer Boundary
- Single Sign-On (SSO)
- Troubleshooting
- Related Documentation
- Support
Authentication in RoboSystems is credential-in-header, validated by a FastAPI dependency before any handler runs. There is exactly one route you can hit anonymously — GET /v1/status, the health probe — and everything else requires a valid credential. A request carries either an X-API-Key header (programmatic) or an Authorization: Bearer header (frontend session); both resolve to the same authenticated User.
Access is layered. A valid credential authenticates you; access to a specific graph_id is checked separately by the graph-scoped dependency. That distinction drives the most important status-code rule on the platform: 401 means "no/invalid credential," 403 means "valid user, no access to that graph." Keep them straight and most auth debugging resolves itself.
The full endpoint surface — request/response schemas, every field, every status code — lives in the live OpenAPI docs at http://localhost:8000/docs (or the hosted API Documentation). This page covers the concepts and the day-to-day tasks; it does not re-tabulate the endpoint surface.
- A running local stack. See Quick Start for
just startand the full setup. The API listens onhttp://localhost:8000. -
jqinstalled (used to read the key out of.local/config.jsoninline). - A demo user with an API key — created by
just demo-user(next section).
Run the demo-user recipe from the robosystems/ directory. It creates (or reuses) a demo user, issues an API key, and writes everything to .local/config.json:
just demo-userThe resulting .local/config.json looks like this:
{
"user": { "id": "...", "name": "...", "email": "..." },
"user_id": "...",
"email": "demo_user_...@example.com",
"password": "...",
"api_key": "rfs<64 hex chars>",
"base_url": "http://localhost:8000",
"created_at": "YYYY-MM-DD HH:MM:SS",
"graphs": { "<slot>": { "graph_id": "kg..." } }
}The two fields you read most often:
-
api_key— yourX-API-Keycredential. Read it inline withjq -r .api_key .local/config.json. -
graphs.<slot>.graph_id— a graph identifier, for graph-scoped requests. Read it withjq -r '.graphs.<slot>.graph_id' .local/config.json.
Important: Read the key inline from the config file rather than copy-pasting a literal. Do not stash it in a shell variable like TOKEN="..." — that does not persist reliably into curl calls. The inline $(jq -r .api_key .local/config.json) form is the canonical pattern across all RoboSystems docs.
For all programmatic access, send your key in the X-API-Key header:
-H "X-API-Key: $(jq -r .api_key .local/config.json)"The header name is exactly X-API-Key (case-insensitive per HTTP, but use this spelling). On each request the auth dependency resolves the key to a User, updates the key's last_used_at, and attaches the user to the request context. The same header works for REST endpoints, the graph-scoped GraphQL endpoint, and MCP clients.
Precedence — Bearer wins. If a request carries both an Authorization: Bearer header and an X-API-Key header, the Bearer token is tried first and takes precedence. If a Bearer header is present it must validate or the request is rejected with 401 — the server does not fall back to the API key. The API key path is only taken when no Bearer header is present. This matters when debugging an SDK or browser tool that sets both: a stale Bearer token will 401 even though your API key is good. Strip the Authorization header when testing with an API key.
GET /v1/status is the only routinely-anonymous endpoint — it is the load-balancer health probe. No credential needed:
curl http://localhost:8000/v1/statusOutput:
{"status":"healthy","timestamp":"...","details":{"service":"robosystems-api","version":"..."}}
Note that the root / serves the Swagger UI (HTML), and /openapi.json serves the live OpenAPI spec — neither is a health check. Use /v1/status.
Read your current user with the key pulled inline from config:
curl -X GET "http://localhost:8000/v1/user" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json"A 200 with your user record confirms the key is valid. A 401 means the key is missing, malformed, expired, or revoked.
curl -X GET "http://localhost:8000/v1/graphs" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json"The extensions GraphQL endpoint is graph-scoped at the URL level: the graph_id is a path parameter, never a query argument. The same X-API-Key authenticates you, and the endpoint validates your access to that specific graph before the resolver runs:
GRAPH_ID=$(jq -r '.graphs.roboledger.graph_id // .graph_id' .local/config.json)
curl -X POST "http://localhost:8000/extensions/$GRAPH_ID/graphql" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"query": "{ fiscalCalendar { closedThrough closeTarget } }"}'The fiscalCalendar query above is illustrative — substitute any field your deployment exposes. The thing to remember is the URL is the scope:
Right:
{ entity { … } }—graph_idcomes from the URL path. Wrong:{ entity(graphId: "kg_x") { … } }— GraphQL queries do not take agraphIdargument.
API keys are managed under /v1/user/api-keys. The full request/response schemas are in the OpenAPI docs; the concepts you need to operate them are below.
A RoboSystems API key is the literal prefix rfs followed by 64 hex characters (67 characters total), e.g. rfs9ce10.... Keys are stored hashed (bcrypt) — never in plaintext. The server cannot show you the raw key after it is created; it only ever exposes the 8-character prefix (e.g. rfs9ce10) used to identify the key in lists.
curl -X POST "http://localhost:8000/v1/user/api-keys" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"name": "CI pipeline key", "description": "for nightly smoke tests"}'The request fields are name (required), description (optional), and expires_at (optional, ISO-8601, must be in the future):
curl -X POST "http://localhost:8000/v1/user/api-keys" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{
"name": "CI pipeline key",
"description": "for nightly smoke tests",
"expires_at": "2026-12-31T23:59:59Z"
}'The response contains the key metadata (api_key) plus the raw key:
{
"api_key": {"id": "uak_...", "name": "CI pipeline key", "prefix": "rfs9ce10", ...},
"key": "rfs<64 hex chars>"
}
Important: The raw key is returned exactly once, at create time. Capture it immediately. After this response you can never retrieve it again — only the prefix is visible going forward. If you lose a key, revoke it and create a new one.
curl -X GET "http://localhost:8000/v1/user/api-keys" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json"Each entry exposes id, name, description, prefix, is_active, last_used_at, expires_at, and created_at — but never the raw key.
PUT /v1/user/api-keys/{api_key_id} updates mutable metadata (name and description). Use the id (a ULID prefixed uak) from the list response:
curl -X PUT "http://localhost:8000/v1/user/api-keys/uak_..." \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"description": "rotated 2026-06-11"}'curl -X DELETE "http://localhost:8000/v1/user/api-keys/uak_..." \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json"Revocation deactivates the key and invalidates its cached lookup. The effect is immediate but cache-bounded — the API-key validation cache has a short TTL, so a revoked key stops working within that window rather than on the next millisecond. Plan key rotation accordingly: create the replacement, cut traffic over, then revoke the old key.
Authentication and authorization are separate steps. Your API key authenticates you against the platform. Access to a specific graph is a second check performed by the graph-scoped dependency layer: it validates that your user has access to the graph_id in the URL before the handler or GraphQL resolver runs.
The practical consequence is the status-code split:
| Status | Meaning | Typical fix |
|---|---|---|
| 401 | No credential, or the credential is invalid/expired/revoked | Check the X-API-Key header; confirm the key still exists and is active |
| 403 | Valid user, but no access to that graph_id
|
Confirm the graph belongs to (or is shared with) your user; check the graph id is correct |
This applies uniformly across the REST graph endpoints (/v1/graphs/{graph_id}/...), the extensions command and view operations (/extensions/{domain}/{graph_id}/operations/...), and the GraphQL endpoint (/extensions/{graph_id}/graphql). The same key, validated against per-graph access. See Graphs and Multi-Tenancy for the multi-tenant model and how graph access is granted.
The browser frontends authenticate with JWT Bearer tokens, not API keys. This is the boundary you do not cross when testing the backend.
The flow, at a glance:
| Endpoint | Purpose |
|---|---|
POST /v1/auth/login |
Exchange credentials for a Bearer token |
GET /v1/auth/me |
Identify the current user from a Bearer token |
POST /v1/auth/refresh |
Refresh the session (within a grace window) |
POST /v1/auth/logout |
End the session |
login returns a short-lived Bearer JWT that the frontend session layer carries on subsequent requests and refreshes automatically before it expires. The token is signed with HS256.
For backend testing you never need this path. There is no reason to call login from curl when you have an API key — the API key authenticates the same User against the same endpoints. If you find yourself wrangling Bearer tokens in a shell script, you are on the wrong door; switch to X-API-Key. The Bearer path exists for browser sessions across the three frontend domains, where token-based session handling and refresh are the right model.
RoboSystems supports SSO so a user authenticated in one frontend app (for example robosystems-app) can hand off to a sibling app (roboledger-app, roboinvestor-app) without re-entering credentials. It is a three-step token handoff:
| Step | Endpoint | What it does |
|---|---|---|
| 1. Generate | POST /v1/auth/sso-token |
The originating app (authenticated) mints a short-lived SSO token |
| 2. Exchange | POST /v1/auth/sso-exchange |
The target app exchanges that token for a session |
| 3. Complete | POST /v1/auth/sso-complete |
The session is finalized in the target app |
SSO is a frontend-to-frontend concern built on the Bearer/session layer. You do not invoke it from curl when testing the backend — like the JWT path, it sits on the frontend door. It is documented here so the full authentication picture is complete; for programmatic access, stay on the X-API-Key path.
The credential is missing, malformed, expired, or revoked. Check, in order:
- The
X-API-Keyheader is present and spelled correctly. - You are reading a live key:
jq -r .api_key .local/config.jsonreturns anrfs...value, notnull. - The key has not been revoked or expired (list your keys; confirm
is_activeis true andexpires_atis in the future). - No stale
Authorization: Bearerheader is also being sent (see the next item).
Bearer-wins precedence is biting you. If the request carries an Authorization: Bearer header alongside X-API-Key, the Bearer token is validated first and the request does not fall back to the API key. A stale or expired Bearer header will 401 regardless of how good your API key is.
Solution: Remove the Authorization header from the request and send only X-API-Key. This is most common with SDKs or browser tooling that set both.
You are authenticated, but your user does not have access to the graph_id in the URL. This is an authorization failure, not a credential failure.
Solution: Confirm the graph id is correct and that the graph belongs to (or is shared with) your user. See Graphs and Multi-Tenancy.
The raw key is shown exactly once, at create time, and is stored hashed thereafter — there is no way to recover it.
Solution: Revoke the lost key (DELETE /v1/user/api-keys/{id}) and create a new one.
The graph-scoped GraphQL endpoint takes graph_id from the URL path, not as a query argument. Passing graphId inside the query body is wrong.
Solution: Put the graph id in the URL (/extensions/{graph_id}/graphql) and remove any graphId argument from the query — { entity { … } }, not { entity(graphId: "...") { … } }.
/v1/status is the only unauthenticated endpoint. If it returns 200 but authenticated calls 401, the stack is up and the problem is your credential, not the server. Re-run just demo-user to refresh .local/config.json, then re-read the key inline.
GET /health and GET /v1/health do not exist. The root / is the Swagger UI (HTML), not a probe.
Solution: Use GET /v1/status for health checks and GET /openapi.json for the live spec.
Wiki Guides:
-
Quick Start - Start the local stack and run
just demo-userto get your first API key - Graphs and Multi-Tenancy - The per-graph access model and 401-vs-403 semantics
-
Graph Operations - Where authenticated requests go: operation envelopes and
X-API-Keyin practice
API Reference:
- API Documentation - Live OpenAPI reference with request/response schemas for every endpoint
- Local OpenAPI spec: http://localhost:8000/docs (Swagger UI) and http://localhost:8000/openapi.json (machine-readable)
Codebase Documentation:
- Authentication Middleware - The authentication system internals
- Graph Routing - Multi-tenant graph routing and per-graph access
- GraphQL Extensions - The graph-scoped GraphQL surface and its auth
© 2026 RFS LLC
- Authentication & API Keys
- Graphs & Multi-Tenancy
- Shared Repositories
- Graph Operations
- Querying the Analytical Graph
- Credits & Billing
- AI Operators & MCP
- Pipeline Guide
- Extensions Surface Overview
- GraphQL Reads
- RoboLedger Operations
- RoboInvestor Operations
- Connecting QuickBooks Locally