HTTP reference

Markdown

Public API abuse controls & key scopes

Operator reference for Public CMS API (/api/v1) and GraphQL (/api/graphql) abuse controls and optional API key scopes. Defaults stay open until you opt in — copy values into production .env deliberately.

Config table

Verified against externa-core develop (config/api.php, config/lighthouse.php, .env.example).

ControlEnv / settingDefaultWhere it applies
Route throttleLaravel throttle:api60/minEvery /api/v1 and /api/graphql route — keyed by authenticated user id or IP (AppServiceProvider)
Per-API-key rate limitKey rate_limit_per_minute or API_KEY_RATE_LIMIT60/minBearer keys only (ResolveApiAccess) — message API key rate limit exceeded. → 429
Cost budget (60 s window)API_COST_LIMIT0 (off)After ResolveApiAccess (ApiRequestCostMiddleware); 0 disables
Cost weightsAPI_COST_BASE / INCLUDE / MUTATION1 / 2 / 5REST: base + include tokens × include + mutation methods; GraphQL charges base only here
Relational filter costAPI_COST_FILTER_RELATION2Added per dotted filter[relation.field] key, REST and GraphQL (1.4.0)
Max batch sizeAPI_MAX_BATCH_SIZE100Entries per items/batch request (min 1); more → 422 (1.4.0)
Max request bodyAPI_MAX_BODY_BYTES0 (off)When set, Content-Length over the cap → 413 Request body too large.
Max per_pageAPI_MAX_PER_PAGE100REST item/file lists and GraphQL items
GraphQL max depthGRAPHQL_MAX_DEPTHempty = offLighthouse security.max_query_depth (QueryDepth::DISABLED when unset)
GraphQL max complexityGRAPHQL_MAX_COMPLEXITYempty = offLighthouse security.max_query_complexity
GraphQL introspectionLIGHTHOUSE_SECURITY_DISABLE_INTROSPECTIONfalseUnchanged by #113 — set true in production if you want introspection off
Key collection allowlistAPI key collection_slugsnullNull/empty = unrestricted (legacy); non-empty ∩ with role ACL (also gates ?include= hydration)
Key read-onlyAPI key read_onlyfalseWhen true, only collection read (writes → 403); also blocks Files API writes
Key files scopeAPI key files_scopeallnone | read | all — Files API access independent of collection_slugs (core#322)
Key ownerAPI key owner_idbackfilledOwner's permissions are an extra ceiling; key revoked when the owner is deactivated or deleted (1.4.0)
Key named scopesAPI key scopesnullmcp:read / mcp:write for the MCP server only (1.4.0)
# Always-on floors
API_KEY_RATE_LIMIT=60

# Opt-in abuse controls (0 / empty = off)
# API_COST_LIMIT=1000
# API_COST_BASE=1
# API_COST_INCLUDE=2
# API_COST_MUTATION=5
# API_COST_FILTER_RELATION=2
# API_MAX_BATCH_SIZE=100
# API_MAX_BODY_BYTES=1048576
# API_MAX_PER_PAGE=100
# GRAPHQL_MAX_DEPTH=10
# GRAPHQL_MAX_COMPLEXITY=200
# LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION=true

Admin UI: Settings → Access → API Keys — optional per-key rate limit, IP allowlist, expiry, collection_slugs, read_only, and files_scope (requires can-manage-api-keys).

Stacking order

For authenticated Public CMS routes (routes/api.php):

  1. throttle:api — coarse 60/min by user or IP
  2. EnforcePublicApiOrigin — when project origin allowlist is set
  3. ResolveApiAccess — public role or API key (+ per-key rate limit)
  4. ApiRequestCostMiddleware — body cap + optional cost budget

GraphQL adds the same cost middleware via config/lighthouse.php and applies depth/complexity in Lighthouse security when env caps are set.

REST ?include= depth remains 1 (nested items do not re-apply relation includes) — Expand with include.

API key scopes

Scopes only narrow the key’s role. Effective access = role ACL ∩ key scopes, and from externa-core 1.4.0 also ∩ the key owner’s permissions when the key has an owner (#215).

FieldBehaviour
collection_slugsnull / omitted → all collections the role can touch; list of slugs → allowlist (case-insensitive)
read_onlyfalse → role actions unchanged; true → only read (create/update/delete denied)
owner_idUser whose permissions cap the key (1.4.0). A key whose owner is deactivated or deleted is revoked and returns 401. The upgrade backfills owners: created_by when that user is still active, else the first active super-admin, then admin.
scopesNamed capability scopes (1.4.0): mcp:read, mcp:write. They only gate the MCP server; REST and GraphQL ignore them.
files_scopenone → deny Files API + bare ids on file relations; read → list/download only; all → read+write (still ∩ read_only ∩ role file ACL). New scoped keys default none; unscoped default all (or read when read_only). Migrated keys keep all.

Applies to REST and GraphQL through CollectionPermissionEnforcer / AuthorizesFileAccessTrait (single source of truth). Super-admin roles remain rejected for API keys. Empty slug list after normalization is stored as null (unrestricted). Response cache keys include all three scope fields.

Includes, cache, and Files API (core 1.3.0+)

From core PR #321 and core#322:

  • ?include= — relation hydration uses the same enforcer, so a key scoped to blog cannot pull full items from out-of-scope collections via includes (out-of-scope relations stay bare id / link without nested item).
  • Response cache — REST and GraphQL cache hashes include normalized key scopes (collection_slugs, read_only, files_scope), so a narrower key cannot reuse a wider key’s cached payload.
  • Files API — read_only blocks writes on /api/v1/files. collection_slugs still does not gate Files API by itself; use files_scope (none | read | all) for least privilege. Effective file access = role file ACL ∩ files_scope ∩ read_only.

Handling 429 (and 413)

Per-key rate limit

HTTP/1.1 429 Too Many Requests
Content-Type: text/plain; charset=UTF-8

API key rate limit exceeded.

Clients should back off (e.g. exponential retry with jitter) and lower concurrency. Raise the key’s rate_limit_per_minute or API_KEY_RATE_LIMIT only when traffic is legitimate.

Cost budget

When API_COST_LIMIT > 0 and the 60 s bucket is exhausted:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0

{"message":"API cost budget exceeded."}

Honour Retry-After (seconds). Successful responses under the budget also carry X-RateLimit-Limit / X-RateLimit-Remaining for the cost window.

Body too large

HTTP/1.1 413 Payload Too Large
Content-Type: application/json

{"message":"Request body too large."}

Only enforced when API_MAX_BODY_BYTES > 0 and Content-Length is present and over the cap.

Example client backoff

async function fetchPublicApi(
  url: string,
  init?: RequestInit,
  attempt = 0,
): Promise<Response> {
  const res = await fetch(url, init)
  if (res.status !== 429 || attempt >= 5) return res
  const retryAfter = Number(res.headers.get('Retry-After') ?? '1')
  const waitMs =
    Math.min(30_000, Math.max(1, retryAfter) * 1000) + Math.random() * 250
  await new Promise((r) => setTimeout(r, waitMs))
  return fetchPublicApi(url, init, attempt + 1)
}
  1. Keep throttle:api and per-key limits; set explicit rate_limit_per_minute on partner keys.
  2. Turn on API_COST_LIMIT once you have a baseline (start conservative; watch Pulse slow queries / Health).
  3. Set GRAPHQL_MAX_DEPTH / GRAPHQL_MAX_COMPLEXITY if GraphQL is exposed publicly.
  4. Prefer scoped keys (collection_slugs + read_only + files_scope) for frontends and partners instead of a wide role alone. Review files_scope on existing scoped keys after upgrading to 1.4.0 (migration keeps all).
  5. Pair with origin allowlist + IP allowlist — Origin allowlist.

Source map

PiecePath
Configconfig/api.php, config/lighthouse.php
Route throttleapp/Providers/AppServiceProvider.php (RateLimiter::for('api'))
Per-key limitapp/Http/Middleware/ResolveApiAccess.php
Cost + body capapp/Http/Middleware/ApiRequestCostMiddleware.php
Key scopes modelapp/Models/ApiKey.php (allowsCollectionSlug, allowsAction)
Admin create formresources/js/pages/admin/api-keys/index.tsx