Skip to content
API 7 min read

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:read can call the dark-web endpoints, but the endpoint will then enforce the tier gate (e.g. darkweb/keyword-hits is Business).
  • Stats fold into threats:read. There's no separate stats:read because 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-key CLI 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 interceptionhttp:// 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 their exp.

See also