API consumers and plugin flows
Since v18.0.0, every call going through an API plan is attached to a
consumer, and a consumer is always an API key. It does not matter
whether the caller presents client credentials, a JWT token, a client certificate or nothing at all:
the plan turns whatever identifies the caller into an API key, and everything an API key carries
(quotas, restrictions, rotation, metadata, plugins) then applies to the call.
That single change makes two things possible:
- the same rules for every plan kind — a
keylessorjwtplan can rate limit its callers, one by one, exactly like anapikeyplan does; - a plugin chain per consumer — a plan, or a single API key, can add its own plugins to the route it calls, so two consumers of the same route do not go through the same pipeline.
This page describes the runtime behaviour. The properties themselves are documented with their entity: plans for an API, and plugin flow for an API key.
Every plan identifies a consumer
When an API is deployed, each published plan adds its own apikey extractor to every route of
the API, plus one shared Api consumer enforcer at the very end of the access validation phase.
| Plan kind | Plugin added | Identity comes from | Generated client id |
|---|---|---|---|
keyless | Expression apikey extractor | an expression, ${req.ip} by default | keyless_<plan id>_<value> |
apikey | Apikey calls | client credentials (basic auth, headers, client id, bearer, signed JWT) | the client id of the API key |
oauth2-local | Apikey calls | a token delivered by the client credentials endpoint of the plan | the client id of the API key |
jwt | Jwt apikey extractor | a claim of a token verified by a JWT verifier | jwt_<plan id>_<claim value> |
mtls | Client certificate apikey extractor | the client certificate of the TLS connection | mtls_<plan id>_<DN attribute, or a value derived from the subject and the serial number> |
oauth2-remote | OIDC apikey extractor | a claim of a token verified against the OIDC settings of an auth. module | oauth2_<plan id>_<claim value> |
The client id of a generated identity is always prefixed with the kind and the id of the plan. Two APIs sitting behind the same identity provider therefore never share a consumer, and a value coming from a request can never resolve to the client id of a real API key.
What the plan hands over
An API key generated by a plan is built exactly like the one a subscription would create, so the consumer gets the same treatment whether it subscribed or not:
| Property | Value |
|---|---|
clientId | the prefixed identity described above |
clientSecret | a random token, never handed to the caller |
throttlingStrategy | the rateLimiting.strategy of the plan |
apiRef | { "api": <api id>, "plan": <plan id>, "sub": "" } |
authorizedEntities | the API itself, or whatever the plan template declares |
restrictions, rotation, validUntil, readOnly, ... | the apikey template of the plan (an apikey plan carries a full template, the other kinds only carry the tags and metadata of the plan) |
metadata | the plan metadata, plus created_by: apikey-extractor |
An API key that already exists always wins over a generated one: a revoked, disabled or expired key
cannot be resurrected by a caller presenting the same identity. Such a call is answered with
401 unknown_apikey and raises a revoked apikey usage alert.
Creating the API key, or not
Every extractor has a create_if_missing flag, on by default.
create_if_missing | Behaviour |
|---|---|
true | the generated API key is written to the datastore, appears in the UI, and can be edited like any other |
false | the API key only lives for the duration of the call |
In both cases the caller is identified and served, and in both cases quota counters are keyed by client id, so consumption adds up across calls. Turning it off is what keeps a public API from filling the datastore with one entry per caller.
create_if_missing: false does not mean only accept API keys that already exist. It only
decides whether the identity is persisted.
The consumer enforcer
Api consumer enforcer runs last (plugin index 1000 in the access validation phase) and is the
only place where a call made through a plan is counted:
- it requires a consumer — no API key and no authenticated user means
401(errors.auth.unauthorized, withapikey_rejection_reason: no consumer identified on the call); - it applies the restrictions of the API key;
- it triggers secret rotation when due;
- it consumes one unit of the throttling strategy of the API key, answering
429(errors.too.much.requests) when a quota is exhausted, and raising the usual quotas almost exceeded / quotas exceeded alerts.
Because the count happens at the very end of access validation, a call rejected by anything else — another plugin, a failed JWT verification, a restriction — never consumes quota. An API key wins over a user session: a caller that went through an auth. module and carries an API key is enforced against the API key.
APIs with at least one published plan also get Send otoroshi headers back, so the caller receives
the usual Otoroshi-* response headers, remaining quotas included.
Several plans on the same route
All the published plans of an API are applied on all its routes, and every extractor writes to the same place: the first plugin that identifies a consumer wins. The order is fixed by the plugin index of each extractor:
| Order | Plugin | Index |
|---|---|---|
| 1 | Apikey calls (apikey, oauth2-local) | 2.00 |
| 2 | Jwt apikey extractor | 2.01 |
| 3 | Client certificate apikey extractor | 2.02 |
| 4 | OIDC apikey extractor | 2.03 |
| 5 | Expression apikey extractor (keyless) | 900 |
| 6 | Api consumer enforcer | 1000 |
A keyless plan always resolves an identity, so it runs last: a caller presenting real credentials
is identified by its own plan, and only the others fall back on the public plan.
Extractors added by a plan are not strict: failing to identify a caller lets the call continue,
and the enforcer is the one turning it away at the end. A rejection an extractor took on its own
(403, 404 or 429, a JWT verifier answering with its own error result) is returned as is.
Using the extractors on a plain route
The four extractors are regular plugins: they can be added to any route, outside of any API, to give a consumer identity to callers that have none. They all share the same settings on top of their own:
| Property | Default | Description |
|---|---|---|
strict | depends on the plugin | Reject the call when no identity can be extracted. When false, the call goes on without any API key |
client_id_prefix | depends on the plugin | Namespace of the identities this plugin mints |
create_if_missing | true | Persist the generated API key |
api_id / plan_id | none | Fill the apiRef of the generated API key, which is what binds it to the plugins of a plan |
throttling_strategy | none | Rate limiting strategy of the generated API key |
apikey | {} | Template of the generated API key (same shape as the apikey access mode configuration) |
| Plugin | Own settings |
|---|---|
cp:otoroshi.next.plugins.NgExpressionApikeyExtractor | expression (default ${req.ip}), client_id_prefix default public_, strict default false |
cp:otoroshi.next.plugins.NgJwtApikeyExtractor | verifier, client_id_path (default client_id, JSON path supported), client_id_prefix default jwt_, strict default true |
cp:otoroshi.next.plugins.NgClientCertApikeyExtractor | regex_subject_dns, regex_issuer_dns, client_id_field, client_id_prefix default mtls_, strict default true |
cp:otoroshi.next.plugins.NgOidcApikeyExtractor | ref (auth. module), client_id_path, source (token location), fetch_user, user_metadata_key, client_id_prefix default oauth2_, strict default true |
An extractor only identifies the caller. To also enforce the quotas and the restrictions of the
resulting API key, add cp:otoroshi.next.plugins.NgApiConsumerEnforcer after it, like a published
plan does.
{
"plugin": "cp:otoroshi.next.plugins.NgExpressionApikeyExtractor",
"enabled": true,
"config": {
"expression": "${req.headers.x-consumer}",
"client_id_prefix": "public_",
"strict": true,
"create_if_missing": false,
"throttling_strategy": {
"id": "LegacyThrottlingStrategyConfig",
"quota": { "window": 10, "daily": 1000, "monthly": 10000 }
}
}
}
Beware of what an expression resolves to when the value is missing: ${req.headers.x-consumer}
gives no-header-x-consumer when the header is absent, which puts every such caller on one shared
quota. Use an empty default (${req.headers.x-consumer:}) to have those calls rejected instead —
an empty or unresolved expression is treated as a failed extraction.
Plugin flows per consumer
A route has a plugin chain. With an API, the plan a consumer subscribed to and the API key itself can each bring plugins of their own, which are applied on top of the chain of the route, for that consumer only.
Plugins come from two places:
- the plan, through its
pluginsproperty — either an inline chain, or aflow_refpointing at one of the flows of the API so that several plans share the same chain (an inline chain extends the referenced flow, it does not replace it); - the API key, through its own
pluginsproperty.
An API key is bound to a plan through its apiRef. Subscriptions fill it automatically, plans
generating identities fill it too, and it can be set by hand on any API key.
{
"clientId": "consumer-123",
"apiRef": { "api": "api_xxxx", "plan": "plan_gold", "sub": "sub_yyyy" },
"plugins": {
"overrides": false,
"plugins": [
{
"plugin": "cp:otoroshi.next.plugins.AdditionalHeadersOut",
"enabled": true,
"config": { "headers": { "X-Consumer": "${apikey.clientId}" } }
}
]
}
}
Composition rules
| Situation | Result |
|---|---|
| the plan has plugins, the API key has none | the chain of the plan |
| both have plugins | the plan first, the plugins of the API key appended |
| the API key has plugins, no plan behind it | the chain of the API key |
| an empty chain, on either side | behaves like no chain at all |
overrides set on either side | the composed chain overrides |
What runs, and when
The composed chain is applied right after the access validation phase of the route, once a consumer has been identified:
- the route runs its own
before request,pre-routeandaccess validationphases, as usual — this is where the API key is identified; - the plugins brought by the consumer run their
before request,pre-routeandaccess validationphases (they can reject the call at that point); - the two chains are merged, and the rest of the request — request transformation, backend call, response transformation — goes through the merged chain.
With overrides enabled, the plugins of the route are replaced by those of the consumer, except
the pre-route and access validation plugins that already ran: dropping them would drop their other
steps too — Apikey calls for instance would stop wiping the credentials from the backend request.
Global plugins are always kept. A route plugin evicted by an override still gets its afterRequest
callback if its beforeRequest had run, so plugins holding a resource (the Coraza WAF, for
instance) are never left hanging.
Debugging
Enabling debug_flow, or exporting the execution report, shows the
composition as its own steps:
| Task | Content |
|---|---|
handle-apikey-plugins-flow | resolution and composition of the chain |
apikey-flow-before-request-plugins | beforeRequest of the plugins brought by the consumer |
apikey-flow-pre-route-plugins | their pre-route phase |
apikey-flow-access-validator-plugins | their access validation phase |
The identity that actually reached the backend can be surfaced with the expression language:
| Expression | Value |
|---|---|
${apikey.clientId} | client id of the consumer |
${apikey.api.id} | id of the API the API key consumes |
${apikey.api.plan} | id of the plan |
${apikey.api.sub} | id of the subscription, when the API key comes from one |
Composing a chain per call costs more than serving a plain route: the slots of the consumer are expanded, indexed and sorted on every request. Plugin configuration caches stay correct — each composed chain gets its own cache entries — but a chain that is the same for everyone is better placed on the route or in a flow of the API.
Related
- APIs — plans, access modes and flows
- API keys — quotas, restrictions and plugin flow
- Proxy engine — request pipeline and execution reports
- Plugin system — plugin phases