Authentication¶
Every request carries one header:
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:
- It creates a new key with the same scopes and a new expiry. Its token is shown once.
- 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.
- 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 sendingnull) creates a key with full access.expires_in_daysdefaults to 90 on create and on rotate;grace_hoursdefaults to 24. - Create and rotate return the token once, as
api_key, withscopes(null= full access) andexpires_at(Unix seconds). Rotate addsrotated_from(the old key's id) andpredecessor_expires_at(when the old key stops working). - Each
GETrow carriesscopes,expires_at(nullfor a key that never expires) andexpired. - An unknown scope, an empty
scopeslist, an out-of-range number or a body that is not a JSON object is refused with422and adetailnaming the problem. Rotating a key that is not yours, is revoked, has expired or does not exist answers404.
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
Originheader (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 with403before the socket opens, even when the browser attached a valid session cookie. A server-side client that sends noOriginis 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:
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 (andcurrent) 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 answers400 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.