OAuth2 Client Credentials Scopes

Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

The data pipeline OAuth client requested scope=admin because it was easier than listing six API permissions. Six months later, a compromised worker container used that token to delete production tables — the scope allowed it. Client credentials grants have no user in the loop; whatever scopes you attach to the token are the entire authorization story until expiry.

Scope design for M2M is least-privilege engineering: every service identity gets only the permissions it needs, enforced at issuance and on every API call.

Scope naming conventions

Use {resource}:{action} or {api}:{resource}:{action}:

inventory:read
inventory:write
orders:read
orders:cancel
billing:reports:export
llm:inference:invoke

Document scopes in your API catalog. Developers discover available scopes from docs, not by copying production env vars.

Anti-pattern Problem Fix
admin One breach owns everything Split by resource
read (global) Cannot distinguish APIs Prefix with service
User role names in M2M Roles change; tokens linger Resource actions only

Auth server configuration

Register each service with allowed scope whitelist:

{
  "client_id": "billing-reconciler",
  "grant_types": ["client_credentials"],
  "allowed_scopes": ["orders:read", "payments:read", "reports:export"]
}

Reject token requests asking for scopes outside the whitelist at the token endpoint — do not silently drop unknown scopes.

Resource server enforcement

from functools import wraps

def require_scope(*required):
    def decorator(fn):
        @wraps(fn)
        def wrapper(request, *args, **kwargs):
            token_scopes = set(request.auth.get("scope", "").split())
            if not all(s in token_scopes for s in required):
                return Response({"error": "insufficient_scope"}, status=403)
            return fn(request, *args, **kwargs)
        return wrapper
    return decorator

@require_scope("orders:read")
def list_orders(request):
    ...

For JWT access tokens, validate signature, exp, aud, and scope. For opaque tokens, introspect (RFC 7662) with caching bounded by token TTL.

Scope growth and reviews

Quarterly access reviews for machine clients:

  1. Export last 90 days of token grants by client_id and scope
  2. Compare to actual API access logs — unused scopes get removed from whitelist
  3. Split clients that accumulated scopes from multiple teams
  4. Rotate secrets after scope reduction events

Automate drift detection: alert if a client requests a scope it has not used in 30 days — often a copy-paste from an old tutorial.

LLM and agent workloads

Agent frameworks tempt teams to mint one powerful token for all tools. Instead:

Log client_id, granted scope, and API route on every call. When an agent misbehaves, you need to know which credential family to revoke.

Testing scopes in CI

Contract tests should fail if an endpoint is reachable without the expected scope:

it("rejects token without orders:write", async () => {
  const token = await mintTestToken({ scopes: ["orders:read"] });
  const res = await request(app)
    .post("/orders/123/cancel")
    .set("Authorization", `Bearer ${token}`);
  expect(res.status).toBe(403);
  expect(res.body.error).toBe("insufficient_scope");
});

Run the same tests against staging IdP configuration before promoting OAuth client changes.

Migration from API keys

Map each legacy API key to a client + scope set:

API_KEY_BILLING_READONLY  →  client: billing-reconciler, scopes: orders:read, payments:read
API_KEY_BILLING_ADMIN     →  split into write client + break-glass client with shorter TTL

Run dual-auth period: accept key or OAuth, log which auth method each caller uses, migrate stragglers, revoke keys.

Incident response

When a client secret leaks:

  1. Revoke the client or rotate secret immediately
  2. Review audit logs for scope usage during exposure window
  3. Narrow allowed scopes before re-enabling — incidents often reveal over-provisioned clients
  4. Issue new tokens with shorter TTL temporarily

Scopes are not a substitute for network policy — pair least-privilege tokens with service mesh authorization for defense in depth.

Auditing and compliance evidence

Export monthly reports: client_id, allowed scopes, last secret rotation, and APIs accessed. Assessors and internal security reviews want proof that machine credentials stay minimal over time — not a snapshot from launch week. Tie scope changes to tickets the same way you tie firewall rule changes.

When integrating LLM vendors via M2M, verify their requested scopes against data classification. A scope like documents:read:all on a summarization worker may violate residency rules if the worker runs outside approved regions.

Federation across environments

Use distinct OAuth clients per environment (billing-reconciler-staging vs billing-reconciler-prod) so staging tokens never work in production APIs even if misconfigured base URLs slip through CI. Scope whitelists should differ too — production write scopes must not exist on staging clients used by developer laptops.

Document emergency break-glass clients with 24-hour TTL tokens and pager approval — never widen the primary client's scopes temporarily during incidents; that temporary state becomes permanent without reviews.

