Core Concepts
Consumer Identity
Tell Kipchak who each request is from, once, for usage metering and quotas, whether clients use API keys, tokens from an identity provider, or signed requests.
Introduction
Authentication decides whether a request may proceed. Billing and quotas need something more: a stable name for who the request is from, the consumer, and often the organisation it belongs to, the tenant.
Where that name comes from depends on how clients authenticate:
| Authentication | Clients are defined | The consumer is |
|---|---|---|
| API keys | In Kipchak, in kipchak.auth.key | The client name the key maps to |
| JWKS or JWT | In your identity provider (Keycloak, Auth0, Entra ID, Cognito, ...) | A claim in the verified token, such as client_id, azp or sub |
| Request Signing | In Kipchak, in kipchak.auth.hmac | The key ID of the verified signature |
| An API gateway in front | In the gateway | A header the gateway sets |
config/kipchak.identity.php describes this once. Usage Metering and Quotas both read it, so a client is the same consumer on the bill and in the limits.
kipchak/identity is installed as a dependency of those packages; there is nothing to install or initialise.
Configuration
<?php
// config/kipchak.identity.php
return [
// Who the request is billed and limited as. Sources are tried in order; the first that answers wins.
'consumer' => [
['source' => 'token', 'claim' => 'client_id'], // tokens from your identity provider
['source' => 'api_key'], // auth-key clients
],
// Which organisation it belongs to. Optional.
'tenant' => [
['source' => 'token', 'claim' => 'org_id'],
],
];
A request that no source can identify has no consumer. Metering does not record it by default, and quotas let it through by default, leaving the decision to authentication.
Sources
| Source | Options | Reads |
|---|---|---|
token | claim | A claim of the token that auth-jwks or auth-jwt verified. Dots reach into nested claims: org.id. |
api_key | keys, header, query_param | The client name for the request's API key in kipchak.auth.key's authorised_keys. The key itself is never used as an identity, so it never reaches the event stream or the counter store. |
attribute | name, claim | Any request attribute set by an earlier middleware, such as kipchak.hmac.key_id. |
header | name, hash | A request header. With 'hash' => true, sha256:<hex> of it. |
callable | resolve | Your own function: fn (ServerRequestInterface $request): ?string. |
Every source also takes a map, which translates what it found into a name. A value that is not in the map resolves to nothing, and the next source is tried:
['source' => 'token', 'claim' => 'client_id', 'map' => [
'0oa1b2c3d4e5f6' => 'acme-corp',
'0oa9z8y7x6w5v4' => 'globex',
]],
Where identity can be read from
Identity is read from the request when the metering or quota middleware runs. A middleware can only see what the middlewares that ran before it attached to the request, so authentication must run first.
Slim runs the middleware added last first. In middlewares/middlewares.php, initialise quotas and metering before authentication:
Error::initialise($app);
Quotas::initialise($app); // runs after authentication and metering
Metering::initialise($app); // runs after authentication
Key::initialise($app); // authentication
JWKS::initialise($app);
SubashiPro::initialise($app); // the firewall: runs first of all
Authentication added to a single route or group runs after every global middleware, so global metering and quotas cannot see what it verifies. Authenticate globally (using ignore_paths for public routes), or attach Meter and Quota to the same routes, after the authentication middleware:
$app->get('/v1/reports', [Reports::class, 'list'])
->add(new Quota($app->getContainer()))
->add(new Meter($app->getContainer()))
->add(new AuthJWKS($app->getContainer())); // added last, so runs first
Never trust an unverified identity
tokenreads only the request attribute that auth-jwks and auth-jwt set after verifying the token. A bearer token that has not been verified is never read, so a forged token cannot claim to be another client.api_keyonly answers for keys listed inauthorised_keys.headeris safe only for a header your gateway sets and overwrites. A header the client controls lets the client bill or limit someone else.
Examples
API keys (auth-key)
Clients are defined in Kipchak. The key identifies them, and the name it maps to is the consumer:
// config/kipchak.auth.key.php
'authorised_keys' => [
'k_live_8f2c...' => 'acme-corp',
'k_live_31d0...' => 'globex',
],
// config/kipchak.identity.php
return [
'consumer' => [['source' => 'api_key']],
];
A request with apikey: k_live_8f2c... is billed and limited as acme-corp. A new key for the same client, for rotation, maps to the same name, so its usage continues on the same account.
Tokens from an identity provider (auth-jwks, auth-jwt)
Clients are defined in your identity provider, which issues signed tokens. Which claim names the consumer depends on what you are billing:
| You bill per | Claim | Typical in |
|---|---|---|
| Application (machine-to-machine, OAuth client credentials) | client_id, or azp | Auth0, Okta, Keycloak, Cognito |
| User | sub | Every provider |
| Organisation | a custom claim such as org_id, as the tenant | Auth0 Organisations, Keycloak, Entra ID (tid) |
// config/kipchak.identity.php
return [
'consumer' => [
['source' => 'token', 'claim' => 'client_id'], // machine-to-machine clients
['source' => 'token', 'claim' => 'azp'], // user tokens: the application acting for the user
],
'tenant' => [
['source' => 'token', 'claim' => 'org_id'],
],
];
The provider's client IDs are opaque. To bill under readable names, add a map, or keep the IDs and map them in your billing system.
Several identity providers
When tokens from more than one provider are accepted, the issuer (iss) tells them apart. Map it to a name and use it as the tenant, so each provider's users are counted and billed separately:
'tenant' => [
['source' => 'token', 'claim' => 'iss', 'map' => [
'https://login.acme.example/realms/main' => 'acme-sso',
'https://globex.eu.auth0.com/' => 'globex-sso',
]],
],
To limit everyone signing in through one provider together, see Limits shared by everyone from one identity provider.
Signed requests (auth-hmac)
Request Signing puts the verified key ID on the request:
'consumer' => [
['source' => 'attribute', 'name' => 'kipchak.hmac.key_id'],
],
A gateway in front
When an API gateway authenticates and passes the result on in headers it controls:
'consumer' => [['source' => 'header', 'name' => 'X-Consumer-ID']],
'tenant' => [['source' => 'header', 'name' => 'X-Tenant-ID']],
Several kinds of client at once
Sources are tried in order, so one API can serve all of them:
'consumer' => [
['source' => 'token', 'claim' => 'client_id'],
['source' => 'attribute', 'name' => 'kipchak.hmac.key_id'],
['source' => 'api_key'],
],
Overriding per package
The metering and quota configs can set their own consumer or tenant, which then replaces the shared one for that package only. Each key falls back separately, so a package can override the tenant and keep the shared consumer.
Setting the consumer from a route
When the identity is only known inside the route, set it there:
use Kipchak\Middleware\Metering\Usage;
Usage::of($request)?->setConsumer($account->billingId);
This applies to metering, which reads the identity after the route has run. Quotas decide before the route runs, so they need the identity from a source.
Worker mode
The auth-jwks and auth-jwt middlewares also keep the decoded token in the container under token, as before. In FrankenPHP worker mode the container lives for the whole worker, so that entry still holds the previous request's token on later requests that carry none. In testing, 12 of 12 unauthenticated requests served after authenticated ones saw a previous caller's token there.
The token source reads the request attribute, which exists only on the request that carried the verified token. Use the attribute in your own code too:
$token = $request->getAttribute('kipchak.auth.token'); // null on routes without a verified token