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_8f0c1b4e2b1a4d599d0e3a2f7c1b9e44Two 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_... productionWhat 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
/v1route that exists returns 401 withtype: "unauthenticated",code: "not_authenticated", and aWWW-Authenticate: Bearerchallenge. - An unauthenticated request to a path under
/v1that does not exist returns 404 withcode: "route_not_found". Routing resolves first;resolvePrincipalattaches 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.xyz401
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/warrants401, 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:
| Status | type | Means | What to do |
|---|---|---|---|
| 401 | unauthenticated | No credentials, or credentials that resolved to nobody. | Authenticate and retry. |
| 403 | forbidden | We know who you are. The answer is still no. | Do not retry. Signing in again changes nothing. |
| 404 | not_found | It 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:
| Case | Status | Challenge |
|---|---|---|
| Header absent or wrong | 401 | Warrant-Worker / X-Telegram-Bot-Api-Secret-Token |
| Secret not configured on the deployment | 403 | none |
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 /healthandGET /— service metadata.