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 |
| 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 a
key. The reference states Authorization: Bearer (jns_ key or SSO session) on every
authenticated endpoint for exactly this reason.
An API key is a bearer credential
Anyone holding a jns_… key can act as you, up to your plan's limits, until the key is
revoked. Keep keys in environment variables or a secret manager, never in a repository, a
frontend bundle, or a URL query string. Rotate on exposure — the value is unrecoverable
after issue, so a lost key is replaced, not looked up.
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/notes", {
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
The code differs by surface. On a proxied prefix the body carries
"code": "csrf_required". On a gateway-native prefix — /api/v1/notes/*, as in the
example above — the body carries "code": "forbidden" with "detail": "csrf_required".
A client that keys its refresh-and-retry on code === "csrf_required" will therefore never
fire on the notes surface. Match status === 403 and treat csrf_required appearing in
either code or detail as the signal.
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.
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.
Failure modes¶
| Status | code |
Cause |
|---|---|---|
401 |
unauthorized |
missing, malformed, expired or revoked credential |
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.