API keys
Mint a key for a tenant in a project and an environment, and read what the SDK gives your handler.
A key belongs to one project, one tenant and one environment, and carries optional scopes, an optional expiry and optional per-key rate limits.
API keys → Mint a key asks for exactly those. Over the API:
curl -X POST https://keyring-api.belghalem.fr/v1/keys \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"project_id":"'$PROJECT_ID'","tenant_id":"'$TENANT_ID'","env":"test","name":"Local dev key","scopes":["read:orders"]}'{
"object": "api_key",
"id": "01a0...",
"env": "test",
"display_prefix": "kr_test_a1b2c3",
"key": "kr_test_...",
"key_shown_once": true,
"scopes": ["read:orders"]
}The key is shown once. display_prefix is what you will see everywhere afterwards: the prefix plus six characters of the body, enough to recognise a key in a log and useless as a credential.
The key format
kr_live_<32 base64url chars><8 hex chars> 48 characters
kr_test_<32 base64url chars><8 hex chars>24 random bytes, 192 bits of entropy, and a CRC-32 over the prefix and body. The checksum is not a security control: it lets a typo be refused with no lookup, and it is one of GitHub's requirements for a scannable secret. Key format has the rest, including why the environment is inside the hashed material.
What your handler sees
After the middleware, req.keyring (or the KeyringContext your framework hands you) is:
| Field | Meaning |
|---|---|
keyId, workspaceId, projectId, tenantId | The identity. null on a fail-open decision, where there is no policy record. |
env | 'live' or 'test', read from the key itself. |
scopes | What the key was minted with. has(scope) matches them. |
displayPrefix | Loggable by construction. |
expiresAt | Epoch milliseconds, or null. |
degraded | True whenever the decision came from stale or incomplete policy. |
verified | False when the key was admitted with no policy record at all. |
rateLimit | The counter states after this request, or null when the counters were unreachable. |
| your resources | Whatever you passed as resources, resolved for this key's environment. |
It is KeyringContext | null in every adapter: null on a route configured skip: true, so read it with ?..
Scopes
Scopes are free-form strings you define. A route can require them:
routes: { 'POST /v1/payments': { onUnavailable: 'closed', scopes: ['write:payments'] } }A key without the scope gets 403 with missing_scopes listing what it lacks. A route that declares scopes is deliberately not scope-checked on a fail-open decision, because there is no scope set to check against; that is why a money route also sets onUnavailable: 'closed'.
Editing a key
The key's page has Edit (name, scopes, expiry, rate limits, a free-form meta), Rotate and Revoke. PATCH /v1/keys/:id is the same edit. Every change bumps the project's policy version and reaches every SDK node within a poll.
Expiry
expires_at is honoured by the SDK from its cached policy, so an expired key dies inside your process with nothing scheduled to run at the hour mark. A leaked live key reported by GitHub's secret scanning is quarantined the same way: its expiry is pulled in to one hour, never extended, and you get a mail.