# Public API abuse controls & key scopes

Source: https://docs.qiick.io/externa/api-abuse-controls

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.

> **Related**
> [Public CMS API](https://docs.qiick.io/externa/public-cms-api) · [GraphQL](https://docs.qiick.io/externa/graphql) · [Environment variables](https://docs.qiick.io/externa/environment-variables) · [Threat model & hosting](https://docs.qiick.io/externa/threat-model) · [Security checklist](https://docs.qiick.io/externa/security-checklist) · core [#113](https://github.com/qiick-io/externa-core/issues/113) · [#116](https://github.com/qiick-io/externa-core/issues/116)

## Config table

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

| Control                   | Env / setting                                       | Default         | Where it applies                                                                                                                                 |
| ------------------------- | --------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Route throttle            | Laravel `throttle:api`                              | **60**/min      | Every `/api/v1` and `/api/graphql` route — keyed by authenticated user id or IP (`AppServiceProvider`)                                           |
| Per-API-key rate limit    | Key `rate_limit_per_minute` or `API_KEY_RATE_LIMIT` | **60**/min      | Bearer keys only (`ResolveApiAccess`) — message `API key rate limit exceeded.` → **429**                                                         |
| Cost budget (60 s window) | `API_COST_LIMIT`                                    | **0** (off)     | After `ResolveApiAccess` (`ApiRequestCostMiddleware`); **0** disables                                                                            |
| Cost weights              | `API_COST_BASE` / `INCLUDE` / `MUTATION`            | `1` / `2` / `5` | REST: base + include tokens × include + mutation methods; GraphQL charges **base only** here                                                     |
| Relational filter cost    | `API_COST_FILTER_RELATION`                          | `2`             | Added per dotted `filter[relation.field]` key, REST and GraphQL (1.4.0)                                                                          |
| Max batch size            | `API_MAX_BATCH_SIZE`                                | **100**         | Entries per `items/batch` request (min 1); more → **422** (1.4.0)                                                                                |
| Max request body          | `API_MAX_BODY_BYTES`                                | **0** (off)     | When set, `Content-Length` over the cap → **413** `Request body too large.`                                                                      |
| Max `per_page`            | `API_MAX_PER_PAGE`                                  | **100**         | REST item/file lists and GraphQL `items`                                                                                                         |
| GraphQL max depth         | `GRAPHQL_MAX_DEPTH`                                 | empty = off     | Lighthouse `security.max_query_depth` (`QueryDepth::DISABLED` when unset)                                                                        |
| GraphQL max complexity    | `GRAPHQL_MAX_COMPLEXITY`                            | empty = off     | Lighthouse `security.max_query_complexity`                                                                                                       |
| GraphQL introspection     | `LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION`         | `false`         | Unchanged by #113 — set `true` in production if you want introspection off                                                                       |
| Key collection allowlist  | API key `collection_slugs`                          | `null`          | Null/empty = unrestricted (legacy); non-empty ∩ with role ACL (also gates `?include=` hydration)                                                 |
| Key read-only             | API key `read_only`                                 | `false`         | When true, only collection `read` (writes → **403**); also blocks Files API writes                                                               |
| Key files scope           | API key `files_scope`                               | `all`           | `none` \| `read` \| `all` — Files API access independent of `collection_slugs` ([core#322](https://github.com/qiick-io/externa-core/issues/322)) |
| Key owner                 | API key `owner_id`                                  | backfilled      | Owner's permissions are an extra ceiling; key revoked when the owner is deactivated or deleted (1.4.0)                                           |
| Key named scopes          | API key `scopes`                                    | `null`          | `mcp:read` / `mcp:write` for the [MCP server](https://docs.qiick.io/externa/mcp-server) only (1.4.0)                                                                  |

```env
# 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`](https://docs.qiick.io/externa/public-cms-api#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](https://github.com/qiick-io/externa-core/issues/215)).

| Field              | Behaviour                                                                                                                                                                                                                                                   |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collection_slugs` | `null` / omitted → all collections the role can touch; list of slugs → allowlist (case-insensitive)                                                                                                                                                         |
| `read_only`        | `false` → role actions unchanged; `true` → only `read` (create/update/delete denied)                                                                                                                                                                        |
| `owner_id`         | User 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.           |
| `scopes`           | Named capability scopes (1.4.0): `mcp:read`, `mcp:write`. They only gate the [MCP server](https://docs.qiick.io/externa/mcp-server); REST and GraphQL ignore them.                                                                                                               |
| `files_scope`      | `none` → 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](https://github.com/qiick-io/externa-core/pull/321) and [core#322](https://github.com/qiick-io/externa-core/issues/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
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` &gt; 0 and the 60 s bucket is exhausted:

```http
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
HTTP/1.1 413 Payload Too Large
Content-Type: application/json

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

Only enforced when `API_MAX_BODY_BYTES` &gt; 0 and `Content-Length` is present and over the cap.

### Example client backoff

```ts
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)
}
```

## Recommended production posture

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](https://docs.qiick.io/externa/public-cms-api-types#origin-allowlist).

## Source map

| Piece             | Path                                                               |
| ----------------- | ------------------------------------------------------------------ |
| Config            | `config/api.php`, `config/lighthouse.php`                          |
| Route throttle    | `app/Providers/AppServiceProvider.php` (`RateLimiter::for('api')`) |
| Per-key limit     | `app/Http/Middleware/ResolveApiAccess.php`                         |
| Cost + body cap   | `app/Http/Middleware/ApiRequestCostMiddleware.php`                 |
| Key scopes model  | `app/Models/ApiKey.php` (`allowsCollectionSlug`, `allowsAction`)   |
| Admin create form | `resources/js/pages/admin/api-keys/index.tsx`                      |
