Arcus Docs

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.

GatewayControl plane
Who uses itYour agentsYou, in a browser
CredentialAn Arcus API keyA Clerk session
HeaderAuthorization: Bearer ark_live_…Clerk session cookie / token
Reaches/v1/dispatch onlyEverything 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#

text
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:

StoredValue
keyHashSHA-256 of the raw key, hex
prefixThe first 15 characters — ark_live_ plus 6 hex, for display
MetadataLabel, 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:

bash
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"
  }'
FieldRequiredNotes
emailYesOwner email — who this key was issued to. Distinct from your account email, so a key handed to a contractor is attributable.
labelYes1–100 characters, for your own reference.
roleNoUSER or ADMIN. Defaults to USER.
agentIdNo1–200 characters. Binds the key to one agent identity. Strongly recommended.

The response contains rawKey exactly once:

json
{
  "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:

json
{ "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#

bash
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#

bash
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.
  • lastUsedAt shows activity from an environment you did not expect.
  • Your audit chain shows dispatch.spoof_rejected events for that key's prefix — something is using it to claim an identity it was not issued for.

Key limits per plan#

PlanActive keys
Free2
Pro10
Max50

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#

python
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 r

Rules 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.