The short version
One credential shape: an API key issued by auth.rodmena.co.uk, presented as Authorization: Bearer rak_…. The ledger holds no key capable of minting a credential it
will accept.
Why that sentence matters
Until 2026-08-22 the ledger verified HS256 tokens signed with a secret it also held. That
meant anyone who could read LEDGER_JWT_SECRET — from the service environment, a systemd
unit, a backup — could mint a token for any tenant with any roles and move money.
Verification and signing were the same key.
That is gone. The ledger now holds only its own namespace key at the authorization service,
which cannot forge a tenant’s credential. probe_no_symmetric_token.py asserts it
continuously: the service must start with no LEDGER_JWT_SECRET present, must refuse a
forged token signed with any historical secret, and no token issuer may ship in the
distribution.
Roles
| Role | Permissions | Use |
|---|---|---|
ledger_reader | ledger_read | reporting, dashboards |
ledger_poster | ledger_read, ledger_post | the normal application key |
ledger_admin | + ledger_admin | also creates asset types and accounts |
ledger_poster deliberately includes read: a poster that cannot read balances is unusable,
and posting responses return them.
The credential carries its own tenant
The authorization service knows the key by a user name of the form lg.<tenant>.<principal>, base64url-encoded. The ledger parses the tenant out of the
validated name, scopes the request to it, and then confirms the principal under that
tenant’s own row-level security. A forged pairing names a row that is invisible under the
wrong tenant, so parsing the name is verified rather than trusted.
Consequence for you: a key belongs to exactly one tenant and cannot be pointed at another.
Another tenant’s identifiers return 404.
Expiry and revocation are enforced by the ledger
The authorization service does not expire keys — it returns expires_at: null and has
no expiry parameter. The deadline on your key is the ledger’s, enforced in its own data
plane against the key you presented. Revocation is likewise checked on every request, with
decisions cached for about 5 seconds; a revoked key stops working within that window.
Two consequences worth stating plainly.
Expiry does not bound a leaked key. Because the deadline is ours alone, a key past it is still a valid credential upstream — it is refused here and nowhere else. If a credential is compromised, the control is revocation, which is immediate. Waiting for an expiry is not a containment plan.
Nothing warns you before a key expires. No job scans expiry; it is read by the request
that refuses on it and by the listings, and that is all. Keys now default to a hundred-year
life so this is not a cliff you inherit by accident, but if you deliberately choose a short
one, put the date in your own calendar. A 401 CREDENTIAL_EXPIRED in production is otherwise
the first notice you get.
When the authorization service is unreachable
You get 503 AUTHZ_UNAVAILABLE with Retry-After. Never 401 or 403.
This distinction is deliberate and is the most important behaviour in this document. “You
are denied” and “we could not ask” are different answers. Returning a denial for an outage
gives a valid caller a final-looking error their client will not retry, and caching it turns
a one-second blip into a minutes-long lockout. If you see 503, back off and retry — your
credential is fine.
There is a second, rarer case: 503 AUTHZ_CREDENTIAL_REJECTED means the authorization
service is up and refusing the ledger’s own credential. Also not your problem, also
retryable, and it pages us.
If a key leaks
Tell the operator; revocation is one command and takes effect within seconds. You will get a new key — the old secret cannot be recovered, only replaced. Keys are capped at 25 per principal.
What is not here yet
You cannot mint or rotate your own keys — an operator does it (issuedb #65/#66). There is no OAuth flow, no session, and no user login for the API. Those belong to the tenant console, which does not exist yet.