Authentication¶
django-spec-mcp is a resource server: it validates bearer tokens
that someone else issued. The library does not implement a token issuer (that's
the job of an Authorization Server / IDP), and it does not verify a token for
you: how a token is verified depends on the authorization server a project
runs, so that backend is the project's to write. What ships is the seam: the
MCPAuthBackend protocol, the bearer_challenge helper that builds the 401
challenge, the exact audience comparison, the RFC 9728 metadata view clients use
to discover your authorization server, and AllowAnyBackend for development.
The pieces¶
| Surface | Protocol | Default |
|---|---|---|
MCPAuthBackend |
Authenticate request → TokenInfo, build WWW-Authenticate, supply PRM payload |
none: auth_backend= is required to mount, and urls / async_urls refuse without one |
The spec's permissions |
Kernel PermissionChecks guarding a tool or resource |
required: a spec with none is refused at registration |
scopes= |
OAuth scopes a binding's caller must carry | None (no scope gate) |
permissions= on a prompt or view |
Kernel PermissionChecks, each handed the binding as the spec |
None (no checks; [] is refused, as on a spec); a prompt with neither these nor scopes= is refused |
/.well-known/oauth-protected-resource |
RFC 9728 metadata | served from backend's protected_resource_metadata() |
The transport flow on every request:
- Validate
Origin,MCP-Protocol-Version,MCP-Session-Id(where required). backend.authenticate(request)→TokenInfo | None.None, the answer for a caller the backend cannot authenticate, → 401 with the challenge frombackend.www_authenticate_challenge(error=...):error="invalid_token"when the request sent anAuthorizationheader,error=Nonewhen it sent none (RFC 6750 3.1: a request with no credentials gets no error code). A backend that raises instead is not answered as MCP: aPermissionDeniedorHttp404renders as Django's own HTML 403 or 404, and anything else is a 500. From here onrequest.userandrequest.auser()are the token's principal.- The binding's scope gate and its spec's permissions are asked; a denial → 403, with any required scopes surfaced in the challenge.
- Handler dispatched.
AllowAnyBackend (dev only)¶
Authenticates every request as anonymous. The metadata payload is intentionally
minimal and includes a _warning. Don't ship this to production.
Pass it when you build the server:
from django_spec_mcp import MCPServer
from django_spec_mcp.auth.backends.allow_any_backend import AllowAnyBackend
server = MCPServer(name="dev", auth_backend=AllowAnyBackend())
Writing your own backend¶
A mounted server has no default backend. Every request to its endpoint reaches
authenticate(), and a package default would either verify against an
authorization server you may not run or answer every caller, so
server.urls and server.async_urls raise ImproperlyConfigured when
auth_backend= was not given. A server used purely in process (call_tool,
list_tools) never authenticates and needs no backend.
A backend implements MCPAuthBackend:
| Method | Answers |
|---|---|
authenticate(request) |
A TokenInfo for a request carrying a valid token, None for one that does not (the transport answers 401) |
protected_resource_metadata() |
The RFC 9728 document served at .well-known/oauth-protected-resource: the resource's URL and the authorization servers that issue tokens for it |
www_authenticate_challenge(scopes=, error=) |
The WWW-Authenticate value of a 401 or an insufficient-scope 403. A 401 passes error="invalid_token" when the request sent an Authorization header and error=None when it did not; a 403 passes error="insufficient_scope" with the scopes |
This one verifies tokens with a function the project supplies (a JWKS check, an introspection call, a database lookup) and binds each token to this server's URL:
from django.http import HttpRequest
from django_spec_mcp.auth import TokenInfo, bearer_challenge
from django_spec_mcp.auth.audience import audience_matches
from django_spec_mcp.auth.types.protected_resource_metadata import ProtectedResourceMetadata
class BearerBackend:
"""Verifies tokens an outside authorization server issued for this server."""
def __init__(self, *, resource_url, authorization_server, scopes_supported, verify):
self._resource_url = resource_url
self._authorization_server = authorization_server
self._scopes_supported = scopes_supported
self._verify = verify # raw token -> TokenInfo, or None when it does not verify
def authenticate(self, request: HttpRequest) -> TokenInfo | None:
scheme, _, raw = request.headers.get("Authorization", "").partition(" ")
if scheme.lower() != "bearer" or not raw:
return None
info = self._verify(raw)
# The audience MUST: a token issued for another resource on the same
# authorization server verifies fine and must still be refused here.
if info is None or not audience_matches(info.audience, self._resource_url):
return None
return info
def protected_resource_metadata(self) -> ProtectedResourceMetadata:
return ProtectedResourceMetadata(
resource=self._resource_url,
authorization_servers=[self._authorization_server],
scopes_supported=list(self._scopes_supported),
)
def www_authenticate_challenge(self, *, scopes=None, error=None) -> str:
# The server mounts its metadata view under its own URL, so the pointer
# a client follows to find the authorization server is derived from it.
return bearer_challenge(
resource_metadata_url=(
f"{self._resource_url.rstrip('/')}/.well-known/oauth-protected-resource"
),
scopes=scopes,
error=error,
)
from django_spec_mcp import MCPServer
server = MCPServer(
name="internal-mcp",
auth_backend=BearerBackend(
resource_url="https://example.com/internal/mcp/",
authorization_server="https://idp.example.com",
scopes_supported=["invoices:read", "invoices:write"],
verify=verify_access_token,
),
)
bearer_challenge(resource_metadata_url=, scopes=, error=) is where the header
is formatted, once: the realm, then resource_metadata, error and scope in
the order a client reads them. resource_metadata_url has no default on
purpose. It is the URL a client follows to find your authorization server, and a
challenge without it leaves the client no way to start the flow, with no error
on either side. Pass the URL the server's protected-resource-metadata route
answers at (https://example.com/internal/mcp/.well-known/oauth-protected-resource
for a server whose resource URL is https://example.com/internal/mcp/), or
None when the backend publishes no metadata.
Mounting is where a backend becomes load-bearing, so a backend may check itself
there: one that implements check_configuration() (the SelfChecking
protocol) is asked once when server.urls or server.async_urls is read, and
raising ImproperlyConfigured from it surfaces while the URLconf is imported,
which manage.py check reaches, rather than as a 500 on the first request. A
backend that needs no setup implements nothing and is left alone.
name and version belong on the server
SERVER_INFO["name"] / ["version"] are the fallback for a server
built without name= / version=. Prefer the constructor — it is the only
way to give two servers in one project distinct identities:
Who may call a tool¶
A tool or resource is guarded by its spec's permissions: a list of kernel
PermissionCheck instances, the same
checks every other transport on the kernel asks. Each is asked through the
kernel's dispatch of the principal the backend resolved, TokenInfo.user,
before any rate limit is charged, the target is looked up or the service runs.
Registering a spec that declares no permissions at all is refused:
ImproperlyConfigured at registration. [Unrestricted()] is the honest choice
for an operation open to every caller, because it says so out loud. Set
SPEC_MCP["REQUIRE_TOOL_PERMISSIONS"] = False to downgrade the refusal to an
UnguardedToolWarning; a spec declaring permissions=None then runs each call
under a kernel Grant built for that spec and that caller, so it is called
with no permission check. A scopes= gate does not count as a guard either
way. See Kernel specs.
A check is a subclass with a has_permission(principal, spec) method. A Django
permission is a few lines:
from django_service_specs import PermissionCheck, ServiceSpec
class HasDjangoPerm(PermissionCheck):
def __init__(self, codename: str) -> None:
self.codename = codename
def has_permission(self, principal, spec) -> bool:
return principal.has_perm(self.codename)
server.register_tool(
ServiceSpec(service=refund_invoice, permissions=[HasDjangoPerm("invoices.refund_invoice")]),
name="invoices.refund",
description="Refund an invoice.",
scopes=("invoices:write",),
)
A check refusing the caller is answered -32006 "Insufficient permission"
with HTTP 403. The challenge names no scope, because no scope the client could
obtain would change the answer.
A check that depends on the token rather than the user, a claim say, asks
for the call's context by declaring a keyword call_context. Over HTTP its
"request" is the Django HttpRequest, carrying the backend's TokenInfo as
request.mcp_token; request.user and request.auser() are the token's
principal from authentication on, whatever the session cookie names. An
in-process call_tool or acall_tool is not over HTTP: its "request" may be
None, or a request whose mcp_token, when it has one, carries the scopes=
the call passed and raw=None. So a check reading the token decides for itself
what an in-process call means, and never tells one apart by request is None:
from django_service_specs import PermissionCheck
class HasClaim(PermissionCheck):
def __init__(self, claim: str) -> None:
self.claim = claim
def has_permission(self, principal, spec, *, call_context) -> bool:
request = call_context.get("request")
token = getattr(request, "mcp_token", None)
return token is not None and self.claim in (token.raw or {})
A check may ask for the call's arguments the same way, and for a registered
pool seed by its name. A check is synchronous: one declaring an async def
method is refused with ImproperlyConfigured when the spec is built, because an
un-awaited coroutine is truthy and would allow every call.
The scope gate¶
scopes= on register_tool, register_resource, register_prompt and
register_ui_resource names the OAuth scopes the caller's token must carry, every one of them. A bare string is one scope:
scopes="invoices:write". A token missing one is answered -32006 with HTTP
403, every declared scope in data.requiredScopes, and a challenge carrying
error="insufficient_scope" and the scopes, so the client knows what to ask
for. scopes=() is refused, since it would read as a gate and admit every
caller.
The scope gate is a transport check, beside the spec's permissions rather than
in place of them: on a tool or resource it never satisfies
REQUIRE_TOOL_PERMISSIONS, which reads the spec's own permissions. A prompt
has no spec, so there either guard counts.
Prompts and interactive views¶
A prompt and an interactive view are not specs, so they are gated where they
are registered: register_prompt and register_ui_resource take
permissions=[...], a list of the same kernel PermissionChecks a spec
declares, and scopes=, the scope gate above. The scope gate is asked first,
then a deactivated caller is refused, then each check in order, the first
refusal ending it. A check is asked has_permission(principal, binding):
principal is the token's user, an AnonymousUser when it has none, and
binding is the prompt's or view's binding standing where a spec would, so a
check can read its name.
from django_service_specs import PermissionCheck
class IsStaff(PermissionCheck):
message = "Staff only."
def has_permission(self, principal, spec) -> bool:
return bool(principal.is_staff)
server.register_prompt(
name="summarise-ledger",
render=render_summary,
permissions=[IsStaff()],
scopes="ledger:read",
)
The same judgement answers prompts/get, resources/read,
completion/complete and a subscription. With FILTER_LISTINGS_BY_PERMISSIONS
on, it answers prompts/list and resources/list too, so a caller is never
shown a prompt or view it would then be refused; with the setting off, the
default, the listings show every prompt and view. In a filtered listing a check
that raises hides its own prompt or view and no other, as a spec's check that
cannot decide without a call's arguments hides only its tool; an
ImproperlyConfigured still fails the listing, as it fails the call.
Registration holds the list to the kernel's rule for a spec's permissions,
through the kernel's own check_permissions: a bare string or anything else
that is not a sequence, a check class rather than an instance, an object that
is not a PermissionCheck, a check with an async def method, whose answer
would be a truthy coroutine nothing awaits, and an empty list, which reads as a
gate and admits everyone, are each refused. Leave permissions= out for no
checks, or declare [Unrestricted()]. A check that answers a coroutine anyway
at call time is refused there by the kernel's sync_answer. Only the
principal and the binding are passed: the keywords a spec's check may opt into,
such as arguments or call_context, are the kernel's to offer at dispatch,
so a check that requires one cannot gate a prompt.
A prompt with neither permissions= nor scopes= is refused like a tool. Interactive
views (register_ui_resource) are the one exemption, and a deliberate
one — a view is a template rendered with no context, a literal document, or a
zero-argument callable, none of which can read the caller's data.
That third source is caller-blind because registration enforces it: a
render= declaring any fillable parameter is refused, since the read path
resolves every binding's callable against a pool carrying request and
user. Without that refusal the exemption would be unsound for one of its
three cases — a caller-aware view registered unguarded, served into a document
hosts may cache across callers. If you want a view whose content depends on
who is asking, you want register_resource, where the declaration check
applies.
Object-level permissions¶
A check's has_object_permission(principal, spec, target) runs against the
row the dispatch resolved: the target of a retrieve, or the instance an update
acts on. It allows by default, because most checks are class-level only.
has_scope_permission(principal, spec, scope) is the same question for a call
that resolves no single row: the set a bulk call acts on or a collection
reads, and None for a create. A kernel spec's checks are asked once on a
plain call, and on a streamed call once in its pre-flight and once more when
the call runs, when its arguments pass the kernel's argument check.
A resource read runs the same dispatch, so its spec's checks see the row the
URI names. completion/complete on a resource template names the template
rather than a URI, so the spec's checks are asked on no arguments, and a check
scoping by a URI variable refuses the completion, whether it answers no on the
missing arguments or raises on them.
Filtering listings by permissions¶
By default tools/list, resources/list, resources/templates/list,
and prompts/list return every registered binding regardless of whether
the current caller's permissions would let it invoke one. (A tool that an
operation-scope affordance refuses right now is the exception: tools/list
leaves it out either way, because that condition cannot depend on the
call's arguments — see
Concepts.) Set
SPEC_MCP["FILTER_LISTINGS_BY_PERMISSIONS"] = True to drop
bindings whose permissions deny the caller before paginating.
This is binding-level gating. A binding's scopes= gate is evaluated
against the caller's token, and a prompt's or view's checks against its
principal, as a prompts/get or resources/read asks them. A tool or
resource is judged by its spec's own checks as well, asked through the
kernel's authorize with no arguments, because a listing has none; a create,
a service spec with no instance or collection selector, is also asked each
check's has_scope_permission of None, as every call to it is. Both
questions get the caller's principal, the server's pool seeds and a
call_context whose request carries the token, as a call does. A caller the
checks refuse is not shown the binding, and neither is a deactivated caller. A
spec declaring permissions=None is listed only where its call is granted, as
register_tool grants it with REQUIRE_TOOL_PERMISSIONS off.
A spec's check whose decision depends on the call arguments cannot answer at list time
and will conservatively deny: when asking it raises, that binding alone is
hidden and the rest of the listing is unaffected. Mark such a binding with
always_listed=True to keep it visible as a discovery aid; the check still
gates the actual invocation. An ImproperlyConfigured raised by a check is not
a refusal: it is raised and fails the listing, because the call raises it too.
Audience binding (RFC 8707)¶
The audience check is the backend's job, because only the backend knows where
the audience lives (a JWT aud claim, an introspection response, a column on the
token row). TokenInfo.audience carries it out, and audience_matches is the
comparison, as the worked backend above calls it:
audience_matches(token_audience, expected)is exact equality. Token audiences are URLs, not patterns, and a prefix or substring match is unsafe: a token bound to…/mcpwould otherwise satisfy a server expecting…/mcp-admin.- A token that carries no audience is rejected when an
expectedURL is given, which is the point of configuring one. expected=Nonedisables the check, for a deployment whose gateway already binds the token upstream.
The MCP spec makes this a MUST
2026-07-28 says a resource server MUST validate that access tokens were
issued specifically for it. A backend that only verifies a signature leaves a
conformance gap, and the failure mode is cross-resource token replay: a token
minted for a different resource on the same authorization server is accepted
here. Before you require it, check that the clients you serve send RFC 8707's
resource parameter on the authorize and token requests, because a token that
carries none is rejected.
Each server needs its own canonical URL. RFC 8707 binds a token to a resource, and that binding is what stops a token issued for one resource being replayed against another; two servers sharing one URL defeat it:
# urls.py
common = {
"authorization_server": "https://idp.example.com",
"scopes_supported": ["invoices:read"],
"verify": verify_access_token,
}
internal = MCPServer(
name="internal-mcp",
auth_backend=BearerBackend(resource_url="https://example.com/internal/mcp/", **common),
url_namespace="internal-mcp",
)
public = MCPServer(
name="public-mcp",
auth_backend=BearerBackend(resource_url="https://example.com/public/mcp/", **common),
url_namespace="public-mcp",
)
urlpatterns = [
path("internal/mcp/", internal.urls),
path("public/mcp/", public.urls),
]
A token minted for public-mcp is now rejected by internal-mcp, and each
server's WWW-Authenticate challenge points at its own metadata endpoint.
If you are writing a resource server against the official mcp SDK¶
The SDK publishes the metadata for you and never validates the audience
This is the single easiest thing to get wrong in this area, and it is not specific to this package — it is worth knowing wherever you build an MCP resource server in Python.
mcp.shared.auth_utils provides resource_url_from_server_url() and
check_resource_allowed(), and in the shipped wheel they are called only
from client paths. AccessToken carries a resource field, but
BearerAuthBackend.authenticate() checks the bearer prefix, the verifier's
verdict and expires_at — and nothing else.
So a TokenVerifier that merely checks a signature is non-compliant with
the audience MUST, and cross-resource token replay works: a token minted
for another resource on the same authorization server is accepted. FastMCP's
JWTVerifier does validate audience out of the box; the official SDK's
verifiers do not. If you are writing your own verifier anywhere, validate the
audience in it.
Try it with mcp-inspector¶
Inspector reads the protected-resource metadata, hits your authorization server's metadata, walks the auth flow, and exercises
tools/list + tools/call. Common failure modes and where to look:
| Symptom | Likely cause |
|---|---|
401 with no WWW-Authenticate |
The backend's www_authenticate_challenge returned an empty value. Return bearer_challenge(...). |
401 with WWW-Authenticate but no resource_metadata |
The backend passed resource_metadata_url=None to bearer_challenge. |
| Token verifies but every call is still 401 | The backend's audience comparison fails: the token's audience is not the server's own resource URL (or the token carries none). |
403 with error="insufficient_scope" and scope= in the challenge |
Token authenticated, missing one of the per-binding scopes. The body also carries JSON-RPC -32006 with data.requiredScopes. |
403 with no scope= |
A spec's permission check refused the caller, or a prompt's or view's own check. RFC 6750 defines no error code for that case, so the challenge advertises nothing rather than a scope the client cannot obtain. |
403 with INVALID_REQUEST (-32600) in the body |
Not a permission failure — Origin rejection. |