Authentication
One header, one token format, and four refusals that answer alike on purpose.
An integration carries its key in one header, and none of its requests are answered without it.
Authorization: Bearer ulg_live_...The token
A token is ulg_live_ followed by 32 hexadecimal characters. The prefix makes
it recognisable in a log or a paste, which is what makes leak scanning possible
at all.
Only a hash of the token is stored. Nobody, including us, can read a key back after it is made: the column holding the hash is granted to no role that speaks to the API, so a query reaching for it is refused even to the key's own owner.
What a refusal means
A key that is unknown, revoked, expired or malformed all answer the same: 401 invalid_key. They are deliberately not told apart, so a token cannot be
probed for which kind of wrong it is.
A key that is live but lacks the permission answers 403 insufficient_scope,
and the body names the scope it was missing:
{
"error": {
"code": "insufficient_scope",
"message": "This key does not hold library.write.",
"scope": "library.write"
}
}What a key can reach
The request runs as the account that owns the key. A key holding lists.write
on an account that cannot write to some private list still cannot write to it,
because the list's own rule is what refuses, not the scope.
A scope is a ceiling. It narrows what a key may attempt; it never widens what its owner may do.
The website comes in the same way
uloggd does not reach the database around its own API: every button on the site calls these same routes. What it carries is the session cookie of whoever is signed in rather than a key, because a key the site issued to itself would be one nobody could revoke.
That changes three things, and only three:
- A session holds no scopes. It is the account itself, so
403 insufficient_scopenever happens to it. - A session spends no key's allowance, and the answer carries no
X-RateLimit-*. The limits the account already had still apply. - On
/api/v1/me,keycomes backnull.
A cookie is only accepted when the request comes from this origin, because a cookie travels on its own and a header does not. A key skips that check: it only arrives if somebody wrote it.
When the header is there, it decides. A revoked key answers 401 invalid_key
even if the browser is also carrying a valid session: presenting a key is
saying which identity you want, and a key that keeps working after it is
revoked, for as long as its author is signed in, is a revocation nobody can
test.
Session-only routes
Some routes answer only a session of the site itself, never a key, because a key must not gain more power than it was given or change the account that made it:
- everything under
/api/v1/account: apps and keys, sessions, the account's state and bootstrap, the export, the data, the username, the birth date, identities and connections, and every Arcade developer and player route, the development URL's (GET,PUTandDELETE /api/v1/account/apps/{id}/arcade/override) included; - the profile layout's images (
/api/v1/profile/layout/media), which spend the account's own storage; - a report on an Arcade game (
POST /api/v1/reportswithonARCADE_APP).
A key gets 403 forbidden on them. The Arcade ones are in
Resources: Arcade. The development URL's
also want a session verified with two-factor, even to read it (see
The development URL).
Keeping a key
- Treat it like a password, because it is one that skips the password.
- Give each integration its own key. Revoking one then costs nothing else.
- Revoke it from the app's Keys tab in the portal the moment a key is loose. Revocation takes effect on the next request.