Scope documentation for API consumers

Publish a machine-readable scope catalog (scopes.json) linked from OpenAPI tags. Each scope entry should list endpoints, example client_ids allowed to request it, and data classification. Developer portals that hide scope meaning behind tribal knowledge recreate the admin antipattern within quarters.

For internal LLM tool servers, map each tool to required scopes in the MCP manifest so policy reviewers can reject tools that demand excessive M2M permissions before deployment.

Worked example: billing reconciler

Imagine a nightly job reconciling Stripe charges against internal orders:

  1. Register billing-reconciler client with scopes orders:read, payments:read, reports:export only.
  2. Token endpoint rejects orders:cancel even if a developer adds it to the request body.
  3. Orders API returns 403 if someone deploys a bug calling DELETE /orders/{id} — the token cannot authorize destruction.
  4. Quarterly review shows reports:export unused; remove from whitelist until finance re-requests.

This pattern prevents a read-only batch job from becoming an accidental admin key because one engineer copied scopes from a wiki page.

Resources

Production notes for LLM stacks

When oauth2-client-credentials-scopes sits on an inference or RAG path, treat user prompts and retrieved chunks as untrusted input. Log correlation IDs and policy decisions—not raw prompts—in production telemetry. Gate risky operations behind explicit authorization at the gateway, not inside ad-hoc tool handlers.

Roll out changes with shadow mode first: record what would have happened under the new rule without blocking traffic. Compare deny rates, latency impact, and false positives for at least one business week before enforcing. Pair enforcement with a runbook entry: symptom, dashboard, rollback (feature flag or config), and owner.

Load-test with production-shaped concurrency. LLM workloads burst differently from CRUD APIs—tail latency and token throttling dominate. If oauth2 client credentials scopes protects an invariant (security, billing, data residency), prove the invariant with an automated test that fails CI when someone removes the check.

What teams get wrong

Teams copy a reference architecture without matching their compliance tier, then discover in audit that logs, backups, or support exports reintroduced the data they thought they had eliminated. Another pattern: shipping the demo integration without idempotency, then fighting duplicate side effects when clients retry on model timeouts.

Document the tradeoff you chose—strictness vs recall, cost vs quality, sync vs async—and the metric that tells you if the choice still holds six months later.

Authorization server configuration

Enable refresh token rotation in your IdP (Auth0, Okta, Keycloak, Cognito) and verify the behavior in a staging tenant before mobile clients ship. Rotation should issue a new refresh token on every refresh response and invalidate the previous token in the same family. Store only hashed refresh token identifiers server-side so database leaks do not grant session persistence.

Document the client behavior matrix: public clients must use PKCE; confidential backends may use client authentication on the token endpoint; never mix refresh token policies across platforms using the same OAuth client ID if redirect and storage models differ.

Detecting token reuse

When a refresh token is presented twice, treat it as compromise: revoke the entire token family, force re-authentication for that user session, and emit a security event with device fingerprint and IP. Rate-limit refresh endpoints separately from login to prevent brute force against leaked tokens.

async function rotateRefresh(oldToken: string): Promise<TokenPair> {
  const row = await db.refreshTokens.findByHash(hash(oldToken));
  if (!row || row.revoked) {
    await revokeFamily(row?.familyId);
    throw new ReuseDetectedError();
  }
  await db.refreshTokens.revoke(row.id);
  return issueNewPair(row.familyId, row.userId);
}

Mobile and SPA considerations

SPAs should not store refresh tokens in localStorage. Prefer HttpOnly cookies with SameSite constraints for web, and secure enclave / Keychain storage for native. LLM features that call backends on behalf of users should use short-lived access tokens minted server-side—not long-lived refresh tokens embedded in client-side agent runtimes.

Frequently asked questions

Should each microservice share one OAuth client with broad scopes?

No. One client per service identity with scopes limited to APIs that service calls. Shared super-clients make compromise catastrophic and make audit logs meaningless — you cannot tell which service exfiltrated data.

How granular should M2M scopes be?

Resource-oriented and action-oriented: inventory:read, orders:write, reports:export. Avoid role names like admin or superuser in machine tokens — roles belong to humans with MFA, not long-lived service credentials.

Where are scopes enforced — auth server or API?

Both. The auth server restricts which scopes a client may request; each resource server must validate the token scope claim on every request. Trusting the gateway alone fails when internal services are reachable laterally.

Can LLM agent runtimes use client credentials scopes?

Yes, but isolate agents in their own OAuth clients with minimal scopes per tool domain. Never reuse the same client credentials for batch ETL and interactive agent tools — compromise paths and blast radius differ.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →