Docs
Authentication
Every request carries one bearer credential. A token's authority is its owner's live permissions intersected with its own scope, recomputed per call.
Every call to the X-Radius API carries a bearer credential in the
Authorization header. There is no handshake, no session cookie, and no
tenant selector read from a request body or a query string — the tenant comes
from the host you call and from the credential itself.
Two credentials, one header
Two kinds of bearer are accepted, and most endpoints accept either:
- A manager session token, returned by
POST /api/v1/auth/login. It belongs to a person, expires after the tenant's configured session length, and can be revoked from Account → Sessions. - An API token, created under Developer → API Tokens. It starts with
xrt_, belongs to a script, and lives until it is revoked or expires.
curl -s https://acme.example.com/api/v1/users \
-H "Authorization: Bearer $XRADIUS_TOKEN"
The two are told apart by shape, not by route: an API token is the literal
prefix xrt_ followed by exactly 43 base64url characters. A session token is
dot-separated and never matches, and a random Authorization header is
rejected on that syntax check before it costs a database lookup. Nothing
downstream of the check knows which credential it was handed, so a route is
never "session only" by accident — the surfaces that refuse a token refuse it
explicitly, and they are listed below.
The base URL
https://<your-tenant-slug>.<instance-domain>/api/v1
Use the host you see in the browser when you are signed in to the panel. Calling the instance's own address resolves no tenant, so every request comes back empty or unauthorised and it looks exactly like a bad credential. If a first call fails, check the host before anything else. See API tokens for how to obtain and rotate the credential itself.
Confirming a credential works
GET /api/v1/auth/me is the cheapest correct first call. It is authenticated
but not permission-gated, so any working credential reaches it, and it reports
exactly what that credential may do:
{
"data": {
"version": "0.2.34",
"manager": {
"id": 41,
"tenant_id": 12,
"email": "ops@acme.example",
"username": "ops",
"status": "active",
"two_factor_enabled": true
},
"roles": ["support"],
"permissions": ["prm_users_index", "prm_users_update"],
"is_admin": false,
"settings": {
"currency": "EGP",
"timezone": "Africa/Cairo",
"default_language": "en"
}
}
}
If that call succeeds, the rest is a matter of picking endpoints. If
permissions is shorter than you expected, read the next section before
suspecting the endpoint.
Authority is an intersection, recomputed per request
An API token's effective authority is:
(the owner's permissions right now) ∩ (the token's allow-list)
Neither half is a snapshot. The allow-list is stored with the token; the
owner's half is read from the database, and the intersection itself is
computed on every single request and never cached. It is materialised in one
place — authz.Resolver.Resolve, the single door every authority question in
the codebase goes through — so a handler cannot forget to apply it.
Three consequences follow, and all three surprise people at least once:
- A token can never do more than its owner. Ticking a permission the owner does not hold has no effect. The scope is a ceiling, not a grant.
- A token shrinks with its owner, silently. Take a permission off the
owner's role and every token that person holds loses it too, with no
notification, no audit entry against the token, and no visible change to the
token's own scope list. A token's stored scope still shows the permission;
the effective set no longer contains it. An integration that broke overnight
with
403and no deploy on your side is usually this. - A restricted token is restricted for an administrator too. A token scoped to "view subscribers" is not an admin token, whoever created it.
Role edits drop the owner's cached set immediately in the process that handled the edit; elsewhere the set is re-read within the resolver's cache window, which is 30 seconds by default. Nothing caches the narrowed set — caching it would leak one token's scope onto its owner's next signed-in request.
Why a restricted token reports is_admin false
When a scope is in play, the resolver returns a permission set whose codes are
the real intersection and whose IsAdmin flag is forced to false, even
when the owner is a tenant administrator. The reserved role names
(tenant_admin, sys_admin) are stripped from the request's claims for the
same reason.
That is not belt-and-braces; it is the only correct shape. IsAdmin is an
exported field, and roughly twenty call sites read it directly rather than
asking Can() — the managers and subscribers admin scopes, the bootstrap
handler, roles, POS, NAS VPN, card-template scoping, the dashboard home, ticket
administration, card template queries, and the Telegram manager service. A mask
applied only inside Can() would leave every one of those seeing an
administrator, so a "read-only" token on a tenant admin would quietly carry
full admin authority. That is a false guarantee, which is worse than no
guarantee at all.
What you see as a caller: GET /auth/me on a restricted token returns
"is_admin": false and a permissions array that is the intersection — not
the owner's catalogue. Gate your client on that array, not on the owner's role
name.
An empty scope may do nothing
A restricted token's scope is an allow-list, and an empty allow-list means may do nothing. It never means unrestricted. A token created with no permissions ticked authenticates correctly and is then refused by every permission-gated endpoint.
Full authority is a separate flag on the token, not an empty list. The polarity is deliberate: the failure mode of the opposite convention is a token that escalates, and the failure mode of this one is a token that stops working.
What refuses a machine credential
Some surfaces refuse an API token outright, and answer 403 ERR_FORBIDDEN
with the message key api_token_forbidden:
- Account credentials — password, two-factor, sessions. A token that could change its owner's password would be an account-takeover primitive for anyone who ever read it out of a configuration file.
- The API-token routes themselves. Otherwise a token could mint, roll or reveal tokens and widen itself past its own scope in one call.
- Signing in as another manager (
POST /managers/{id}/login-as), which would let a token hop to a more powerful account.
Signing in as a subscriber (POST /users/{id}/login-as) is allowed, because
it goes the other way: the borrowed session can do strictly less than the
manager who authorised it.
Separately, a handful of routes refuse an impersonated caller — one acting through login-as — including password resets, role assignment and compensation approval. Those refuse the borrowed session regardless of which credential type it was minted from.
When a call fails
A 401 always means the credential itself: it is missing, malformed,
cryptographically invalid, or no longer valid. The envelope's code is the
coarse ERR_UNAUTHORIZED for all of them, so the discriminator is
error.details.reason:
details.reason |
What happened |
|---|---|
missing_bearer_token |
No Authorization: Bearer header at all |
invalid_token |
Signature, expiry or subject is wrong; also a subscriber-portal token sent to the admin API |
session_revoked |
The session was signed out from Account → Sessions |
api_token_invalid |
No such token, or its tenant is no longer serviceable |
api_token_revoked |
Revoked permanently — mint a new one |
api_token_expired |
Past its expiry date |
api_token_disabled |
Switched off, and reversible |
api_token_owner_inactive |
The owning manager is suspended or deleted |
An unknown token and a well-formed guess are answered identically, on purpose.
A 403 means the credential is fine and the authority is not:
insufficient_permissions (widen the token's scope, or check the owner holds
the permission at all), api_token_forbidden (that endpoint refuses machine
credentials by design), or license_locked (the whole tenant is over its
subscriber cap or past expiry, and only the licence page answers until that is
resolved).
Every response carries error.request_id. Quote it when asking for help — it
is the one value that finds the exact request in the server log.
Last updated