Security

API keys and delegation

Minting, rotation, and delegated sub-keys.

Spectron uses API keys for all authenticated operations. Keys carry a scope floor that restricts what memory they can read and write. A key can never access memory outside its scope floor, and it can only mint sub-keys with a scope floor that is equal to or narrower than its own.

Keys are created via the management API (operator), Cloud-brokered access tokens, or member self-service. Each data-plane key is bound to a principal and carries grant regions per verb.

Management mint (requires management key):

POST /api/v1/contexts/{context_id}/principals/{principal_id}/keys/{key_name}

See Management API for the full control-plane surface.

Each key requires:

  • Principal binding — the principal whose grants the key inherits (optional body grants only attenuate)

  • Name — a human-readable label unique within the Context

  • Expiry (optional) — via ?ttl_seconds= on mint or rotate

spectronctl contexts ctx_acme keys create planner-agent \
--principal agent \
--scope-floor 'org=acme,agent=planner' \
--api-key "$SPECTRON_MGMT_KEY"

The command prints the key id and plaintext secret once — store the secret immediately. See Management API for the HTTP equivalent.

The plaintext key is returned only at creation time. Spectron stores only the hash. If the value is lost, delete the key and create a new one.

PrincipalDescription
agentAn individual agent. Reads and writes memory within its scope floor.
supervisorReads memory across all agents within its org scope. Typically used for reflection and cross-agent queries.
managementFull administrative access to the context: key management, configuration, lifecycle operations.

Two flows share the same key substrate on the Spectron data plane:

End users call the SurrealDB Cloud API, not Spectron's management API directly. Cloud holds the root management key and brokers on your behalf.

ActorCloud API entrySpectron effect
MemberPOST /v1/organizations/{org}/spectron_contexts/{ctx}/access_tokensShort-lived token for their own principal (external_id = surreal-cloud:<user_id>)
AdminPOST …/spectron_contexts/{ctx}/api_keysLong-lived sk-ctx-… key (what Surrealist API keys creates)
AdminPOST …/spectron_contexts/{ctx}/scoped_keys, …/principals, …/scopes, …Proxied management operations

Members cannot call admin proxy routes (403). ttl_seconds on access tokens is required; re-broker before expiry — there are no refresh tokens.

For Surrealist and application integrations, use API keys and your context host. Members obtain brokered tokens through the Cloud API entry above.

Operators with a Spectron management key can broker directly:

POST /api/v1/contexts/{context_id}/access-tokens
Authorization: Bearer <management-key>

{
"external_id": "surreal-cloud:usr_01HF…",
"display_name": "Alice",
"ttl_seconds": 3600,
"grants": {
"memory:read": [{ "org": "acme", "user": "alice" }],
"memory:write": [{ "org": "acme", "user": "alice" }]
}
}

Self-service (data-plane API) — once a member holds a brokered or admin-minted key, they manage their own keys without admin access:

GET  /api/v1/{context_id}/me
POST /api/v1/{context_id}/keys
GET /api/v1/{context_id}/keys
DELETE /api/v1/{context_id}/keys/{name}
POST /api/v1/{context_id}/keys/{name}/rotate

GET /me returns principal identity, grants, effective grants, and delegation state. POST /keys mints for the caller’s own principal; optional grants only attenuate (narrow) — widening returns 400. Self-service mutating routes reject unbound keys and delegated callers.

Operators can disable self-mint with config.allow_self_service_keys: false or cap TTLs with config.max_token_ttl_seconds (see Configuration).

There is no separate key:create grant verb — self-mint is safe by construction because attenuation can only narrow access, and gating it would be circular (you need a key to mint a key).

Spectron stores the HMAC-SHA256 hash of each key in SurrealDB. The plaintext is never persisted. Authentication on each request hashes the presented key and compares it to the stored hash.

Because keys are hashed on storage, you cannot retrieve a key's value after creation. Treat key creation responses like you would treat newly generated secrets: copy the value immediately to a secrets manager or environment variable.

Spectron supports in-place rotation — swap the secret while keeping the same key id, principal binding, and grants:

POST /api/v1/contexts/{context_id}/keys/{key_name}/rotate?ttl_seconds=2592000
Authorization: Bearer <management-key>

The response includes the new secret (shown once). The old secret stops validating immediately; there is no grace-period overlap.

Query paramBehaviour
ttl_seconds omittedInherit the key’s current expiry
ttl_seconds=NReset expiry to now + N seconds
spectrond keys rotate ctx_acme primary --expires-in 2592000

