mudraid-platform-middleware
FastAPI / Starlette middleware for MudraID — a trust layer for AI agents.
Drop it into your FastAPI app, point it at your mudraid_scopes.yaml,
and every scope-gated route is enforced before reaching your handler.
No decorators, no per-route auth code, zero changes to existing route files.
from fastapi import FastAPI
from mudraid_platform_middleware import MudraIDMiddleware
app = FastAPI()
app.add_middleware(MudraIDMiddleware)
@app.get("/api/v1/items")
def list_items():
return {"items": [...]} # gets to here only after JWT + scope check pass
That's the entire integration. Two lines plus a YAML file in your project root.
What the middleware actually does
For every incoming request:
- Match
(method, path)against rules frommudraid_scopes.yaml. - Bypass routes flagged
public: true(health checks, etc.). - 404 routes flagged
skip: trueor absent from the YAML — they're invisible to agents. - Extract the
Authorization: Bearer <jwt>header. - Verify signature,
iss,aud == platform_id,exp,nbf,iatusing MudraID's JWKS (cached 1 h; reactive-refresh on unknownkidso MudraID key rotation is invisible). - Check the route's required scope is in the JWT's
scopesclaim. - Forward to the route handler if every gate passes; otherwise
return a structured
{"error_code", "message"}401 / 403 / 404 / 500 without the handler ever being called.
All of this happens before your route code runs — your handler only sees authenticated, authorised, scope-checked requests.
Installation
pip install mudraid-platform-middleware
Requires Python 3.10+. Brings in pyjwt[crypto], cryptography,
httpx, pyyaml, and starlette as runtime deps.
1.1.0 is not on PyPI yet. Install from source until the first upload lands:
pip install -e sdks/mudraid-middleware-python # from the repo root
Quickstart
1. Register your platform on MudraID
Sign in to the MudraID portal (or call the API), create a new platform, verify your domain ownership (DNS TXT record), and define the scopes your routes will require.
2. Download mudraid_scopes.yaml
The portal generates a file mapping every one of your routes to a
scope, a public: true bypass, or a skip: true 404. Drop it into
your project root next to main.py.
platform_id: 4f8e9c1d-2b4f-5e6a-7b8c-9d0e1f2a3b4c
version: 1
routes:
- method: GET
path: /health
public: true
- method: GET
path: /api/v1/items
scope: items:read
- method: GET
path: /api/v1/items/{item_id}
scope: items:read
- method: POST
path: /api/v1/items
scope: items:write
3. Add two lines to your FastAPI app
from fastapi import FastAPI
from mudraid_platform_middleware import MudraIDMiddleware
app = FastAPI()
app.add_middleware(MudraIDMiddleware)
That's the entire change.
Your existing route handlers stay untouched — no decorators, no auth
parameters, no per-route checks. The middleware enforces the rules
in mudraid_scopes.yaml before requests reach them.
Configuration reference
| Setting | Env var | Constructor kwarg | Default |
|---|---|---|---|
| YAML path | — | scopes_yaml_path= |
./mudraid_scopes.yaml (cwd) |
| MudraID JWKS URL | MUDRAID_JWKS_URL |
jwks_url= |
https://api.mudraid.ai/.well-known/jwks.json |
Everything else (cache TTLs, clock-skew leeway, request-body limits) is internal and stable in v1.
V2 mode (mode="v2") — full action enforcement
V2 adds signed-bundle action enforcement on top of V1's token and scope
checking: every tools/call on a protected surface resolves to a canonical
action and gets a live authority decision, and anything that cannot be decided
is denied rather than allowed.
HttpDecideClient is the supported implementation — it fetches and verifies the
signed bundle, keeps it refreshed, resolves tool names to canonical actions, and
makes the authenticated /decide call.
Two settings configure it, and selecting an environment means changing them — never changing the package, the build or the code path:
| Setting | What it is |
|---|---|
MUDRAID_BASE_URL |
The MudraID origin for your environment. The client derives every path it needs from this. |
MUDRAID_ADAPTER_TOKEN |
Your adapter's bearer, shown once at registration. It must differ between environments — reusing a staging credential in production breaks environment isolation. |
Both are printed by the MudraID portal when you register an adapter. You do not configure a signing secret or a service secret: bundle signatures are verified with public keys the client fetches for itself.
from mudraid_platform_middleware import HttpDecideClient, MudraIDMiddleware, V2Config
decide_client = HttpDecideClient(
base_url=os.environ["MUDRAID_BASE_URL"],
adapter_token=os.environ["MUDRAID_ADAPTER_TOKEN"],
)
@app.on_event("startup")
async def _start_enforcement() -> None:
# Fetches and verifies the first bundle, then refreshes in the background.
# Until one verifies, protected surfaces fail CLOSED — never open.
await decide_client.start()
@app.on_event("shutdown")
async def _stop_enforcement() -> None:
await decide_client.aclose()
app.add_middleware(
MudraIDMiddleware,
mode="v2",
v2=V2Config(decide_client=decide_client, protected_paths=("/mcp",)),
)
Staging-qualified, not generally production-ready. The decision exchange is versioned in both directions and every response is bound to the request that asked for it, but the response is authenticated by the transport alone. A signed decision response is required before this is described as a finished public package. Deploy it behind a version you control, and measure it end-to-end against your own authority first.
| Setting | V2Config field |
Default |
|---|---|---|
| Protected surfaces | protected_paths= |
None (every route is protected) |
| Bounded body limit | max_body_bytes= |
1048576 (1 MiB) — Kong defaults to 128 KiB |
| Control/discovery allowlist | public_methods= |
DEFAULT_PUBLIC_METHODS |
/decide deadline |
decide_timeout_sec= |
2.0 |
protected_paths matches whole path segments, not characters. A configured
/mcp protects /mcp, /mcp/ and /mcp/tools — and does not protect
/mcpfoo or /mcp-metrics, which are different routes that merely start with
the same letters:
| request | /mcp configured |
|---|---|
/mcp · /mcp/ · /mcp/tools · /mcp//tools |
protected |
/mcp?x=1 · /%6dcp/messages · /mcp/../mcp/tools |
protected — every spelling is tested |
/mcpfoo · /mcp-evil · /mcpevil/steal · /MCP |
not protected |
The Kong plugin applies the identical rule, and a contract test runs this table through both implementations so they cannot drift apart.
Entries are validated at construction. A prefix whose meaning depends on
interpretation — /mcp/../admin, /%6dcp, /mcp?x=1 — raises ValueError
there rather than resolving to something you did not write. A trailing slash is
accepted and normalized away.
public_methods differs from the Kong plugin's default, deliberately. This
middleware defaults to the portable contract's allowlist — initialize, ping,
tools/list, resources/list, prompts/list — while mudraid-enforce's
schema defaults to the narrower initialize, ping, tools/list. A Kong
surface left on its default therefore denies resources/list and
prompts/list (403 ENFORCE_MESSAGE_NOT_ALLOWED) where this middleware on its
default passes them.
If you need the two adapters to agree, set it explicitly on both rather than relying on either default:
from mudraid_platform_middleware.v2 import KONG_DEFAULT_PUBLIC_METHODS
V2Config(decide_client=client, public_methods=KONG_DEFAULT_PUBLIC_METHODS)
An empty set is honoured as an empty set — the strictest configuration, where
every message other than tools/call and notifications/* denies.
Two defaults differ between the adapters, and both are configurable on both sides. If you rely on a workload behaving identically on Kong and here, set them explicitly rather than trusting either default:
| Setting | Kong plugin | This middleware |
|---|---|---|
public_methods |
initialize, ping, tools/list |
those plus resources/list, prompts/list |
| body limit | 128 KiB | 1 MiB |
A contract test pins this inventory, so a change to either default fails until this table is updated in the same commit.
decide_timeout_sec is the middleware's own deadline, applied around your
DecideClient whether or not it sets one of its own. It expires deny-closed
(503 ENFORCE_DECIDE_UNAVAILABLE), matching the plugin's decide_timeout_ms
default of 2000. Set it to None only if your client is known to bound itself.
# Local development against the docker-compose stack:
app.add_middleware(
MudraIDMiddleware,
scopes_yaml_path="./mudraid_scopes.yaml",
jwks_url="http://localhost:8011/.well-known/jwks.json",
)
Error responses
All errors come back as a single JSON shape so platforms can build alerting on a stable contract:
{
"error_code": "MISSING_SCOPE",
"message": "required scope 'items:write' not present in token"
}
| Status | error_code |
Cause |
|---|---|---|
| 401 | MISSING_TOKEN |
Authorization header missing or not Bearer |
| 401 | INVALID_TOKEN |
Malformed JWT or invalid signature |
| 401 | EXPIRED_TOKEN |
exp claim is in the past |
| 401 | TOKEN_NOT_YET_VALID |
nbf claim is in the future |
| 401 | WRONG_AUDIENCE |
aud doesn't match this platform's platform_id |
| 401 | WRONG_ISSUER |
iss is not mudraid-identity |
| 403 | MISSING_SCOPE |
JWT valid but required scope absent |
| 404 | ROUTE_NOT_FOUND |
No YAML rule OR skip: true (intentionally indistinguishable) |
| 500 | JWKS_UNAVAILABLE |
Couldn't fetch JWKS to verify (operator-side, not credential-side) |
| 500 | MIDDLEWARE_NOT_READY |
YAML couldn't be loaded or parsed |
Behind that contract is a unit suite covering every failure shape, the JWKS
rotation and rate-limit paths, concurrency safety, and an anti-leak guarantee
that no JWT or secret ever appears in middleware logs. Run it with
pytest from sdks/mudraid-middleware-python for the current count — a number
written here goes stale the first time anyone adds a test, and a stale number
reads as a measurement.
Diagnostics
Enable the middleware's structured logs to watch enforcement happen:
import logging
logging.getLogger("mudraid_platform_middleware").setLevel(logging.INFO)
Bootstrap, JWKS fetches, 401 retries, and 500-level events surface at INFO/WARNING.
Performance
Per-request overhead the middleware adds on top of a vanilla FastAPI handler, measured against the sample-platform route shape with a warm JWKS cache. Numbers are µs of wall time per request, sequential (single-process — capacity-planning numbers, not throughput).
| Route type | Middleware p50 | Middleware p99 | Delta vs. bare app (p50) |
|---|---|---|---|
public: true |
527 µs | 811 µs | +130 µs |
| Scope-gated (RS256 + scope) | 722 µs | 1403 µs | +310 µs |
Measured on Python 3.14, Windows 11, 2000 iterations per scenario, 200-iteration warm-up. Reproduce with
python -m benchmarks.middleware_overhead --iters 2000from the package root.
The scope-gated delta is dominated by RS256 signature verification. JWKS fetch happens once at first request and is not part of the steady-state cost.
Operator playbook
Updating scopes / routes
The middleware loads mudraid_scopes.yaml once at first request and
holds the result in memory. To pick up YAML changes: redeploy
(or restart your process). Hot-reload is intentionally not supported
in v1 — the single-source-of-truth contract between portal and
running middleware is easier to reason about with a restart in the
middle.
MudraID key rotation
When MudraID rotates its signing key, the middleware:
- Receives a JWT signed under the new
kid - Looks up the
kidin its cached JWKS — not found - Re-fetches JWKS (single in-flight refresh under contention)
- Verifies against the new key — success
Zero operator intervention required. Locked by integration tests and the unit-level "unknown-kid reactive refresh" contract.
JWKS endpoint unreachable
The middleware returns 500 JWKS_UNAVAILABLE for affected requests.
The failure is logged at WARNING. The cache is not invalidated on
failure — a transient network blip doesn't take down every cached
key.
The endpoint is not re-dialled once per request while it is down. A fetch
that teaches the client nothing — one that failed, or one that returned a key
set identical to the cache it replaced — arms a short backoff (30 s by default,
refresh_cooldown_sec=). A fetch that changes the key set clears it, so a real
rotation is picked up at full speed and the unknown-kid refresh contract is
unaffected.
What the backoff bounds is a caller inventing kid values: without it, every
unverified bearer token could force one outbound request to MudraID's key
endpoint. Outcomes are unchanged — an unknown kid was a refusal before and still
is.
One consequence worth knowing: if the cache is past its TTL and the client is in backoff, requests are refused rather than served from the expired cache. Serving it would extend the lifetime of every key in it, including one rotated out.
Misconfigured mudraid_scopes.yaml
The middleware lazily loads the YAML on first request. A schema
error returns 500 MIDDLEWARE_NOT_READY and is logged at ERROR. The
failure is not cached — once you fix the YAML, the next request
succeeds without a restart.
Try it locally
A runnable sample platform ships in examples/sample-platform/.
It's a minimal FastAPI app with the middleware applied, callable
from the matching sample agent in
examples/sample-agent/.
# From the repo root:
docker compose up -d # backend
docker compose --profile samples up -d sample-platform # this middleware in action
curl http://localhost:8020/health
# → {"status":"ok"} (public route, no JWT required)
curl http://localhost:8020/api/v1/items
# → 401 {"error_code":"MISSING_TOKEN", "message":"..."}
The sample-platform README walks through the full setup including
the manual platform_id swap before first run.
How it fits into the wider system
agent making a request your FastAPI app
───────────────────────── ────────────────────────
agent.get(url) ──── HTTP ────► MudraIDMiddleware
│
▼
(verify JWT + check scope)
│
▼
your route handler
│
▼
response
↕
┌──────────────────────────┐
│ MudraID backend │
│ /auth/token + /jwks │
└──────────────────────────┘
The matching client-side package is
mudraid-sdk for Python agents.
License
Apache 2.0 — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mudraid_platform_middleware-1.1.0.tar.gz.
File metadata
- Download URL: mudraid_platform_middleware-1.1.0.tar.gz
- Upload date:
- Size: 127.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f117578a85a2c3b274c55f4323f42948573c6322ac71ee846933c7b17f1ff22c
|
|
| MD5 |
809d8661f1cb3edf896ac63ce3606061
|
|
| BLAKE2b-256 |
d56a4b3230012eeb1d3033ba41e892e53cb3e30267896409de19a149e4036be5
|
Provenance
The following attestation bundles were made for mudraid_platform_middleware-1.1.0.tar.gz:
Publisher:
publish.yml on MudraID/mudraid-platform-middleware
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mudraid_platform_middleware-1.1.0.tar.gz -
Subject digest:
f117578a85a2c3b274c55f4323f42948573c6322ac71ee846933c7b17f1ff22c - Sigstore transparency entry: 2436139827
- Sigstore integration time:
-
Permalink:
MudraID/mudraid-platform-middleware@326ac283f619f8b68df93d1a61d29cc3d93d45a3 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/MudraID
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@326ac283f619f8b68df93d1a61d29cc3d93d45a3 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file mudraid_platform_middleware-1.1.0-py3-none-any.whl.
File metadata
- Download URL: mudraid_platform_middleware-1.1.0-py3-none-any.whl
- Upload date:
- Size: 80.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05cb9ec5584e9204ca7f7eb08d4ddd4806fecd54609d80e7aa060fa1b932f1a2
|
|
| MD5 |
5693b0fff6444b04f694adc8cfaf3f2c
|
|
| BLAKE2b-256 |
3c353a092b2354eb2f37f6dab23ae95771f11cf84883308e7ef76e50922898b9
|
Provenance
The following attestation bundles were made for mudraid_platform_middleware-1.1.0-py3-none-any.whl:
Publisher:
publish.yml on MudraID/mudraid-platform-middleware
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mudraid_platform_middleware-1.1.0-py3-none-any.whl -
Subject digest:
05cb9ec5584e9204ca7f7eb08d4ddd4806fecd54609d80e7aa060fa1b932f1a2 - Sigstore transparency entry: 2436140399
- Sigstore integration time:
-
Permalink:
MudraID/mudraid-platform-middleware@326ac283f619f8b68df93d1a61d29cc3d93d45a3 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/MudraID
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@326ac283f619f8b68df93d1a61d29cc3d93d45a3 -
Trigger Event:
workflow_dispatch
-
Statement type: