API Keys for AI Agents
Covered in this guide
Section titled “Covered in this guide”In this guide, you will:
- Understand the agent authentication model.
- Mint an API key with the scopes the agent needs.
- Authenticate an agent to the wallet daemon using that key.
- List, audit, and revoke keys.
WebAuthn is great for humans — touch a hardware key and you’re in. It is not a viable login mechanism for an AI agent acting on your behalf: an agent has no fingers and no Yubikey, and you don’t want to interactively re-authenticate every five minutes. The wallet daemon supports a second authentication path designed for that case: long-lived, permission-scoped API keys that an Admin user mints once and that the agent then presents on every request.
How it works
Section titled “How it works”- You log into the wallet daemon as an Admin user via WebAuthn as normal.
- You call
auth.create_api_keywith a list of scopes and a friendly name. The daemon mints 32 random bytes, prefixes them withtw_, base64-encodes the result, and returns it to you exactly once. Only a SHA-256 hash of that string is persisted; a database leak does not expose the key. - You hand the raw key to the agent — environment variable, secrets manager, etc.
- The agent sends the raw key as the
Authorization: Bearer …header on every JSON-RPC call. There is noauth.requestround-trip and no JWT exchange. The daemon’s auth middleware detects thetw_prefix on the bearer string, resolves it against theapi_keystable, parses the stored permission set, and enforces it against the called endpoint’s required scopes.
The permissions enforced for each request come from the stored API key row, not from anything the agent sends —
an agent cannot self-promote by claiming Admin when its key only grants AccountInfo. Revocation is immediate:
the storage-layer lookup filters revoked rows, so the very next request after a revoke fails.
Choosing scopes
Section titled “Choosing scopes”API key scopes are an arbitrary subset of the JSON-RPC permission model. Pick the smallest set that lets the agent do its job — for a read-only monitoring agent that just needs to track balances:
["AccountInfo", "AccountBalance_<your_account_substate_id>", "TransactionGet"]For a trading agent that needs to submit transactions from one specific account:
["AccountInfo", "AccountBalance_<account>", "TransactionSend_<account>", "TransactionGet"]Granting Admin to an API key is allowed (since you have it), but the daemon requires you to set
confirm_admin: true in the create request so a UI checkbox can be the gate, not the JSON-RPC payload alone.
Setting an expiry
Section titled “Setting an expiry”API keys can optionally carry an expires_at timestamp (unix seconds). When set, the daemon’s active-row filter
stops surfacing the key once that timestamp has passed — the agent’s next request fails with the same opaque
ApiKey is invalid or revoked error as for an unknown or revoked key. Prefer the shortest expiry that fits the
agent’s job: shorter is safer, since a leaked short-lived key dies on its own without manual revocation.
# CLI: relative duration, parsed with `humantime`. Accepts forms like# 30d, 12h, 1y, 90 days, 1day 6h.tari-wallet-cli auth api-key create \ --name monitoring-agent \ --permissions AccountInfo,TransactionGet \ --expires-in 30d// Rust: absolute unix-seconds. `None` means the key never expires.use std::time::{SystemTime, UNIX_EPOCH, Duration};
let expires_at = (SystemTime::now() + Duration::from_secs(30 * 24 * 3600)) .duration_since(UNIX_EPOCH)? .as_secs() as i64;
admin_client.auth_create_api_key(AuthCreateApiKeyRequest { name: "monitoring-agent".into(), permissions: vec!["AccountInfo".into(), "TransactionGet".into()], confirm_admin: false, expires_at: Some(expires_at),}).await?;The Web UI exposes this as a “Never expires” checkbox plus a date picker on the create dialog. The list view
shows the expiry column and renders a yellow EXPIRED pill for keys whose expiry has passed.
Mint a key (Wallet Web UI)
Section titled “Mint a key (Wallet Web UI)”Navigate to API Keys in the wallet daemon’s web UI (/api-keys route, requires Admin auth):
- Click Create API Key.
- Enter a friendly name and the comma-separated scope list (
AccountInfo, TransactionGet, …). - If granting
Admin, tick the explicit confirmation checkbox — the form refuses to submit Admin without it. - The dialog shows the raw key exactly once with a copy-to-clipboard button. Copy it before dismissing. Once you close the dialog the daemon cannot surface it again.
The same page lists all active and revoked keys with last-used timestamps, with an inline Revoke action per row. Revocation is immediate.
Mint a key (tari_ootle_wallet_cli)
Section titled “Mint a key (tari_ootle_wallet_cli)”The CLI binary wraps the same JSON-RPC endpoints for shell-based admin workflows. Authenticate as an Admin first
(via auth request Admin or the WebAuthn UI), then:
# Create a key with two scopestari-wallet-cli auth api-key create \ --name monitoring-agent \ --permissions AccountInfo,TransactionGet
# Granting Admin requires the explicit speed-bump flagtari-wallet-cli auth api-key create \ --name dangerous-full-access \ --permissions Admin \ --confirm-admin
# List existing keys (active and revoked)tari-wallet-cli auth api-key list
# Revoke by idtari-wallet-cli auth api-key revoke --id 7The create command prints the raw key to stdout on its own line, prefixed with KEY (shown ONCE — store immediately):,
so a shell pipeline can grep for and capture it once. Subsequent list calls will never include the raw key — the
daemon only stored a hash.
Mint a key (Rust client)
Section titled “Mint a key (Rust client)”use tari_ootle_walletd_client::{ WalletDaemonClient, types::AuthCreateApiKeyRequest,};
let mut admin_client = WalletDaemonClient::connect(daemon_url, Some(admin_jwt))?;
let response = admin_client.auth_create_api_key(AuthCreateApiKeyRequest { name: "monitoring-agent".to_string(), permissions: vec![ "AccountInfo".to_string(), "TransactionGet".to_string(), ], confirm_admin: false,}).await?;
println!("Hand this to the agent, then forget it: {}", response.api_key);Authenticate an agent (Rust client)
Section titled “Authenticate an agent (Rust client)”use tari_ootle_walletd_client::WalletDaemonClient;
// Pass the raw API key as the bearer token at connect time; subsequent// JSON-RPC calls go out with `Authorization: Bearer tw_…`, which the// daemon resolves against the api_keys table on every request.let mut agent_client = WalletDaemonClient::connect(daemon_url, Some(api_key_from_env))?;
let info = agent_client.get_wallet_info().await?;Authenticate an agent (JavaScript client)
Section titled “Authenticate an agent (JavaScript client)”import { WalletDaemonClient } from "@tari-project/wallet_jrpc_client";
const client = WalletDaemonClient.usingFetchTransport("http://localhost:5100/json_rpc");// Sets the raw API key as the bearer token. No auth.request round-trip;// the daemon authorises each call directly against the api_keys table.client.authenticateWithApiKey(process.env.TARI_AGENT_API_KEY!);
const info = await client.walletGetInfo();Audit and revoke
Section titled “Audit and revoke”// Admin: enumerate active and revoked keys.let listed = admin_client .auth_list_api_keys(AuthListApiKeysRequest::default()) .await?;
for key in &listed.keys { println!( "id={} name={} scopes={:?} last_used={:?} revoked={:?}", key.id, key.name, key.permissions, key.last_used_at, key.revoked_at );}
// Admin: revoke a key (effective immediately).admin_client .auth_revoke_api_key(AuthRevokeApiKeyRequest { id: 7 }) .await?;Revoke is immediate: the storage layer filters revoked rows out of the authentication lookup, so the next request after a revoke fails — there is no client-side cache and no JWT to outlive the revoke.
Failure modes the daemon handles for you
Section titled “Failure modes the daemon handles for you”- Wrong / revoked / unknown key: same error message and timing for all three. An attacker probing the endpoint can’t enumerate valid hashes.
- Out-of-scope request: the daemon enforces the stored scope set against every endpoint’s required scopes. Any
handler that requires a scope not on that list rejects the request with the standard
InsufficientPermissionserror. - Non-admin trying to mint/list/revoke keys: rejected with the same
InsufficientPermissionserror — only theAdminpermission grants access to the key-management endpoints. - Empty permission list at creation: rejected. An unusable key is worse than no key.
- Concurrent revoke during authentication: a request whose lookup wins the race authenticates successfully (the
key was active at the moment of the read); the very next request, against the now-revoked row, fails. The
last_used_atbump is throttled and best-effort — a write failure here cannot abort the already-successful request.