Skip to content
Ledger by RODMEN/A
Menu

Documentation

Authentication

How credentials work in the Ledger API: one key shape, four roles, expiry and revocation enforced by the ledger, and what to do when a key leaks.

Last updated 2026-09-05.

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

RolePermissionsUse
ledger_readerledger_readreporting, dashboards
ledger_posterledger_read, ledger_postthe normal application key
ledger_admin+ ledger_adminalso 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.