Skip to content
Warrantv0.1

Authentication

API keys for agents, sessions for people.

Every /v1 route needs a caller: an agent holding an API key, or a person holding a session cookie. They are not interchangeable.

Authorization: Bearer wr_test_8f0c1b4e2b1a4d599d0e3a2f7c1b9e44

Two kinds of caller

An agent holds an API key. Keys carry no role: an agent can open and verify warrants, because that is an agent's job, and it cannot resolve an escalation. If a key could approve a payment then the approval step above the threshold would be decorative, since anyone holding the key could route around it.

A person holds a session, minted through Privy and stored as an httpOnly cookie. People have a role in exactly one organisation — viewer, operator, admin or owner — and the role is what each route checks.

Key prefixes decide the environment and nothing else does:

wr_test_...   sandbox
wr_live_...   production

What an unauthenticated request gets

Only on routes that exist

The brief for this product says an unauthenticated request to any /v1 route returns 401 whether the route exists or not, because auth runs before routing. The second half is not true, and it is the half that matters when you are debugging.

What actually happens, verified against the running API:

  • An unauthenticated request to a /v1 route that exists returns 401 with type: "unauthenticated", code: "not_authenticated", and a WWW-Authenticate: Bearer challenge.
  • An unauthenticated request to a path under /v1 that does not exist returns 404 with code: "route_not_found". Routing resolves first; resolvePrincipal attaches whoever is calling without rejecting anyone, and each route's own guard is what refuses.

So a 404 from /v1 means the path is wrong, not that your key is. That is useful, and it is the opposite of what a uniform 401 would tell you.

One consequence worth holding onto: under /v1, 404 never means "not allowed". It means the path does not exist, or the resource belongs to another organisation. Permission problems are always 401 or 403, never 404.

GET /v1/warrants HTTP/1.1
Host: api.gowarrant.xyz

401

WWW-Authenticate: Bearer realm="warrant"

{
"error": {
  "type": "unauthenticated",
  "code": "not_authenticated",
  "message": "Sign in, or present an API key.",
  "doc_url": "https://docs.gowarrant.xyz/api-reference/errors#not_authenticated",
  "request_id": "req_9c2a41f0..."
}
}
curl -i https://api.gowarrant.xyz/v1/warrants

401, 403 or 404

The three are not interchangeable. The status alone should tell you what to do next, and the API uses them the way RFC 9110 defines them:

StatustypeMeansWhat to do
401unauthenticatedNo credentials, or credentials that resolved to nobody.Authenticate and retry.
403forbiddenWe know who you are. The answer is still no.Do not retry. Signing in again changes nothing.
404not_foundIt does not exist, or it belongs to another organisation.Check the id.

Every 401 carries a WWW-Authenticate challenge, because a status that says "authenticate" without saying how is only half a message.

A 403 arrives with a code saying which rule you hit: insufficient_role when your role is too low, user_session_required when an API key tried something that needs a person, kill_switch_active when the organisation has stopped new warrants. None of them are worth a retry.

A key that has been revoked, a cookie whose membership was removed, and a sandbox request carrying a wr_live_ key all return 401: each resolves to no principal at all, so there is nobody to refuse. All three return the identical body, so probing keys tells you nothing.

Another organisation's resources are 404, not 403

Reading a warrant that exists but belongs to someone else returns 404, not 403. A 403 would confirm the id is real, which is exactly the fact worth withholding. Every query filters on the calling principal's organisation, so a handler never learns another organisation's id in the first place.

The practical consequence: a 404 never means "you lack permission". It means the id is wrong, or it is not yours — and from outside those are the same thing.

The internal surfaces

/internal/* and /telegram/webhook sit outside /v1 and authenticate with a named header rather than a bearer token. They follow the same rule, and their challenge names the header they actually read:

CaseStatusChallenge
Header absent or wrong401Warrant-Worker / X-Telegram-Bot-Api-Secret-Token
Secret not configured on the deployment403none

The second row is the reason these are worth documenting. When the deployment has no secret set, the route is closed and no credential can open it, so answering 401 would send a caller round a loop it cannot exit. Absent and wrong return the same code as each other, so a forger learns nothing by comparing.

Routes that need no caller

Three are public on purpose:

  • GET /t/:id — a trace receipt. The entire point is a link an outside party can open, so requiring an account would defeat it.
  • POST /bench/run — the verifier bench. It reads nothing and writes nothing, so the only thing to protect is CPU, and it has a tighter rate limit instead.
  • GET /health and GET / — service metadata.