Enforcement
Authentication & keys
How agents authenticate, what Arcus stores, how identity binding stops one agent impersonating another, and how dashboard access differs from gateway access.
Two separate credentials#
Arcus has two authentication paths and they never overlap.
| Gateway | Control plane | |
|---|---|---|
| Who uses it | Your agents | You, in a browser |
| Credential | An Arcus API key | A Clerk session |
| Header | Authorization: Bearer ark_live_… | Clerk session cookie / token |
| Reaches | /v1/dispatch only | Everything under /v1/admin/* |
This separation is the point. An API key can send dispatches and nothing else — it cannot read your logs, list your keys, change a policy, disable a guardrail or revoke itself. A compromised agent key gives an attacker the ability to attempt dispatches that your policies still refuse. It does not give them your control plane.
An API key cannot be used against the admin API
There is no privilege escalation path from ark_live_… to /v1/admin. The two use different middleware and different identity sources. Do not try to authenticate an admin request with an API key — it returns 401.
Key format#
ark_live_4f8c21a90b73e5d6c8a41f27b9e03d5a6c17f82be40d95c3
└──┬───┘ └─────────────────────────┬──────────────────────┘
prefix 48 hex characters (24 random bytes)ark_live_ plus 24 cryptographically random bytes rendered as hex. 192 bits of entropy — not guessable, and not derivable from any other key.
The fixed prefix is deliberate. It makes a leaked key recognisable: secret scanners can match on it, and Arcus's own DLP engine matches ark_live_[a-f0-9]{16,} as an API_KEY category, so an agent that tries to send an Arcus key through Arcus is refused.
What Arcus stores#
Three things, per key:
| Stored | Value |
|---|---|
keyHash | SHA-256 of the raw key, hex |
prefix | The first 15 characters — ark_live_ plus 6 hex, for display |
| Metadata | Label, owner email, optional agentId, createdAt, lastUsedAt, revokedAt |
The raw key is never persisted. It exists in the creation response and nowhere else.
Consequences you should plan around:
- There is no "show key again". Lost means reissue.
- A database compromise yields no usable keys. An attacker gets hashes, and SHA-256 over 192 bits of entropy is not reversible by brute force.
- Support cannot read your key, which also means nobody can be socially engineered into producing it.
Verification on the hot path is one hash and one indexed lookup by keyHash — no bcrypt work factor, because the input is already high-entropy random rather than a human-chosen password.
Creating a key#
From the dashboard, API Keys → New key. Or via the API:
curl -sS -X POST "$ARCUS_URL/v1/admin/keys" \
-H "Authorization: Bearer <clerk-session-token>" \
-H "Content-Type: application/json" \
-d '{
"email": "ops@yourcompany.com",
"label": "intake-agent (production)",
"role": "USER",
"agentId": "intake-agent"
}'| Field | Required | Notes |
|---|---|---|
email | Yes | Owner email — who this key was issued to. Distinct from your account email, so a key handed to a contractor is attributable. |
label | Yes | 1–100 characters, for your own reference. |
role | No | USER or ADMIN. Defaults to USER. |
agentId | No | 1–200 characters. Binds the key to one agent identity. Strongly recommended. |
The response contains rawKey exactly once:
{
"id": "clx8m2p4k0001abcd",
"rawKey": "ark_live_xxxxxxxx",
"prefix": "ark_live_4f8c21",
"label": "intake-agent (production)",
"agentId": "intake-agent",
"createdAt": "2026-08-24T09:14:02.881Z"
}One key per agent per environment
intake-agent (staging) and intake-agent (production) should be separate keys even though they bind to the same agent id. Rotating one then never touches the other, and lastUsedAt tells you which environments are actually live.
Identity binding#
An agent id on a dispatch is a claim. Binding turns it into a check.
Unbound key — the dispatch's from is accepted as sent. Convenient for a single-agent prototype, and the reason binding is recommended the moment you have two agents.
Bound key — the dispatch's from must equal the key's agentId, or:
{ "error": "Agent identity mismatch" }401, recorded in your audit chain as dispatch.spoof_rejected, and nothing is delivered.
Why this matters more than it first appears#
Every downstream decision references the sender:
- policies match on
senderAgentId, - guardrail rules match on
senderAgentId, - rate-limit counters and quarantines are keyed on the agent,
- statistical baselines are learned per
(agent, field).
Without binding, a compromised support-agent could declare from: "billing-agent" and be evaluated against billing's policies — the ones that allow payment routes. Binding closes that, which is why the check runs at gate 2, before anything that reasons about identity.
Rate limiting also keys on the bound agent rather than the claimed from, so a quarantined agent cannot escape its own quarantine by changing one string in its payload.
If from is omitted#
The dispatch falls back to the key's agentId. If the key has no binding either, the sender is effectively unspecified and only a wildcard policy (senderAgentId: "*") can match it. In practice: always send from, and always bind the key.
Listing keys#
curl -sS "$ARCUS_URL/v1/admin/keys" \
-H "Authorization: Bearer <clerk-session-token>"Returns metadata only — prefix, label, owner email, bound agent, createdAt, lastUsedAt, revokedAt. Never a hash, never a raw key.
lastUsedAt is written best-effort on each authenticated dispatch: the update is fire-and-forget so a slow write cannot add latency to the enforcement path. Treat it as "recently active", accurate to within a request or two, not as a precise access log. For a precise record, read the audit chain — every dispatch event carries the key prefix as its actor.
Revoking a key#
curl -sS -X DELETE "$ARCUS_URL/v1/admin/keys/clx8m2p4k0001abcd" \
-H "Authorization: Bearer <clerk-session-token>"Revocation is immediate — the next dispatch with that key returns 401. The row is kept with revokedAt set, so historical logs and audit events still resolve to a recognisable key prefix. Revocation is not deletion; the evidence stays intact.
Revocation appends key.revoked to your audit chain with the actor, the prefix, the label and the owner email. That record is the answer to "who revoked this and when", and it is sealed.
Revoke first, then rotate
A revoked key stops working the moment the request is processed. If the agent is live, issue and deploy the replacement key first, confirm it works, then revoke the old one — otherwise you take a short outage.
When to revoke immediately#
- The key appeared in source control, a log, a screenshot or a support ticket.
- The person named in the owner email no longer needs it.
lastUsedAtshows activity from an environment you did not expect.- Your audit chain shows
dispatch.spoof_rejectedevents for that key's prefix — something is using it to claim an identity it was not issued for.
Key limits per plan#
| Plan | Active keys |
|---|---|
| Free | 2 |
| Pro | 10 |
| Max | 50 |
The limit counts active keys — revoked keys do not consume a slot, so rotating never requires you to first go below your limit. Exceeding it returns a 403 naming the limit, your tier and the tier that would raise it.
Handling keys in your agent#
import os
import requests
ARCUS_URL = os.environ["ARCUS_URL"]
ARCUS_KEY = os.environ["ARCUS_KEY"] # from the environment, never a literal
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {ARCUS_KEY}",
"Content-Type": "application/json",
})
def dispatch(target: str, message: str, data: dict | None = None):
r = session.post(
f"{ARCUS_URL}/v1/dispatch",
json={"target": target, "from": "intake-agent",
"message": message, "data": data or {}},
timeout=15,
)
if r.status_code == 401:
# Bad, revoked, or an identity mismatch. Do not retry — the key or the
# declared sender is wrong, and retrying just records more rejections.
raise RuntimeError(f"Arcus rejected the credential: {r.text}")
return rRules that are worth being rigid about:
- Environment variables or a secret manager. Never a literal in source, never a committed
.env, never a notebook cell. - Never log the key. Log the prefix if you need to correlate — that is exactly what the prefix is for.
- Never send it in a payload. Arcus's DLP will refuse the dispatch, which is the correct outcome but an avoidable incident.
- Do not retry a
401. It is not transient. - Never expose it to a browser. An
ark_live_key in client-side JavaScript is a public key. Dispatch from your server.
Control-plane access#
The dashboard authenticates with Clerk — email or a social provider. Your Clerk user id is your tenant id, so there is no separate workspace to provision and no seat model. See Core concepts.
Admin API calls use a Clerk session token, not an API key. That is why the dashboard can read your logs and an agent cannot.
A small number of endpoints under /v1/admin/platform/* are restricted further, to platform operators identified Clerk-side — a role settable only with the Clerk secret key, or a server-only email allow-list as the bootstrap path. Those are cross-tenant and are documented separately in Platform endpoints.