The short version
Ledger takes one kind of credential, an API key issued by auth.rodmena.co.uk and 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, whether from the service environment, a systemd unit or a backup, could mint a token for any tenant with any roles and move money.
Verification and signing were the same key.
That arrangement no longer exists. The ledger now holds only its own namespace key at the authorization service,
which cannot forge a tenant’s credential. The probe probe_no_symmetric_token.py asserts it continuously. The service must start with no LEDGER_JWT_SECRET present, it 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, because 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. The parsed name is checked before it is used.
For you this means 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, and returns expires_at: null and has
no expiry parameter. The deadline on your key is the ledger’s own, and the ledger enforces it in its data plane against the key you presented. Revocation is likewise checked on every request. Decisions are cached for about 5 seconds, and 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, and 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, because 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. You will not inherit this cliff by accident. 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 own 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. This one is also not yours to fix. It is retryable, and it pages us.
If a key leaks
Tell the operator straight away. Revocation is one command and takes effect within seconds. You will get a new key, because the old secret cannot be recovered. Keys are capped at 25 per principal.
What is not here yet
You cannot mint or rotate your own keys, and an operator does it for you (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.