Skip to content

Authentication

Every request carries one header:

curl https://api.trilocore.ai/api/v1/workspaces \
  -H "Authorization: Bearer $TRILOCORE_API_KEY"
import os
import httpx

client = httpx.Client(
    base_url="https://api.trilocore.ai",
    headers={"Authorization": f"Bearer {os.environ['TRILOCORE_API_KEY']}"},
)
r = client.get("/api/v1/workspaces")
r.raise_for_status()
const auth = { Authorization: `Bearer ${process.env.TRILOCORE_API_KEY}` };

const r = await fetch("https://api.trilocore.ai/api/v1/workspaces", {
  headers: auth,
});

There is one authentication scheme — Authorization: Bearer — and two kinds of token that fit it. Everything else about identity is derived by the front door, not sent by you.

The two credentials

API key Browser session
Looks like jns_… a signed session token
Sent as Authorization: Bearer jns_… Authorization: Bearer …, or the trilocore_sso cookie
Obtained from the API keys screen in the Trilocore app signing in to the Trilocore app
Intended for servers, CI, scripts, SDKs the product's own web UI
Expires after the 1–365 days chosen when it was created (default 90) after at most 15 minutes
Can be limited to some products yes — see scopes never
CSRF token needed never yes, when the cookie is used on a writing method

Both resolve to the same identity model, so an endpoint that works with a session works with an unrestricted key. The reference states Authorization: Bearer (jns_ key or SSO session) on every authenticated endpoint for exactly this reason.

A session token lives at most 15 minutes. The front door accepts one only if its lifetime, from issue (iat) to expiry (exp), is 900 seconds or less, which is the longest the sign-in service issues. Anything longer is refused with 401 before it reaches a service. The app renews its session on its own; for a script or a server, use an API key rather than holding on to a session token.

An API key is a bearer credential

Anyone holding a jns_… key can do whatever its scopes allow, as you, up to your plan's limits, until the key expires or is revoked. Keep keys in environment variables or a secret manager, never in a repository, a frontend bundle, or a URL query string. Revoke a key on exposure, and rotate keys you replace on a schedule. The value is unrecoverable after issue, so a lost key is replaced, not looked up.

API keys: scopes, expiry and rotation

Create keys on the API keys screen of the Trilocore app (Generate new key). You choose two things, and neither can be changed afterwards:

  • Expires after — 30, 60, 90 (the default), 180 or 365 days. Every new key expires.
  • Access — Full access, or Restricted to the products and access levels you pick.

The screen shows each key's access and expiry. Keys created before expiry existed show No expiry and never expire; rotate them to replace them with a key that does.

Scopes

A restricted key carries a list of scopes, each <product>:<access>:

