Authorization Guide
This document explains how ZCP protects MCP-compatible and native endpoints, what OAuth-related routes it exposes, and how scope enforcement works in the runtime.
Authorization Modes
ZCP currently supports two practical authorization modes:
- static bearer token protection
- OAuth 2.1 style authorization code flow with PKCE, refresh tokens,
registration, and revocation
Both can protect MCP-facing transports and the native /zcp path.
Why Authorization Is A First-Class Concern
A ZCP or MCP server may expose high-impact operations:
- filesystem access
- tenant data
- administrative actions
- network-connected backends
- long-running workflows
Authorization must be attached to the runtime boundary, not bolted on as client-side convention.
Static Bearer Token Mode
For controlled environments, the simplest option is a fixed bearer token.
from zcp import BearerAuthConfig, ZCPServerConfig, create_asgi_app
application = create_asgi_app(
app,
config=ZCPServerConfig(
auth=BearerAuthConfig(token="replace-me"),
),
)This mode is useful for:
- local development
- single-tenant internal services
- narrow service-to-service deployments
It is not a substitute for a full identity system in larger environments.
End-To-End Pattern: Private Internal Service
For an internal service where every caller is already trusted at the network boundary, bearer auth is often enough.
Typical shape:
- protect
/zcp,/mcp, and/wswith one bearer token - keep
/healthz,/readyz, and/metadatapublic only if needed - use
required_scopesanyway for high-impact tools - rotate the bearer token with your normal secret-management process
This mode is the best fit for:
- private agent platforms
- internal automation services
- staging environments that need parity with production routes
OAuth Support
When OAuth is enabled, ZCP can expose:
- authorization server metadata
- protected resource metadata for the MCP resource surface
- authorization endpoint
- token endpoint
- dynamic client registration endpoint
- token revocation endpoint
Relevant configuration is provided by OAuthConfig on ZCPServerConfig.
End-To-End OAuth Server Example
This is the smallest realistic server setup if you need durable OAuth state and scope-protected capabilities.
from zcp import (
AuthProfile,
FastZCP,
OAuthConfig,
SQLiteOAuthProvider,
ZCPServerConfig,
create_asgi_app,
)
app = FastZCP(
"Protected Weather",
auth_profile=AuthProfile(
issuer="https://zcp.example.com",
authorization_url="https://zcp.example.com/authorize",
token_url="https://zcp.example.com/token",
scopes=["weather.read", "weather.admin"],
),
)
@app.tool(
name="weather.lookup",
description="Read weather data.",
input_schema={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
output_mode="scalar",
inline_ok=True,
required_scopes=("weather.read",),
)
def weather_lookup(city: str):
return {"city": city, "temperature": 24}
@app.tool(
name="weather.rotate_cache",
description="Administrative cache rotation.",
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
output_mode="scalar",
inline_ok=True,
required_scopes=("weather.admin",),
)
def rotate_cache():
return {"status": "rotated"}
application = create_asgi_app(
app,
config=ZCPServerConfig(
oauth=OAuthConfig(enabled=True, issuer="https://zcp.example.com"),
oauth_provider=SQLiteOAuthProvider("oauth.sqlite3"),
),
)This setup gives you:
- discovery metadata
- authorization code plus PKCE
- refresh token support
- dynamic client registration if enabled
- revocation support
- durable token and code state in SQLite
Authorization Code Flow With PKCE
The current OAuth flow supports:
- metadata discovery
- authorization code issuance
- PKCE verifier/challenge validation
- access token issuance
- refresh token issuance
- refresh token exchange
- token revocation
This is the correct baseline for browser-adjacent clients and other clients that need user-authorized access instead of static shared secrets.
Dynamic Client Registration
Dynamic registration is available when enabled in config. This lets clients obtain:
client_idclient_secret- registered redirect URIs
This is useful for:
- development tooling
- test harnesses
- embedded clients that do not want hard-coded provisioning
Provider Model
OAuth state is not limited to one process memory anymore. The runtime supports:
InMemoryOAuthProviderSQLiteOAuthProvider- a provider abstraction for other backing stores
In-Memory Provider
Use this for:
- tests
- ephemeral local development
- single-process experiments
SQLite Provider
Use this for:
- persisted local installs
- single-node deployments
- environments that want durable auth state without adding a separate service
Example:
from zcp import OAuthConfig, SQLiteOAuthProvider, ZCPServerConfig, create_asgi_app
application = create_asgi_app(
app,
config=ZCPServerConfig(
oauth=OAuthConfig(enabled=True, issuer="https://zcp.example.com"),
oauth_provider=SQLiteOAuthProvider("zcp-oauth.db"),
),
)Request Sequence For Authorization Code Plus PKCE
The currently implemented sequence is:
GET /.well-known/oauth-authorization-serverGET /authorize?...response_type=code&client_id=...&redirect_uri=...&code_challenge=...- receive a redirect containing
code=... POST /tokenwithgrant_type=authorization_code,code,client_id,
redirect_uri, and code_verifier
- receive
access_tokenandrefresh_token POST /tokenagain withgrant_type=refresh_tokenwhen renewal is needed- call
/mcp,/ws, or/zcpwith the resulting bearer token
That sequence is already exercised in the local test suite. The remaining gap is broader client-interop coverage, not the basic server route flow itself.
End-To-End Pattern: Hosted OAuth Deployment
The usual hosted deployment flow looks like this:
- publish OAuth metadata from the ASGI service
- register clients dynamically or provision them ahead of time
- send browser or user-facing clients through auth code + PKCE
- issue refresh tokens for long-lived sessions
- persist auth state with
SQLiteOAuthProvideror another provider - enforce scopes on tools, resources, and prompts at runtime
This keeps authorization behavior attached to the server boundary instead of scattering it across clients.
Scope Enforcement
Scopes are enforced by the runtime on:
- tools
- resources
- prompts
That means policy lives close to execution rather than depending on client honesty.
Example server-side scope declaration:
@app.tool(
name="admin.rotate_key",
description="Rotate a tenant signing key.",
input_schema={"type": "object", "properties": {"tenant": {"type": "string"}}, "required": ["tenant"]},
output_mode="scalar",
inline_ok=True,
required_scopes=("admin.keys",),
)
def rotate_key(tenant: str):
return {"tenant": tenant, "status": "rotated"}A practical scope split usually looks like this:
- read-oriented tools and resources:
*.read - high-impact mutations:
*.adminor*.write - tenant-specific operations: scopes that match the tenant or role boundary
End-To-End Pattern: Tenant-Scoped Admin Surface
A production pattern that works well is:
- issue normal read scopes broadly
- keep admin scopes narrow and auditable
- attach admin scopes only to a small number of tools or prompts
- require tasks for risky long-running operations so cancellation stays visible
That gives you a much cleaner security model than embedding tenant and policy checks ad hoc inside every tool handler.
Public And Protected Routes
Typical public routes include:
//docs/healthz/readyz/metadata- OAuth discovery and token routes when enabled
Protected routes usually include:
/zcp/mcp/ws
Do not assume public or protected behavior from route names alone. It is the server config that defines the boundary.
Deployment Guidance
Use static bearer auth when:
- the environment is controlled
- all clients are trusted
- the operational simplicity is worth the tradeoff
Use OAuth when:
- clients need delegated user authorization
- token lifecycle matters
- refresh behavior matters
- the integration boundary is broader than a single trusted process
Recommended Deployment Patterns
Internal Single-Tenant Service
- static bearer token is usually enough
- keep OAuth disabled unless delegated user access is a real requirement
Hosted MCP Service
- enable OAuth
- persist auth state in SQLite or your own provider
- scope every mutating capability explicitly
Desktop Or Tooling Integration
- start with bearer auth or a local OAuth provider
- only add dynamic registration if the client lifecycle actually needs it
Rollout Pattern: Bearer First, OAuth Second
If you are still proving the product shape, the pragmatic sequence is:
- ship bearer auth for internal clients first
- validate scopes and route protection
- add OAuth metadata and auth code + PKCE when the integration boundary grows
- move auth state into a persistent provider before broader external adoption
That rollout keeps auth complexity aligned with real adoption instead of adding full delegated auth before you have clients that need it.
Current Production Boundaries
The OAuth layer is much more production-like than a demo-only in-memory flow. It now includes PKCE, refresh tokens, registration, revocation, and durable SQLite-backed state.
The remaining work is not basic correctness. It is mostly about deeper interoperability coverage and broader provider choices. See MCP Gap And TODO for the precise remaining items.