For service developers
Verifying MCP passports on your service
This guide shows you how to accept MCP passports on your service in about 15 minutes. We cover local JWKS verification, key caching, and applying your own ACL. For AI agents: /docs/all.en.md.
At a glance#
| Fact | Value |
|---|---|
| Effort | ~15 minutes |
| Type | Local JWKS verification |
| Delivery | Service (resource server) side |
How it works#
- The agent sends the passport as
Authorization: Bearer. - The service verifies the signature locally via JWKS and caches the keys by
kid. - The service applies its own ACL to the verified
agent_id.
What you need#
Install lime-mcp-server-sdk>=1.0.0 and fetch the Core public keys — their address is in the reference.
Step 1. Verifying the passport on every request#
This is the trust boundary: the service reads Authorization: Bearer and verifies the passport itself. It checks the RS256 signature with the key named by kid, then the time claims exp, iat and nbf with the verifier's clock-skew leeway (LIME_JWT_VERIFY_LEEWAY_SECONDS; the exact value is in the reference). It checks iss against the issuer field of the OAuth AS metadata document (GET /api/v1/modules/oauth/.well-known/oauth-authorization-server) — never a hardcoded string; for the public deployment iss = https://lime.pics. It checks aud=mcp. It then reads the domain claim and requires it to equal expected_domain exactly: both sides are normalized (lowercased; scheme and path stripped) and compared for exact equality — a port is rejected, not stripped, and a subdomain never matches. A passport that carries user_id, passport_version, or request_id is rejected: those claims exist only on the site-login and binding passports, never on the mcp passport. The sub claim is the agent_id.
Any language, without the SDK: read the kid from the JWT header, fetch the Core JWKS (GET /api/v1/core/.well-known/jwks.json), select the key whose kid matches, and accept RS256 only; verify the signature, then check iss against the AS metadata issuer (https://lime.pics on the public deployment), aud=mcp, and exp; require the domain claim to equal your hostname exactly; finally read the verified sub as the agent_id.
Construct the verifier with keyword-only arguments. expected_domain is required: a missing pin raises ValueError.
| Argument | Required | Default | Meaning |
|---|---|---|---|
expected_domain | yes | — | Hostname this service serves; must equal the passport's domain |
base_url | no | — | LIME origin |
audience | no | mcp | Accepted aud |
cache_ttl | no | — | JWKS snapshot TTL, in seconds |
leeway_seconds | no | — | Clock-skew leeway for exp, iat and nbf |
min_refresh_seconds | no | — | Floor between forced refreshes, in seconds |
allowed_algorithms | no | ("RS256",) | Accepted signing algorithms |
config | no | — | Pre-built LimeConfig; explicit keyword arguments override its fields |
cache | no | — | Custom JWKS cache instance for tests or a shared cache; a default is created when omitted |
The verifier reads the same settings from the environment: LIME_EXPECTED_DOMAIN, LIME_BASE_URL, LIME_OAUTH_AUDIENCE (default mcp), LIME_JWKS_CACHE_TTL_SECONDS (default 3600), LIME_JWT_VERIFY_LEEWAY_SECONDS (default 120), LIME_JWKS_MIN_REFRESH_SECONDS (default 60), and LIME_VERIFY_USER_AGENT (default curl/8.5.0).
from lime_mcp_server import TokenVerifier
VERIFIER = TokenVerifier(expected_domain="<SERVICE_DOMAIN>") # pin the hostname this server serves
def agent_id_from(authorization: str) -> str | None:
passport = authorization.removeprefix("Bearer ").strip() # e.g. "Bearer <PASSPORT>"
result = VERIFIER.verify(passport) # synchronous; never raises on a bad token
if not result.is_valid:
raise SystemExit("invalid MCP passport") # -> 401
return result.agent_id # claims["sub"] — this is the agent_idverify(token) returns a TokenValidationResult(is_valid, claims, error) and never raises on a bad token — only the constructor raises, for example on a missing expected_domain pin (ValueError). Read .agent_id (the same value as claims["sub"]) and .domain as convenience properties. Call verify_async(token) from an async handler: it runs verify in a worker thread.
Result: verify returns is_valid = true and the agent_id for a good passport, and is_valid = false for a tampered signature, a wrong aud, or an unknown kid.
Step 2. Caching the keys in each replica#
JWKS is a single set of Core keys for all audiences, so you never pin a service-specific key. Every replica keeps its own cache and refreshes it on an unknown kid: you must not accept a signature that matches only an old key set. The cache TTL is configurable — the default is in the reference.
from lime_mcp_server import TokenVerifier
# A replica reuses one verifier per process: the constructor warms the cache.
VERIFIER = TokenVerifier(expected_domain="<SERVICE_DOMAIN>")
if not VERIFIER.warmup(): # prefetch metadata + JWKS at cold start
log.warning("JWKS prefetch failed") # returns False unless raise_on_failure=True
VERIFIER.refresh_cache() # force refetch; rate-limited to min_refresh_seconds
VERIFIER.invalidate_cache() # drop the cached snapshot
def is_valid_passport(authorization: str) -> bool:
passport = authorization.removeprefix("Bearer ").strip() # e.g. "Bearer <PASSPORT>"
return VERIFIER.verify(passport).is_valid # an unknown kid triggers a JWKS refreshThe constructor already warms the cache. To prefetch explicitly at cold start, call warmup(*, raise_on_failure=False) -> bool: it fetches the metadata and JWKS, then returns False and logs on failure unless raise_on_failure=True. refresh_cache() forces a synchronous refetch but is rate-limited to at most once per LIME_JWKS_MIN_REFRESH_SECONDS (default 60); invalidate_cache() drops the snapshot.
Close the verifier with close() to release the cache's HTTP resources. TokenVerifier is a synchronous context manager, so the with block calls close() on exit. There is no aclose().
with TokenVerifier(expected_domain="<SERVICE_DOMAIN>") as verifier:
assert verifier.verify("<PASSPORT>").is_valid # close() runs when the block exitsResult: a valid passport is accepted, while an expired passport and one with an unknown kid are rejected with 401 — with no call to LIME.
Step 3. Reading `agent_id` and applying your own ACL#
A passport confirms which agent arrived, not what it is allowed to do. Building your ACL on the verified agent_id keeps one agent out of another agent's data.
from lime_mcp_server import TokenVerifier
VERIFIER = TokenVerifier(expected_domain="<SERVICE_DOMAIN>")
SCOPES: dict[str, set[str]] = {"<AGENT_ID>": {"tools:read"}} # your agent_id and scope pairs
def authorize(authorization: str) -> str | None:
passport = authorization.removeprefix("Bearer ").strip() # e.g. "Bearer <PASSPORT>"
result = VERIFIER.verify(passport)
if result.is_valid and "tools:read" not in SCOPES.get(result.agent_id, set()):
raise PermissionError("forbidden") # -> 403, not 401
return result.agent_id # your logs show the scope next to agent_idResult: every call passes both authentication and authorization, and your logs show, next to agent_id, which tool the agent used.
Example response (JSON)#
The service downloads and caches the JWKS document once:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "core-rs-v1",
"n": "base64url-modulus",
"e": "AQAB"
}
]
}Verify it worked#
| Check | Expected | Negative |
|---|---|---|
| Valid passport for your domain | is_valid = true, agent_id present | — |
Tampered signature or wrong aud | is_valid = false | 401 |
| Passport minted for another hostname | rejected by the verifier | is_valid = false (Domain mismatch) |
Passport carrying user_id, passport_version, or request_id | rejected by the verifier | is_valid = false → 401 |
| JWKS endpoint unavailable with a warm cache | verification still succeeds locally | JWKS_UNAVAILABLE |
Codes and errors#
LIME Core errors arrive in the envelope {ok:false,error:{code,message,detail}}; the code column is error.code. The 401 and 403 rows below are your own service's responses.
| Code | HTTP | Cause | Action |
|---|---|---|---|
401 | 401 | Signature is wrong, aud is not mcp, kid is unknown, or the passport expired | Refresh JWKS and mint a new passport |
403 | 403 | The verified agent_id lacks the required scope | Deny the call through your own ACL |
JWKS_UNAVAILABLE | 503 | JWKS is unavailable and no cached key can verify the passport | Return 503 and retry with backoff |
RATE_LIMIT_EXCEEDED | 429 | JWKS refreshes or passport minting run too often | Back off with jitter; the OAuth mint 429 does not carry Retry-After |
The verifier returns is_valid=false and sets error to one exact string: Token expired, Invalid issuer, Invalid audience, Missing domain claim, Invalid domain claim, Domain mismatch, Invalid token: …, or Verification error: …. When JWKS is unreachable and no cached key verifies the passport, error starts with Verification error: — map that failure (or a JWKS fetch failure) to 503 + Retry-After. Map every other auth failure to 401 so the agent re-mints, and reserve 403 for your own ACL.
What your service returns to the agent:
| Verification outcome | Return | The agent then |
|---|---|---|
Authorization missing or not Bearer | 401 | mints a passport and retries |
is_valid=false (signature, iss, aud, kid, expired) | 401 | the SDK re-mints (lazy refresh) and retries once |
| Valid passport, your ACL denies | 403 | stops — it is your policy, not an auth failure |
JWKS unreachable and no cached key (the verifier returns is_valid=false with error starting Verification error:) | 503 + Retry-After | backs off and retries |
| Tool error (not auth) | your own JSON-RPC/tool error | handles it as a tool failure |