To change principal binding or grants, mint a new key and retire the old one:

  1. Create a new key with the desired scope floor and principal type.

  2. Update all callers to use the new key.

  3. Verify the old key is no longer in use (check last_used_at).

  4. Delete the old key.

# Step 1: Create replacement
new_key = await admin.contexts("ctx_acme").keys.create(
name="planner-agent-v2",
principal="agent",
scope_floor={"org": "acme", "agent": "planner"},
)

# Step 3: Check old key activity
old_key = await admin.contexts("ctx_acme").keys.get("key_01hw…")
print(old_key.last_used_at) # 2026-01-15T09:12:00Z

# Step 4: Delete old key
await admin.contexts("ctx_acme").keys.delete("key_01hw…")

Deletion is immediate. Any in-flight requests using the deleted key will fail on their next API call.

A key holder can mint sub-keys with a scope floor that is a superset of (or equal to) their own scope floor. This is the delegation invariant: a delegated key can never be broader than the key that created it.

Example: a planner agent key with {org: "acme", agent: "planner"} can mint a sub-key for a specific tool:

sub_key = await admin.contexts("ctx_acme").keys.create(
name="tool-search",
principal="agent",
scope_floor={"org": "acme", "agent": "planner", "tool": "search"},
expires_at="2026-06-01T00:00:00Z",
created_by="key_01hw…", # the parent key
)

The created_by field records which key minted this sub-key, forming an auditable delegation chain.

Invalid delegation – the following would be rejected because the sub-key's floor is broader than the parent:

# This will fail: {org: "acme"} is broader than {org: "acme", agent: "planner"}
await admin.contexts("ctx_acme").keys.create(
name="too-broad",
scope_floor={"org": "acme"}, # broader than parent → rejected
created_by="key_01hw…",
)
# raises: ScopeEscapeError: delegated floor must be superset of parent floor
  • Per-tool auditing: mint a sub-key per tool call so last_used_at tracks tool-level activity

  • Session-bounded tokens: create a key that expires when a session ends

  • Temporary access: grant a contractor key scoped to a single agent for a time-bounded period

  • Principle of least privilege: give each component exactly the scope it needs

Keys accept an expires_at field in RFC 3339 format. After this timestamp, the key is rejected on every request with a 401 Unauthorized response.

import datetime

expires = (datetime.datetime.utcnow() + datetime.timedelta(days=30)).isoformat() + "Z"

key = await admin.contexts("ctx_acme").keys.create(
name="temp-contractor",
principal="agent",
scope_floor={"org": "acme", "agent": "contractor"},
expires_at=expires,
)

Expired keys are not automatically deleted. They remain in the key list with status: "expired" and can be inspected for audit purposes. Delete them when no longer needed.

When creating keys with spectronctl, you can pass --expires-in <seconds> instead of an absolute timestamp; the management API stores the resulting expiry as valid_until.

Some deployments need one agent to read or write memory for another principal — for example a supervisor orchestrator calling tools on a user’s behalf. Pass the target principal on each request:

X-Spectron-On-Behalf-Of: principal_01hw…

Rules:

  • Depth 1 only — you cannot chain delegation headers.

  • Intersected grants — effective authority is the intersection of the caller’s key, the caller’s grants, and the target principal’s grants. Delegation narrows access; it never widens it.

  • Recorded on traces — reconciliation traces include both the acting principal and the on_behalf_of target when present.

See REST API for the header on end-user routes.

To immediately invalidate a key, delete it:

await admin.contexts("ctx_acme").keys.delete("key_01hw…")

Deletion is hard and immediate. If you want to preserve the key record for audit purposes without allowing further use, set revoked_at instead:

await admin.contexts("ctx_acme").keys.revoke("key_01hw…")

A revoked key has revoked_at set to the current timestamp and status: "revoked". It cannot be unreveoked. Use deletion when the record is no longer needed; use revocation when you need the audit trail.

Every key carries the following audit fields:

FieldDescription
idUnique key identifier
nameHuman-readable label
principalPrincipal type
scope_floorThe scope floor enforced on all requests using this key
created_atWhen the key was created
created_byKey ID of the parent key that minted this key (if delegated)
last_used_atTimestamp of the most recent authenticated request
expires_atExpiry timestamp (null if no expiry)
revoked_atRevocation timestamp (null if active)
statusactive, expired, or revoked
keys = await admin.contexts("ctx_acme").keys.list()

for key in keys:
print(f"{key.name:20} {key.status:8} last used: {key.last_used_at}")

The created_by chain lets you audit delegation trees: given any key, you can walk created_by back to the root management key.

Was this page helpful?