n4nAI

OAuth2 scopes for fine-grained LLM API access control

Learn how to implement OAuth2 scopes for fine-grained LLM API access control with step-by-step token issuance, gateway enforcement, and verification.

n4n Team3 min read715 words

Audio narration

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

Implementing oauth2 scopes llm api access control lets you restrict which models, endpoints, and operations a client can invoke without provisioning a separate API key per use case. This guide walks through a concrete pattern: define a scope taxonomy, mint tokens with those scopes, enforce them at your gateway, and verify the policy end to end.

Step 1: Define a scope taxonomy that mirrors LLM operations

Start by listing the operations your LLM API exposes. Typical surfaces are chat completions, text completions, embeddings, moderation, fine-tune management, and usage metering. Map them to hierarchical scopes so you can grant least privilege.

{
  "scopes": {
    "llm:chat": "Invoke chat completion endpoints on standard models",
    "llm:chat:gpt-4": "Chat with GPT-4 class models only",
    "llm:embed": "Create text embeddings",
    "llm:moderate": "Run moderation checks",
    "llm:admin:keys": "Rotate and read API keys",
    "llm:cache": "Hint provider to use prompt caching"
  }
}

Keep the namespace prefixed with llm: to avoid collisions with other OAuth resources behind the same authorization server. Fine-grained scopes like llm:chat:gpt-4 let you grant an analytics job access to cheap models without exposing flagship weights. Avoid wildcard scopes such as llm:* in production; they defeat the purpose of oauth2 scopes llm api access control.

Document the taxonomy in your developer portal and treat it as an API contract. When a new model family ships, add a new scope rather than silently expanding an existing one.

Step 2: Issue scoped tokens via client credentials

Stand up a token endpoint that authenticates the client and returns a JWT with a scope claim. Below is a minimal Flask example using PyJWT. In production, sign with an RSA key and publish a JWKS endpoint so gateways can rotate keys without a deploy.

import jwt
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
PRIVATE_KEY = open("rsa_private.pem").read()
CLIENT_SECRETS = {"client-123": "supersecret"}

@app.route("/oauth/token", methods=["POST"])
def token():
    cid = request.form.get("client_id")
    csec = request.form.get("client_secret")
    if CLIENT_SECRETS.get(cid) != csec:
        return jsonify(error="invalid_client"), 401
    requested = request.form.get("scope", "").split()
    allowed = {"llm:chat", "llm:chat:gpt-4", "llm:embed", "llm:cache"}
    granted = [s for s in requested if s in allowed]
    if not granted:
        return jsonify(error="invalid_scope"), 400
    now = int(time.time())
    payload = {
        "iss": "https://auth.example.com",
        "sub": cid,
        "aud": "llm-api",
        "scope": " ".join(granted),
        "exp": now + 900,
        "iat": now,
    }
    tok = jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
    return jsonify(access_token=tok, token_type="Bearer", expires_in=900)

The client requests scope=llm:chat%20llm:chat:gpt-4 and receives a bearer token. The space-delimited string in the scope claim is RFC 6749 compliant. Set exp short (15 minutes) for server-to-server calls; long-lived user tokens should use refresh tokens.

If you already run an OAuth2 provider (Keycloak, Auth0, Cognito), configure these scopes as client scopes and use the standard token endpoint instead of rolling your own.

Step 3: Enforce scopes at the gateway

Your LLM gateway should reject requests whose token lacks the required scope before spending a single inference token. A Flask middleware example:

from functools import wraps
import jwt
from flask import jsonify, request

PUBLIC_KEY = open("rsa_public.pem").read()

def require_scope(needed):
    def decorator(f):
        @wraps(f)
        def inner(*args, **kwargs):
            auth = request.headers.get("Authorization", "")
            if not auth.startswith("Bearer "):
                return jsonify(error="unauthorized"), 401
            tok = auth.split()[1]
            try:
                claims = jwt.decode(tok, PUBLIC_KEY, algorithms=["RS256"], audience="llm-api")
            except jwt.ExpiredSignatureError:
                return jsonify(error="token_expired"), 401
            except jwt.InvalidTokenError:
                return jsonify(error="invalid_token"), 401
            token_scopes = claims.get("scope", "").split()
            if needed not in token_scopes:
                return jsonify(error="insufficient_scope"), 403
            return f(claims, *args, **kwargs)
        return inner
    return decorator

