Agent keys & scopes
Scoped credentials, bearer JWTs, and the security model behind ThreatCluster's CLI and API. Learn when to use each key type and how scopes work.
ThreatCluster has two credential shapes for programmatic access:
tc_live_*— the original API key. Long-lived, full-scope, sent on every request. Still works; existing integrations don't need to change.tc_agent_*— scoped agent key. Long-lived, but used only to mint short-lived bearer JWTs. Sent only at token-mint time, never on data calls.
The two are interchangeable for back-compat, but agent keys are the recommended shape for any new integration — especially anything that an AI agent will use.
Why two key shapes?
The classic "one API key on every request" model has an implicit assumption:
the credential is short-lived enough that it can't do much damage if it leaks.
That's true for browser sessions (15-min-ish). It's not true for an API key
sitting in a .env file that an AI agent reads.
Splitting the credential decouples those concerns:
| tc_live_* | tc_agent_* (refresh) | bearer JWT | |
|---|---|---|---|
| Where it lives | Wherever you put it | OS keyring / 0600 file | In-process memory |
| Sent on each request | Yes | No (only at token mint) | Yes |
| Lifetime | Until manually revoked | 90 days (configurable) | 15 minutes |
| Has scopes | Always full set | Whatever you ticked at mint | Subset of refresh's |
| Revocation latency | Immediate | Immediate (new bearers fail) | Up to 15 min (existing bearers) |
If a bearer leaks (a log file, a stack trace, an HTTP request body in a CDN cache), it dies in 15 minutes. If an agent key leaks, you revoke it server-side and all derived bearers die within 15 minutes without you needing to chase down every system that cached the bearer.
Scopes
Every public API endpoint requires exactly one scope. When you mint an agent key, you tick which scopes it gets.
| Scope | What it grants |
|---|---|
threats:read |
/threats/*, /stats/* — list, detail, IOCs, STIX, statistics |
iocs:read |
/iocs/feed, /iocs/export |
entities:read |
/entities/search, /entities/{type}/{value}, /entities/trending, related entities |
vulns:read |
/vulnerabilities, /vulnerabilities/{cve_id}, vulnerability stats |
darkweb:read |
/darkweb/* — ransomware victims, breaches, markets, keyword hits |
feeds:read |
/feeds, /feeds/{id}/entities (your custom feeds) |
Scopes are additive. A key with threats:read and iocs:read can access
both endpoint families and nothing else.
A few intentional design notes:
- No write scopes in v1. Agent keys are read-only. Customer-facing writes
(creating feeds, ack'ing alerts, modifying preferences) still go through the
user-authenticated endpoints (
/api/user/*) which require Auth0. - Scopes are orthogonal to subscription tier. A free-tier user with
darkweb:readcan call the dark-web endpoints, but the endpoint will then enforce the tier gate (e.g.darkweb/keyword-hitsis Business). - Stats fold into
threats:read. There's no separatestats:readbecause stats are derived from the underlying resources you can already see. - Existing
tc_live_*keys are backfilled with the full scope set so all current integrations keep working unchanged.
Scope downgrade
A bearer can request a strict subset of the refresh's scopes. The server rejects any attempt to request scopes the refresh doesn't have:
# CLI, programmatic equivalent of giving a child a narrower bearer
TC_SCOPES=threats:read tc threats list
This is the primary mitigation against prompt-injection scraping: a parent
agent with full scopes can shell out to a sub-agent with TC_SCOPES=threats:read,
and the sub-agent literally cannot reach /darkweb/* no matter what an
injected prompt tells it to do.
Bearer JWTs
Mint a bearer:
POST /api/auth/agent/token
X-API-Key: tc_agent_…
{
"session_id": "optional-string",
"max_requests": 50,
"scopes": ["threats:read"]
}
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiI…",
"token_type": "Bearer",
"expires_in": 900,
"scopes": ["threats:read"]
}
Then call data endpoints with Authorization: Bearer …. Bearers are HS256
JWTs; the server is the only authority on validity (don't try to do scope
checks client-side from the payload — for diagnostics only).
Per-session budget
Bearers can carry a session_id and max_requests. Once the bearer's session
exceeds the request count, every subsequent request gets 429. Use it to
contain blast radius:
# Mint a bearer with a 10-request budget
curl -X POST $TC/api/auth/agent/token \
-H "X-API-Key: $REFRESH" \
-H "Content-Type: application/json" \
-d '{"session_id": "task-42", "max_requests": 10}'
Server-side, the count is tracked in the agent_token_sessions table keyed by
the bearer's jti. Within the bearer's 15-min ttl, the count persists across
requests. After expiry, the bearer is dead regardless.
CI OIDC exchange
CI environments shouldn't carry long-lived secrets. The server accepts a CI-issued OIDC JWT (GitHub Actions or GitLab) and exchanges it for a 1-hour bearer:
POST /api/auth/ci/exchange
{
"oidc_token": "<the JWT from $ACTIONS_ID_TOKEN_REQUEST_TOKEN flow>",
"session_id": "optional",
"max_requests": null
}
The server verifies the JWT against GitHub's / GitLab's JWKS, checks the
repository and ref claims against a configured allowlist, and issues a
bearer mapped to a service user.
CI bearers are minted with the read-only scope set (no alerts:ack or
future write scopes — CI shouldn't be doing irreversible work).
To enable on the server side:
TC_CI_OIDC_PROVIDERS=github
TC_CI_GITHUB_AUDIENCE=threatcluster
TC_CI_GITHUB_ALLOWED_REPOS=acme/repo,acme/other
TC_CI_GITHUB_ALLOWED_REFS=refs/heads/main
TC_CI_GITHUB_USER_UUID_MAP=acme/repo=<tc-user-uuid>
GitHub Actions example:
permissions:
id-token: write # required for OIDC
steps:
- name: Get OIDC token
id: oidc
uses: actions/github-script@v7
with:
script: |
const t = await core.getIDToken('threatcluster');
core.setOutput('token', t);
- name: Exchange for TC bearer
id: tc_auth
run: |
bearer=$(curl -s -X POST https://api.threatcluster.io/api/auth/ci/exchange \
-H "Content-Type: application/json" \
-d "{\"oidc_token\": \"${{ steps.oidc.outputs.token }}\"}" \
| jq -r .access_token)
echo "::add-mask::$bearer"
echo "TC_BEARER=$bearer" >> $GITHUB_ENV
- name: Query TC
run: |
curl -H "Authorization: Bearer $TC_BEARER" \
https://api.threatcluster.io/api/public/v1/threats?limit=5
No long-lived secret is ever stored in GitHub repo secrets.
Managing keys
Mint a new key
UI: Settings → CLI → Mint key. Tick the scopes, set an expiry, copy the
returned tc_agent_… value. Shown once — store it somewhere safe.
API:
POST /api/public/v1/agent-keys
Authorization: Bearer <auth0-user-token>
{
"name": "my-laptop",
"scopes": ["threats:read", "iocs:read", "darkweb:read"],
"expires_in_days": 90
}
List your keys
GET /api/public/v1/agent-keys
Authorization: Bearer <auth0-user-token>
Returns name, key_id (tk_xxxxxxxx), scopes, request_count, last_used_at,
expires_at — but never the secret value.
Per-key usage
GET /api/public/v1/agent-keys/{key_id}/usage
Authorization: Bearer <auth0-user-token>
Same shape as the list entry plus the per-key counters. Use this to spot a leaked key — unexpected request counts, requests at odd hours, etc.
Revoke
UI: click the Revoke button on the key in Settings → CLI.
API (Auth0 user only — for the dashboard):
DELETE /api/public/v1/agent-keys/{key_id}
Authorization: Bearer <auth0-user-token>
API (the key revoking itself — for tc auth logout):
POST /api/auth/agent/self-revoke
X-API-Key: tc_agent_…
The self-revoke endpoint requires no Auth0 session: presenting the credential
proves you control it, and you can only revoke what you already hold. This is
how tc auth logout hard-kills a credential without dragging the user back to
the web UI.
After revocation, new bearer mint requests fail immediately. Existing
bearers continue to work until their exp (max 15 min). If 15 min is too long
for your threat model, rotate proactively rather than rely on revocation.
Org keys
Organisation-scoped API keys (org_api_keys) follow the same shape as personal
keys but belong to the org rather than a user. They're managed from
Settings → Organisation → API Keys by org admins.
Org keys are useful for shared integrations (a SOC's SIEM, a team's CI). They inherit the org's subscription tier rather than the individual minter's.
Threat model
Things this system protects against:
- Lost laptop / forgotten machine — refresh credential is in keyring (encrypted) or 0600 file (uid-checked).
- Process-table snooping — credentials never go on argv. The
--api-keyCLI flag is rejected. - Subprocess credential inheritance — env vars scrubbed unless explicitly propagated.
- Log/stack-trace leak — HTTP client strips auth headers from any log output.
- Plaintext interception —
http://to non-loopback hosts is refused. - Confused deputy / token replay across surfaces — bearers carry an
audience-equivalent (
sub,kid,scopes); they're not interchangeable with Auth0 tokens. - Prompt-injection scraping by a compromised agent — scope downgrade plus per-session request budget caps the damage even when the agent is otherwise authenticated.
- Credential leak via a compromised dependency on disk — keyring is opaque to processes other than the one that wrote it (OS-enforced).
Things this system explicitly doesn't protect against:
- A compromised process running as the same user as
tc. If an attacker has your uid, they can read your keyring entries directly. Use OS-level user separation if this matters (e.g. dedicated CI user). - Server-side compromise. The signing key (
TC_AGENT_TOKEN_SIGNING_KEY) lives on the API server; if that's compromised, an attacker can mint arbitrary bearers. Rotate the signing key as part of any incident response, then all active bearers die at theirexp.
See also
- CLI (
tc) — the command-line client - API Reference — endpoint reference and base URL
- API Reference: Authentication