n4nAI

API key permissions: read-only vs full-access scopes

A practical guide to designing api key permissions read-only full-access scopes for LLM gateways and APIs, with code and tradeoffs.

n4n Team4 min read919 words

Audio narration

Coming soon — every post will get a voice note here.

Most teams hand out full-access API keys by default because it is the path of least resistance. That shortcut turns a single leaked secret into a total compromise of billing, data, and downstream services. Designing api key permissions read-only full-access scopes from the start limits blast radius and forces deliberate integration patterns that survive contact with production.

1. Inventory every endpoint and its side effects

Before you can assign scopes, you need a complete map of what your API surface actually does. A read-only scope is not synonymous with HTTP GET. In LLM gateways, a POST /chat/completions call is a billable event that consumes quota, may write to provider-side caches, and can trigger fallback routes. Treat it as a restricted capability, not a safe read.

List each route with its side effects and cost profile:

{
  "routes": [
    {"path": "/v1/models", "method": "GET", "effect": "none", "cost": "none"},
    {"path": "/v1/chat/completions", "method": "POST", "effect": "bills_tokens", "cost": "per_token"},
    {"path": "/v1/embeddings", "method": "POST", "effect": "bills_tokens", "cost": "per_token"},
    {"path": "/v1/keys", "method": "POST", "effect": "creates_credential", "cost": "none"},
    {"path": "/v1/keys/{id}", "method": "DELETE", "effect": "revokes_credential", "cost": "none"},
    {"path": "/v1/usage", "method": "GET", "effect": "reads_billing", "cost": "none"}
  ]
}

If you cannot articulate the side effect, the route should be blocked until you can. Many teams discover orphaned admin endpoints during this exercise. A route that mutates gateway configuration but lacks an owner is a prime candidate for a strict config:write scope that almost no service key receives.

2. Define scopes as orthogonal capabilities

Scopes should be narrow and composable. A key with models:read can list available models. A key with completions:create can invoke inference. Administrative actions get their own keys:write and usage:read scopes. The anti-pattern is a single full-access grant that silently includes future endpoints you have not reviewed.

When designing api key permissions read-only full-access scopes, resist the urge to map them one-to-one with HTTP verbs. Verb-based thinking leads to read and write buckets that are too coarse. An embeddings call is a write to your budget but not a write to your account.

Concrete scope taxonomy

{
  "scopes": [
    "models:read",
    "completions:create",
    "embeddings:create",
    "keys:write",
    "keys:read",
    "usage:read",
    "config:write"
  ]
}

Issue a key by explicitly listing allowed scopes:

curl -X POST https://api.example.com/v1/keys \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -d '{"name":"batch-worker","scopes":["models:read","completions:create"],"expires_at":"2025-12-31T00:00:00Z"}'

The response returns a restricted key. That key cannot rotate other keys, read account-wide usage, or alter gateway config because those scopes are absent.

3. Provision keys per integration, not per developer

A common mistake is generating one full-access key and pasting it into three microservices, a notebook, and a CI pipeline. Instead, mint a dedicated key for each consumer. When the CI key leaks, you revoke one scope-limited credential instead of rotating everything and breaking prod.

In Python, a bootstrap script might look like:

import os, requests

def create_service_key(service_name: str, scopes: list[str], ttl: str | None = None) -> str:
    payload = {"name": service_name, "scopes": scopes}
    if ttl:
        payload["expires_at"] = ttl
    resp = requests.post(
        "https://api.example.com/v1/keys",
        headers={"Authorization": f"Bearer {os.environ['ADMIN_KEY']}"},
        json=payload,
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["key"]

# CI runner only needs to trigger completions
ci_key = create_service_key("ci-runner", ["completions:create"], "2025-09-01T00:00:00Z")

Store the returned key in your secret manager immediately. The admin endpoint will not show it again. A read-only integration that only pulls model lists gets models:read and nothing else.

4. Enforce scopes at the edge before the provider sees the call

Authorization logic belongs in a gateway or middleware, not scattered across handlers. The gateway validates the presented key, parses its scopes, and rejects requests lacking the required grant before forwarding to the upstream model provider. This keeps provider SDKs dumb and your audit surface small.

A practical FastAPI dependency:

from fastapi import Depends, HTTPException, Request

async def require_scope(request: Request, needed: str):
    key_meta = request.state.key_meta  # populated by auth middleware
    if needed not in key_meta["scopes"]:
        raise HTTPException(status_code=403, detail=f"missing scope {needed}")
    return True

@app.post("/v1/chat/completions")
async def chat(_=Depends(lambda r: require_scope(r, "completions:create"))):
    # forward to provider with stripped credentials
    ...

This pattern works whether you run your own proxy or sit behind an OpenRouter-class service. A gateway like n4n.ai can centralize enforcement while honoring client routing directives and forwarding provider cache-control hints, so a read-only key stays constrained to declared model families without custom code at each service.

Pitfall: wildcard scopes

Never implement scope:*. It seems convenient for internal tools but becomes the default for everything when someone gets lazy. Explicitly deny wildcard in your key admin service and fail closed if an unknown scope appears in a request.

5. Handle fallback and degraded providers without widening scopes

Automatic fallback when a provider is rate-limited sounds great, but a read-only key should not gain the ability to reroute to an unapproved provider. Define fallback behavior as a property of the gateway configuration, not the key. The key says what operations are permitted; the gateway decides where they go.

Tradeoff: strict scopes may block a legitimate retry path during an outage. Solve this with a separate fallback:override scope granted only to supervised jobs, not to ad-hoc scripts. That keeps the default posture safe while allowing controlled escalation.

6. Rotate, expire, and observe

Short-lived keys shrink the window of abuse. Issue keys with expires_at timestamps where possible:

{
  "name": "temp-eval",
  "scopes": ["completions:create"],
  "expires_at": "2025-12-31T23:59:59Z"
}

Audit logs must record which scope was used for each call, not just the key ID. Per-token usage metering (as provided by some gateways) lets you attribute cost to a scope without extra plumbing. If a completions:create key suddenly shows embedding calls, your scope check failed—alert on it.

Common pitfall: logging keys

Never log the full key in access logs. Log the last four characters and the scope set. A leaked log line should not be a credential. Similarly, do not return scopes in client-facing error messages beyond the missing scope name.

Tradeoffs of read-only vs full-access

Read-only scopes reduce risk but increase onboarding friction. A new engineer cannot debug billing without usage:read, so you need a self-serve path to request scoped keys with manager approval. Full-access keys are faster to integrate but turn a typo in a config file into a deleted project or a $10k surprise.

The middle ground is time-boxed full-access for local dev only, revoked after the session. Production gets least privilege always. The api key permissions read-only full-access decision is not binary per key; it is a continuum of explicit grants.

Final checklist

  1. Map routes to side effects, not verbs.
  2. Define explicit scopes; ban wildcards.
  3. One key per integration with minimal scopes and TTL.
  4. Enforce at gateway with clear 403s and fail closed.
  5. Expire keys; audit scope usage per token.
  6. Separate fallback config from key permissions.

Following this ordered path makes api key permissions read-only full-access scopes a default posture rather than a retrofitted cleanup that interrupts shipping.

Tagsapi-keyspermissionsscopesaccess-control

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All api key authentication best practices posts →