Apply it to routes:

@app.route("/v1/chat", methods=["POST"])
@require_scope("llm:chat")
def chat(claims):
    # forward to provider
    ...

This pattern gives you oauth2 scopes llm api access control at the edge with sub-millisecond overhead. For high-throughput fleets, implement the same check in Envoy ext_authz or a Kong plugin so Python never becomes the bottleneck.

Step 4: Map scopes to model-level restrictions

A coarse llm:chat scope is not enough if you need to block specific model families. Parse the requested model from the body and cross-check against scopes that carry model suffixes.

MODEL_SCOPE = {
    "gpt-4": "llm:chat:gpt-4",
    "gpt-4o": "llm:chat:gpt-4",
    "gpt-3.5-turbo": "llm:chat",
}

@app.route("/v1/chat", methods=["POST"])
@require_scope("llm:chat")
def chat(claims):
    body = request.get_json()
    model = body.get("model", "gpt-3.5-turbo")
    needed = MODEL_SCOPE.get(model)
    if needed and needed not in claims["scope"].split():
        return jsonify(error="model_forbidden"), 403
    # proceed to provider

If a token has only llm:chat but the client sends model: gpt-4, the gateway returns 403. This is the core of fine-grained oauth2 scopes llm api access control. You can extend the map to regex matching for model series (e.g., ^claude-3-llm:chat:claude-3).

Step 5: Forward only permitted traffic to the provider

When you proxy to an upstream inference service, strip any admin scopes from the downstream call and pass only the model routing hint. If you front models through a gateway such as n4n.ai, it honors client routing directives and forwards provider cache-control hints, but the scope decision must already be made on your side. Never trust the upstream to enforce your tenant isolation.

import requests

def forward_to_provider(body, claims):
    headers = {"Authorization": "Bearer <provider-key>"}
    if "llm:cache" in claims["scope"].split():
        headers["Cache-Control"] = "max-age=3600"
    r = requests.post("https://api.provider.com/v1/chat", json=body, headers=headers)
    return r.json()

Note that provider API keys remain centralised; the OAuth2 layer is about who can call what, not about hiding the upstream credential.

Step 6: Verify enforcement with a red team curl

Issue a token with limited scope and attempt a forbidden call.

# token with only llm:embed
TOKEN=$(curl -s -X POST https://auth.example.com/oauth/token \
  -d "client_id=client-123" -d "client_secret=supersecret" \
  -d "scope=llm:embed" | jq -r .access_token)

curl -i https://gateway.example.com/v1/chat \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"model":"gpt-4","messages":[]}'
# Expect HTTP/1.1 403 insufficient_scope

Then mint a token with llm:chat:gpt-4 and retry:

TOKEN2=$(curl -s -X POST https://auth.example.com/oauth/token \
  -d "client_id=client-123" -d "client_secret=supersecret" \
  -d "scope=llm:chat%20llm:chat:gpt-4" | jq -r .access_token)

curl -i https://gateway.example.com/v1/chat \
  -H "Authorization: Bearer $TOKEN2" \
  -d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}'
# Expect HTTP/1.1 200 and a completion

Also test expired and malformed tokens to confirm 401s. If all responses match expectations, your oauth2 scopes llm api access control policy is live.

Step 7: Audit, rotate, and narrow scopes

Scopes accumulate cruft. Review granted scopes per client quarterly. Use introspection logs to find tokens that request llm:admin:keys but only ever call llm:chat. Revoke and reissue with narrower scopes. Store scope grants in your IdP so revocation propagates before the JWT exp via a short TTL or revocation list.

For server-to-server workloads, set exp to 15 minutes and use client credentials refresh. For user-delegated access, pair scopes with PKCE and treat llm:admin:* as high privilege. Add a CI test that asserts new routes declare a require_scope decorator; missing enforcement should fail the build.

That is the full loop: define, issue, enforce, map, forward, verify, audit. Implemented correctly, oauth2 scopes llm api access control removes the need for per-model API keys and gives you a single auditable policy surface.

Tagsoauth2scopesaccess-controlllm-api

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 oauth2 & bearer token auth for llm platforms posts →