Skip to main content

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 keyless or jwt plan can rate limit its callers, one by one, exactly like an apikey plan 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.
note

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 kindPlugin addedIdentity comes fromGenerated client id
keylessExpression apikey extractoran expression, ${req.ip} by defaultkeyless_<plan id>_<value>
apikeyApikey callsclient credentials (basic auth, headers, client id, bearer, signed JWT)the client id of the API key
oauth2-localApikey callsa token delivered by the client credentials endpoint of the planthe client id of the API key
jwtJwt apikey extractora claim of a token verified by a JWT verifierjwt_<plan id>_<claim value>
mtlsClient certificate apikey extractorthe client certificate of the TLS connectionmtls_<plan id>_<DN attribute, or a value derived from the subject and the serial number>
oauth2-remoteOIDC apikey extractora claim of a token verified against the OIDC settings of an auth. moduleoauth2_<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:

PropertyValue
clientIdthe prefixed identity described above
clientSecreta random token, never handed to the caller
throttlingStrategythe rateLimiting.strategy of the plan
apiRef{ "api": <api id>, "plan": <plan id>, "sub": "" }
authorizedEntitiesthe 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)
metadatathe 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_missingBehaviour
truethe generated API key is written to the datastore, appears in the UI, and can be edited like any other
falsethe 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.

warning

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:

  1. it requires a consumer — no API key and no authenticated user means 401 (errors.auth.unauthorized, with apikey_rejection_reason: no consumer identified on the call);
  2. it applies the restrictions of the API key;
  3. it triggers secret rotation when due;
  4. 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:

OrderPluginIndex
1Apikey calls (apikey, oauth2-local)2.00
2Jwt apikey extractor2.01
3Client certificate apikey extractor2.02
4OIDC apikey extractor2.03
5Expression apikey extractor (keyless)900
6Api consumer enforcer1000

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:

PropertyDefaultDescription
strictdepends on the pluginReject the call when no identity can be extracted. When false, the call goes on without any API key
client_id_prefixdepends on the pluginNamespace of the identities this plugin mints
create_if_missingtruePersist the generated API key
api_id / plan_idnoneFill the apiRef of the generated API key, which is what binds it to the plugins of a plan
throttling_strategynoneRate limiting strategy of the generated API key
apikey{}Template of the generated API key (same shape as the apikey access mode configuration)
PluginOwn settings
cp:otoroshi.next.plugins.NgExpressionApikeyExtractorexpression (default ${req.ip}), client_id_prefix default public_, strict default false
cp:otoroshi.next.plugins.NgJwtApikeyExtractorverifier, client_id_path (default client_id, JSON path supported), client_id_prefix default jwt_, strict default true
cp:otoroshi.next.plugins.NgClientCertApikeyExtractorregex_subject_dns, regex_issuer_dns, client_id_field, client_id_prefix default mtls_, strict default true
cp:otoroshi.next.plugins.NgOidcApikeyExtractorref (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 }
}
}
}
warning

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 plugins property — either an inline chain, or a flow_ref pointing 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 plugins property.

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​

SituationResult
the plan has plugins, the API key has nonethe chain of the plan
both have pluginsthe plan first, the plugins of the API key appended
the API key has plugins, no plan behind itthe chain of the API key
an empty chain, on either sidebehaves like no chain at all
overrides set on either sidethe 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:

  1. the route runs its own before request, pre-route and access validation phases, as usual — this is where the API key is identified;
  2. the plugins brought by the consumer run their before request, pre-route and access validation phases (they can reject the call at that point);
  3. 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:

TaskContent
handle-apikey-plugins-flowresolution and composition of the chain
apikey-flow-before-request-pluginsbeforeRequest of the plugins brought by the consumer
apikey-flow-pre-route-pluginstheir pre-route phase
apikey-flow-access-validator-pluginstheir access validation phase

The identity that actually reached the backend can be surfaced with the expression language:

ExpressionValue
${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
note

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.