Skip to content

API Keys for AI Agents

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.

  1. You log into the wallet daemon as an Admin user via WebAuthn as normal.
  2. You call auth.create_api_key with a list of scopes and a friendly name. The daemon mints 32 random bytes, prefixes them with tw_, 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.
  3. You hand the raw key to the agent — environment variable, secrets manager, etc.
  4. The agent sends the raw key as the Authorization: Bearer … header on every JSON-RPC call. There is no auth.request round-trip and no JWT exchange. The daemon’s auth middleware detects the tw_ prefix on the bearer string, resolves it against the api_keys table, 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.

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.

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.

Terminal window
# 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.

Navigate to API Keys in the wallet daemon’s web UI (/api-keys route, requires Admin auth):

  1. Click Create API Key.
  2. Enter a friendly name and the comma-separated scope list (AccountInfo, TransactionGet, …).
  3. If granting Admin, tick the explicit confirmation checkbox — the form refuses to submit Admin without it.
  4. 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.

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:

Terminal window
# Create a key with two scopes
tari-wallet-cli auth api-key create \
--name monitoring-agent \
--permissions AccountInfo,TransactionGet
# Granting Admin requires the explicit speed-bump flag
tari-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 id
tari-wallet-cli auth api-key revoke --id 7

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

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);
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?;
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();
// 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.

  • 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 InsufficientPermissions error.
  • Non-admin trying to mint/list/revoke keys: rejected with the same InsufficientPermissions error — only the Admin permission 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_at bump is throttled and best-effort — a write failure here cannot abort the already-successful request.