Control Plane Auth
What is this auth for?
QTG v3’s inbound auth is not a user login system — it is control plane access control. The questions it answers are:- Did this request really come from a registered system?
- Does this key have permission to perform this action?
- Was the request body tampered with in transit?
- Is this request a replay of a previous one?
qtg core-local plus qtg human login / qtg human inspect / qtg human logout (browser/WebAuthn, loopback callback, /v3/auth/human/*), with qtg bootstrap-human-owner as the one-off owner bootstrap. No hosted/public login surface exists — the human routes 404 unless the local human origin is explicitly enabled (human_local_origin). Do not treat the internal persistence/service code as a generally available user-login surface.
Who are the callers?
Examples of systems that act as callers:signal-bot— creates movements (operatororagent)dashboard— approves/rejects (operator)ops-cli— retries/resumes/cancels (operator)admin-cli— signer rotation + registry status control (admin)read-monitor— status queries (operator)
agent role, which is additionally tenant-isolated to a single namespace (see The agent role and namespace isolation).
Authentication method
Required headers:X-QTG-Key-IdX-QTG-TimestampX-QTG-NonceX-QTG-Signature
PATHis the routed, percent-decoded path the server dispatches on — no origin, no query string.QUERYis the query exactly as sent: no leading?, no sorting, empty string if absent. Sorting before signing transmits bytes you did not sign, and every such request 401s.
X-QTG-Signature.
QTG verifies in this order:
- timestamp freshness check
key_idlookup- HMAC comparison
- nonce uniqueness check
- role check
- (agent keys only) namespace match check
key_id against any route and read that key’s role
off the 401-vs-403 split.
Why is a nonce needed?
With only a timestamp, it’s possible to “resend the same request within the 5-minute window.” For example:approve movement X- An attacker captures the same request and resends it 30 seconds later
(key_id, nonce) in the DB, and if the same combination arrives again it is rejected with 401 replay detected.
Role model
Authorization is a 3-role axis. Every API client key carries exactly onerole, constrained at the DB level to one of:
admin— full control (template/signer/executor mutation, registry, rotation)operator— approve/reject, retry/resume/cancel, reads, and (when installed and enabled) Pro capital transfersagent— propose movements within a single tenant namespace, read its own status
This replaced the older 6-”purpose” model (Authority is a fixed route catalog, keyed byread/write/approval/operate/admin/all). Thepurposecolumn survives as a label only; the live authorization axis is roles, and the seed CLI accepts only--role.
(method, path) → allowed roles:
Reads are not automatically the loosest tier —
GET /v3/signers is admin+operator while GET /v3/movements is open to agents. The table prevents, for example, an agent key from approving its own proposal, or an operator key from mutating a signer registry entry.
The catalog is extended at boot. Pro registers its own routes (capital transfers, CCIP/Stargate administration, and auto-approve policies) through the same
(method, path) → roles mechanism, so an OSS deployment simply has fewer entries — never looser ones. Capital-transfer creation remains disabled until its separate Pro mutation flag is explicitly enabled.The agent role and namespace isolation
Theagent role is the only one with tenant isolation. An agent key is bound 1:1 to a network-scoped authority. The control plane enforces that the authority / namespace in the request path matches the authority the key is bound to — a mismatch is rejected with 403 agent_namespace_mismatch before the handler runs. So even a leaked agent key can only act within its own namespace, and only to propose: agent-originated movements are never auto-approved, whatever auto-approve policy is in scope, and a human operator must approve them.
Storage structure
Keys and replay-protection state are backed by a DB-based registry:- a record per registered client system
- a record per issued key, looked up directly by
key_id - a nonce ledger that records seen
(key_id, nonce)pairs for replay defense
Authentication channels
QTG has two primary authentication channels plus an opt-in human-session bearer plane:
A Pro-registered OIDC bearer seam also exists for browser movement routes (
register_oidc_routes), inactive unless registered.
These channels are separate and do not cross. Dashboard UI uses /dashboard/* routes; programmatic callers like Hummingbot and signal-bot use /v3/* routes. Dashboard write keys are issued as role=operator (admin keys are also accepted; the dashboard_writer label survives only for log clarity). The old MG_DASHBOARD_WRITE_TOKEN / VITE_DASHBOARD_WRITE_TOKEN bearer-token model has been removed — the server hard-fails at startup if MG_DASHBOARD_WRITE_TOKEN is set. See the dashboard auth migration runbook.
Caller Inventory & Naming Matrix
Naming Convention
Key ID pattern:{client-name}-{environment}
Client Name: stays identical across environments. Must match the
--client-name argument of the seed CLI.
HMAC Caller Matrix
There is no
read-only role: GET routes are reachable by operator (and many by agent). Use operator for read/monitor callers.
Seed procedure
Secret Storage Policy
Principles
- Complete isolation between environments: staging and prod use separate secrets. Never share them.
- Least privilege: each caller is issued a key with the narrowest role it needs (
agent<operator<admin). Reserveadminfor the admin CLI only. - Store immediately on creation: the secret printed by the seed CLI is one-time only. Store it in a secure location immediately.
- No git commits: never commit
.envfiles, seed artifacts, or any file containing a secret.
Storage guide by environment
Management by secret type
Rotation policy
- Scheduled rotation: once per quarter, or immediately upon suspected secret exposure
- HMAC key rotation (zero-downtime):
- Check current keys with
list_auth_keys - Seed a new key for the same client (
seed_auth_client --key-id {client}-{env}-v{N+1}) - Deploy the new secret to the caller (update
.env+ restart) - Smoke test with the new key
- Run
qtg auth revoke-credential --credential-type hmac --credential-id "$OLD_KEY_ID" \ --reason "planned key rotation"with an authenticated HMAC-admin actor. The legacyrevoke_auth_keycommand fails closed; explicit self-revoke remains available for emergencies.
- Check current keys with
- Dashboard writer rotation: re-seed the operator’s HMAC key and revoke the old one — no frontend rebuild (the key is no longer baked into the bundle)
- Emergency rotation: execute immediately, accepting service interruption
One-line summary
QTG v3 auth is a layer that enforces “who can send what kind of command to this control plane” via HMAC + role + nonce (+ namespace isolation for agent keys).