Spanda Control Center uses a unified Bearer authentication model at the REST and gRPC boundary. Operators present either:
- Long-lived API keys — for automation, SDK clients, and local dev
- Short-lived session JWTs — for browser SSO after OIDC sign-in
Both map to the same RBAC roles and tenant isolation rules.
flowchart TB
subgraph clients [Clients]
CLI[CLI / SDK]
UI[Control Center UI]
Agent[Deploy / fleet agents]
end
subgraph boundary [Control Center API]
AH[AuthHandler]
AK[ApiKeyStore — HMAC hashed]
SJ[SessionTokenIssuer — HS256 JWT]
RBAC[RBAC + tenant check]
end
CLI -->|Bearer API key| AH
UI -->|Bearer API key or session JWT| AH
Agent -->|Bearer API key| AH
AH --> AK
AH --> SJ
AK --> RBAC
SJ --> RBAC
Core crate: spanda-security — AuthHandler, ApiKeyStore, SessionTokenIssuer,
ReadAuthPolicy.
Authorization: Bearer <token>gRPC accepts the same value in authorization metadata or x-api-key.
| Source | On disk | In memory |
|---|---|---|
SPANDA_API_KEY |
Not persisted | Plaintext (dev convenience) |
SPANDA_API_KEYS_FILE / .spanda/api-keys.json |
token_hash only (HMAC-SHA256) |
Hash only after create/load |
Admin POST /v1/admin/api-keys |
Hash persisted; plaintext returned once | Hash only |
Legacy api-keys.json files with a plaintext token field are migrated on load — the
server hashes the token and clears plaintext from memory. The next persist_file_keys write
stores hashes only.
Set a production pepper so key hashes are not portable across instances:
export SPANDA_API_KEY_PEPPER="$(openssl rand -hex 32)"
export SPANDA_SESSION_JWT_SECRET="$(openssl rand -hex 32)" # recommended separate secretWhen unset, the pepper falls back to SPANDA_CONTROL_CENTER_STATE_DIR (or .spanda). Use
explicit secrets in production.
spanda control-center api-key generate --export
# or
export SPANDA_API_KEY="my-local-dev-key"
spanda control-center serveSee control-center.md for multi-key JSON and roles.
After OIDC sign-in, the server issues an HS256 JWT usable as Authorization: Bearer <jwt>.
| Variable | Default | Purpose |
|---|---|---|
SPANDA_SESSION_JWT_SECRET |
falls back to pepper | JWT signing key |
SPANDA_SESSION_JWT_ISSUER |
spanda-control-center |
iss claim |
SPANDA_SESSION_TTL_SECS |
900 (15 min) |
Access token lifetime |
SPANDA_SESSION_REFRESH_WINDOW_SECS |
86400 |
Refresh allowed after expiry |
| Claim | Meaning |
|---|---|
sub |
Operator user id (OIDC sub) |
key_id |
session-<user_id> |
role |
RBAC role (from IdP group map or directory) |
tenant_id |
Instance tenant |
exp / iat |
Standard JWT timestamps |
iss |
spanda-control-center (configurable) |
- Administration → OIDC / SSO — configure issuer, client id/secret, group→role map, redirect URI
- Enable integration (
enabled: trueonPUT /v1/admin/oidc) - Operators use Sign in with SSO in the Control Center UI, or call the public auth routes below
Redirect callback page: /admin/oauth/oidc/callback (PKCE, posts message to opener).
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/auth/config |
No | OIDC login availability, session TTL, read-auth flags |
GET |
/v1/auth/session |
Bearer | Current principal (auth_kind, user_id, role) |
POST |
/v1/auth/session/refresh |
Body { "token": "<jwt>" } |
Refresh near-expired session |
POST |
/v1/auth/oidc/authorize-url |
No | Build IdP authorize URL (requires OIDC enabled) |
POST |
/v1/auth/oidc/callback |
No | Exchange code → session_token + directory import |
Admin directory import (requires existing Deploy token): POST /v1/admin/oidc/oauth/callback —
also returns session_token when userinfo is present.
# 1. Get authorize URL (browser opens authorize_url)
curl -s -X POST http://127.0.0.1:8080/v1/auth/oidc/authorize-url \
-H 'Content-Type: application/json' \
-d '{"redirect_uri":"http://127.0.0.1:8080/admin/oauth/oidc/callback"}'
# 2. After IdP redirect, exchange code
curl -s -X POST http://127.0.0.1:8080/v1/auth/oidc/callback \
-H 'Content-Type: application/json' \
-d '{"code":"<code>","state":"<state>"}'
# 3. Use session_token as Bearer on mutations and sensitive reads
export SPANDA_API_KEY="<session_token>"Unchanged from prior releases — see control-center.md.
- Mutations (
POST/PATCH/PUT/DELETE) require a valid Bearer principal with the matching role action. SPANDA_TENANT_IDon the instance must match the key or sessiontenant_id.
GET /v1/rbac/me returns auth_kind (api_key | session) and user_id for sessions.
By default, GET /v1/* remains open except endpoints that already required Bearer (secrets,
audit, rbac/me, etc.).
Enable stricter policies for network-exposed Control Center instances:
| Variable | Effect |
|---|---|
SPANDA_API_REQUIRE_AUTH_READS=1 |
Sensitive GET prefixes require Bearer (dashboard, devices, fleet, program source, compliance, …) |
SPANDA_API_REQUIRE_AUTH_ALL_READS=1 |
All GETs require Bearer except /v1/health, /v1/version, /v1/instance, /v1/tenant |
Failed read-auth returns 401 and records AuthFailed with reason read_auth_required.
| Concern | Mechanism |
|---|---|
| Robot / fleet wire security | mTLS, signed messages — secure-communication.md |
| OTLP push | SPANDA_OTLP_TOKEN Bearer |
| Twin Cloud | SPANDA_TWIN_CLOUD_API_KEY |
| OIDC code exchange | Used only to obtain IdP userinfo and issue Spanda session JWTs — IdP access tokens are not accepted as Control Center API credentials |
- Set
SPANDA_API_KEY_PEPPERandSPANDA_SESSION_JWT_SECRETto unique random values. - Terminate TLS at ingress; do not bind Control Center to the public internet without TLS.
- Enable
SPANDA_API_REQUIRE_AUTH_READS=1(orALL_READS) when the API is reachable beyond localhost. - Restrict file permissions on
.spanda/api-keys.jsonand state directory. - Rotate API keys on compromise; session JWTs expire automatically.
- Run
./scripts/security_audit_prep.shbefore third-party review — security-audit-third-party.md.
- control-center.md — operator workflows, API key CRUD
- security-architecture.md — runtime / robot security boundary
- sdk.md — client Bearer configuration
- control-center-rate-limits.md — per-key rate limits