Product Covers
bevm the EVM Auditing IDE, /api/v1/bevm/*
bsvm the Solana Auditing IDE, /api/v1/bsvm/*
workbench workbench records (audit evidence, operations, artifacts), /api/v1/workbench/*
radar contract search, /api/v1/radar/*
workspaces workspaces, projects, notes, contracts, audits and activity, /api/v1/workspaces/*
ide the Contract IDE, /api/v1/ide/*
arch the architecture explorer, /api/v1/arch/*
janus Janus AI — not in service
model the model API — not in service
* every product

access is read or write, and write includes read. So radar:read can search contracts and nothing else, bevm:write can do everything on the EVM Auditing IDE, and *:read can read everything and change nothing. A key holds 1 to 20 scopes.

A request needs read when its method is GET, HEAD or OPTIONS, and for the POST operations that change nothing:

POST path
/api/v1/bevm/morphvm:decode
/api/v1/bevm/sessions/{uid}/balances:batchGet
/api/v1/bevm/sessions/{uid}/facets:resolve
/api/v1/bevm/sessions/{uid}/introspections
/api/v1/bevm/sessions/{uid}/logs:query
/api/v1/bevm/sessions/{uid}/recordings
/api/v1/bevm/sessions/{uid}/storage-slots:read
/api/v1/bevm/sessions/{uid}/traces
/api/v1/bevm/sessions/{uid}/transactions:resolve
/api/v1/bevm/stateless/calls
/api/v1/bevm/storage-slots:computeMappingKey
/api/v1/bevm/targets/{uid}/addresses:classify
/api/v1/bsvm/sessions/{uid}/transactions:simulate
/api/v1/radar/code/search
/api/v1/radar/code/search:count

Every other POST, PUT, PATCH and DELETE needs write — including /api/v1/bevm/sessions/{uid}/contracts:call: a call to a function that is not view, or one with force_send, sends a real transaction on the fork. A WebSocket upgrade needs read on its product.

A request the key's scopes do not cover is refused before rate limits, quota and idempotency replay are applied:

# A key whose only scope is workspaces:read, attempting a write.
curl -i -X POST https://api.trilocore.ai/api/v1/workspaces/notes/spaces/{id}/entries \
  -H "Authorization: Bearer $TRILOCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"title": "Findings triage"}'
HTTP/1.1 403 Forbidden
content-type: application/problem+json
www-authenticate: Bearer error="insufficient_scope", scope="workspaces:write"

{
  "type": "https://platform.trilocore.com/docs/en/api/errors/#insufficient_scope",
  "title": "Insufficient scope",
  "status": 403,
  "detail": "This API key's scopes do not grant `workspaces:write`.",
  "code": "insufficient_scope",
  …
}

The scope in the WWW-Authenticate header (RFC 6750) names exactly what the request needed. Retrying does not help: create a key that has that scope.

Browser sessions are never scope-limited. Endpoints under /api/v1/public/* need no credential and so no scope.

Expiry

An expired key is refused with the same 401 unauthorized as a revoked or unknown key, so the response does not tell you which. If a key that used to work starts answering 401, check its expiry on the API keys screen first.

Rotation

Rotate on the API keys screen replaces a key without downtime:

  1. It creates a new key with the same scopes and a new expiry. Its token is shown once.
  2. The old key keeps working for a grace period you choose — from immediately up to 7 days (default 1 day) — and then stops. Rotation never extends the old key's life: if it was due to expire sooner, it still expires then.
  3. Move your clients to the new key within the grace period. Both keys work until it ends.

Only a working key can be rotated; an expired or revoked key cannot. An account holds at most 5 live keys; rotation may go one over that while an old key is in its grace period, never more — so to rotate again, wait for the grace period to end or revoke a key first.

Managing keys over HTTP

The API keys screen calls these endpoints of the sign-in service, under /api/v1/auth/*. They accept a signed-in browser session only — an API key cannot create, rotate or revoke keys — so automate against them only from a context that holds a session.

Method Path under /api/v1/auth/* Body
POST /keys {"label"?: str, "scopes"?: [str], "expires_in_days"?: 1–365}
GET /keys —
POST /keys/{key_id}/rotate {"grace_hours"?: 0–168, "expires_in_days"?: 1–365}
DELETE /keys/{key_id} —
  • Omitting scopes (or sending null) creates a key with full access. expires_in_days defaults to 90 on create and on rotate; grace_hours defaults to 24.
  • Create and rotate return the token once, as api_key, with scopes (null = full access) and expires_at (Unix seconds). Rotate adds rotated_from (the old key's id) and predecessor_expires_at (when the old key stops working).
  • Each GET row carries scopes, expires_at (null for a key that never expires) and expired.
  • An unknown scope, an empty scopes list, an out-of-range number or a body that is not a JSON object is refused with 422 and a detail naming the problem. Rotating a key that is not yours, is revoked, has expired or does not exist answers 404.

Unauthenticated endpoints

Four endpoints answer without a credential, because they exist to let a third party verify work you have published:

# Fetch the public key set used to verify attestation signatures.
curl https://api.trilocore.ai/api/v1/public/attestations/jwks.json

The others are POST /api/v1/public/attestations/verify, GET /api/v1/public/reports/{report_id} and GET /api/v1/public/reports/link/{link_id}. They are rate limited per client IP rather than per key, and they cap request bodies more tightly than the authenticated surface — an oversized body is rejected with 413 payload_too_large.

The specification document at /api/v1/meta/openapi.json is also public.

Browser sessions and CSRF

Skip this section if you are using an API key; it does not apply to you.

Signing in to the Trilocore app sets three cookies:

Cookie Purpose
trilocore_sso the session token the API reads
__Host-trilocore_csrf the CSRF double-submit token
trilocore_refresh used only to renew a session; the API never reads it

When a request is authenticated by cookie and uses a writing method — POST, PUT, PATCH or DELETE — you must mirror the CSRF cookie's value into an x-csrf-token header:

// Browser: read the double-submit cookie and echo it on unsafe methods.
const csrf = document.cookie
  .split("; ")
  .find((c) => c.startsWith("__Host-trilocore_csrf="))
  ?.split("=")[1];

await fetch("https://api.trilocore.ai/api/v1/workspaces/notes/spaces/{id}/entries", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json", "x-csrf-token": csrf },
  body: JSON.stringify({ title: "Findings triage" }),
});

Omit it and the request is rejected with 403 before it reaches any service.

Branch on 403, not on one code string

Every prefix is proxied through the same check now, so the body carries "code": "csrf_required" everywhere. That was not always true: notes (now /api/v1/workspaces/notes/*) used to be served by the gateway itself and answered "code": "forbidden" with "detail": "csrf_required", so a client keying its refresh-and-retry on the code string never fired on that one surface. The divergence is gone — but match status === 403 and treat csrf_required in either code or detail as the signal anyway, because that is the version of the check that was right in both worlds.

Two things to note. First, Authorization: Bearer is exempt — a bearer token is not sent automatically by the browser, so there is nothing for a cross-site request to forge. Second, the requirement is on the authentication method, not the endpoint: the same endpoint needs a CSRF token from a cookie-authenticated caller and does not need one from a key-authenticated caller.

WebSockets

The Auditing IDE's live streams are WebSockets, and two rules apply to them:

  • A browser must open one from an allowed origin. A WebSocket upgrade that carries an Origin header (every browser sends one) is accepted only from a Trilocore origin, the same list cross-origin requests are held to. Any other origin is refused with 403 before the socket opens, even when the browser attached a valid session cookie. A server-side client that sends no Origin is unaffected.
  • A socket does not outlive its credential. When the token that opened the socket expires, the front door closes the socket with close code 1008 (policy violation). Reconnect with a current token.

Headers the front door controls

The front door does not pass your request through unchanged. It establishes who you are once, then forwards a deliberately small set of headers to the service that owns the resource:

content-type      accept
if-match          if-none-match
if-modified-since if-unmodified-since
idempotency-key

Everything else is dropped and, where the backend needs it, re-issued by the front door from the identity it just verified. That includes your Authorization header, your Cookie header, every x-trilocore-* header, and X-Forwarded-For.

Never set an x-trilocore-* header yourself

These headers carry verified identity — key id, user id, organisation id, persona, organisation role, teams, client IP — from the front door to the service behind it. A client-supplied value is stripped and replaced, so setting one cannot elevate your access. It also cannot achieve anything: if you are relying on one to make a call work, the call is wrong. The same applies to X-Forwarded-For; the client IP used for logging and per-IP limits is determined by the edge, not by you.

A small number of additional request headers are read by the front door itself to scope a call rather than forwarded verbatim. Where one applies, it is documented alongside the endpoints that use it. One applies across a whole product and is described here.

The session tag

On the Auditing IDE's session API (/api/v1/bevm/*, /api/v1/bsvm/* for Solana, and the shared /api/v1/workbench/*), one caller can hold several independent sessions — one per browser tab, say. A request header chooses which one a call addresses:

# Open a second, independent session alongside your default one.
curl -X POST https://api.trilocore.ai/api/v1/bevm/sessions \
  -H "Authorization: Bearer $TRILOCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "trilocore-session-tag: tab-2" \
  -d '{"network": "eth", "rpc_url": "https://your-node.example/eth",
       "target_address": "0xYourTargetContract", "block": "latest"}'

Every later call for that session sends the same trilocore-session-tag: tab-2. trilocore-session-tag is the only header that selects a session.

  • Value: [a-z0-9_-]{1,32} — 1 to 32 lowercase ASCII letters, digits, _ or -.
  • Omit it to use your default session. A session tag sent under any other header name is not read at all, so that request addresses your default session too. If calls you meant for a tagged session land in the default one, check the header name first.
  • It is part of the resource id. The session {uid} you receive (and current) names the session for the tag you sent; the same call with another tag, or none, addresses a different session.
  • A value the front door cannot honour is refused, never ignored. A malformed value — TabA, an empty string, 33 characters — or the header repeated with different values answers 400 validation_failed, and nothing is executed.

Failure modes

Status code Cause
401 unauthorized missing, malformed, expired or revoked credential
403 insufficient_scope the API key's scopes do not cover this request
403 csrf_required cookie-authenticated writing method with no x-csrf-token
403 forbidden authenticated, but not permitted to perform this operation
403 persona_forbidden the credential's persona may not use this endpoint
404 not_found the resource does not exist or is not yours

A 401 does not mean the endpoint exists

Authentication is checked before the endpoint is looked up. An unauthenticated request to a path that does not exist answers 401, not 404, so that the API never confirms which routes are real to an anonymous caller. Only an authenticated caller sees 404 for a path that is genuinely absent — so when you are probing an unfamiliar path, authenticate first or you will misread the result.

See Errors for the full catalogue and the response shape.