Two earlier articles secured a FastMCP server two ways: Gate a FastMCP Server to Paying Customers verified opaque license keys you minted and stored yourself, and Add GitHub OAuth delegated login to GitHub. This one takes a third path that sits between them: you run your own token authority that issues asymmetrically signed JWTs, and the server verifies each token’s signature against a JWKS (JSON Web Key Set) it publishes. No per-request database lookup, no third-party login. The signature and claims are self-contained, and only the holder of the private key can mint a valid token.

You will build a small signing service (mint.py), a server that verifies tokens with FastMCP’s JWTVerifier via jwks_uri, and scope-based authorization so privileged tools require an extra scope. The stack is Mac-native: uv, make, and a launchd service.

Why asymmetric? The authority signs with a private key; the server (and anyone) verifies with the public key. The server never holds a secret capable of minting tokens, so publishing its verification keys as a JWKS is safe and is exactly how identity providers expose theirs.

What you will build

  • keys.py: generate and persist an RSA key, and derive the public key and JWKS.
  • mint.py: a CLI token authority that signs JWTs (subject, scopes, expiry).
  • A server that verifies tokens with JWTVerifier(jwks_uri=…) and publishes /.well-known/jwks.json.
  • Tools: whoami, word_count (any valid token), and rotate_secrets (requires the admin scope).
  • A pytest suite covering the full verification decision table and a launchd deployment.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Familiarity with JWTs helps; the claims and signing are explained as they appear. FastMCP pulls in PyJWT and cryptography, so there is nothing extra to install.

Everything runs through uv and make. Token verification applies to the HTTP transport.

Step 1: Scaffold and lock down hygiene

Create the workspace and a .gitignore first. It ignores the RSA private key, which is the one secret that can mint tokens.

Create the files

mkdir -p jwt-fastmcp-server-macos
cd jwt-fastmcp-server-macos
touch .gitignore

Add the code: .gitignore

__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
# Private signing key — never commit
*.pem
jwtRS256.key
# OS / editor noise
.DS_Store

Detailed breakdown

  • jwtRS256.key and *.pem hold the RSA private key. Anyone with it can mint tokens your server will trust, so it must never be committed. The public key and JWKS are derived from it at runtime and are safe to expose.

Step 2: Initialize the project with uv

Create the uv project and add FastMCP plus the test tools.

Create the files

uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio

Add the code: pyproject.toml

[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server that verifies signed JWTs via JWKS"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.3",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • fastmcp provides JWTVerifier and the RSAKeyPair test helper, and it depends on PyJWT and cryptography, which the key and mint modules use. No extra dependency is needed.

Step 3: Shared configuration

The minter and the server must agree on the issuer, audience, and where the JWKS lives. Put those in one module.

Create the file

touch config.py

Add the code: config.py

"""Shared JWT configuration for the minter and the server."""

import os

ISSUER = os.environ.get("JWT_ISSUER", "https://auth.macmcp.local")
AUDIENCE = os.environ.get("JWT_AUDIENCE", "macmcp")
BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
JWKS_URL = os.environ.get("JWKS_URL", f"{BASE_URL}/.well-known/jwks.json")

# Every valid token must carry this scope; premium tools require more.
REQUIRED_SCOPES = ["access"]

Detailed breakdown

  • ISSUER / AUDIENCE become the iss and aud claims. The verifier rejects any token whose iss/aud do not match, which stops a token minted for one service from being replayed against another.
  • JWKS_URL is where the server publishes its verification keys and where the verifier fetches them. In this self-contained demo the authority and the server share a host; in production the authority (your login/billing service) hosts the JWKS and the MCP server points jwks_uri at it.
  • REQUIRED_SCOPES is the baseline every token must carry to reach any tool.

Step 4: Manage the signing key and publish the JWKS

Generate the RSA key once, persist it, and derive the public key and JWKS from it so they never drift apart.

Create the file

touch keys.py

Add the code: keys.py

"""RSA signing key management and JWKS publication.

The token authority signs JWTs with an RSA private key; the server verifies them
with the matching public key, which it publishes as a JWKS document. The private
key is generated once and persisted to a gitignored file; the public key and the
JWKS are derived from it, so they always stay in sync.
"""

import hashlib
import os
from pathlib import Path

from cryptography.hazmat.primitives import serialization
from fastmcp.server.auth.providers.jwt import RSAKeyPair
from jwt.algorithms import RSAAlgorithm

ALGORITHM = "RS256"


def _key_path() -> Path:
    return Path(os.environ.get("JWT_PRIVATE_KEY", "jwtRS256.key"))


def private_pem() -> str:
    """Return the PEM private key, generating and persisting it on first use."""
    path = _key_path()
    if not path.exists():
        keypair = RSAKeyPair.generate()
        path.write_text(keypair.private_key.get_secret_value())
        path.chmod(0o600)
    return path.read_text()


def _public_key():
    priv = serialization.load_pem_private_key(private_pem().encode(), password=None)
    return priv.public_key()


def public_pem() -> str:
    """Return the PEM public key derived from the private key."""
    return _public_key().public_bytes(
        serialization.Encoding.PEM,
        serialization.PublicFormat.SubjectPublicKeyInfo,
    ).decode()


def kid() -> str:
    """A stable key id: the first 16 hex chars of the public key's SHA-256."""
    return hashlib.sha256(public_pem().encode()).hexdigest()[:16]


def jwks() -> dict:
    """Build a JWKS document (one key) from the public key."""
    jwk = RSAAlgorithm.to_jwk(_public_key(), as_dict=True)
    jwk.update({"kid": kid(), "use": "sig", "alg": ALGORITHM})
    return {"keys": [jwk]}

Detailed breakdown

  • private_pem generates an RSA keypair with FastMCP’s RSAKeyPair on first call, writes the private key to a 0600 file, and reuses it afterward. The minter and server both read this one file.
  • public_pem derives the public key from the private key with cryptography, so there is no separate public-key file to keep in sync.
  • kid is a stable key id computed from the public key. Tokens carry it in their header, and the JWKS advertises it, so a verifier can pick the right key. Because it is derived from the key, rotating the key changes the kid automatically.
  • jwks turns the public key into a JWK with PyJWT’s RSAAlgorithm.to_jwk and tags it with the kid, use, and alg. This dict is what the server serves at /.well-known/jwks.json.

Step 5: The token authority (mint.py)

This CLI signs a JWT for a subject with a set of scopes and an expiry. It stands in for whatever issues credentials to your users: a login service, a billing webhook, or an admin tool.

Create the file

touch mint.py

Add the code: mint.py

"""Token authority: mint a signed JWT for a subject.

This stands in for whatever issues credentials to your users (a login service, a
billing webhook, an admin CLI). It signs a JWT with the RSA private key; the
server verifies it against the published JWKS. Run it with `make token`.

    uv run python mint.py alice --scopes access,admin --ttl 3600
"""

import argparse
import time

import jwt

import config
import keys


def mint(subject: str, scopes: list[str], ttl: int) -> str:
    now = int(time.time())
    payload = {
        "sub": subject,
        "iss": config.ISSUER,
        "aud": config.AUDIENCE,
        "scope": " ".join(scopes),
        "iat": now,
        "exp": now + ttl,
    }
    return jwt.encode(
        payload,
        keys.private_pem(),
        algorithm=keys.ALGORITHM,
        headers={"kid": keys.kid()},
    )


def main() -> None:
    parser = argparse.ArgumentParser(description="Mint a signed JWT")
    parser.add_argument("subject", help="the token subject (e.g. a user id)")
    parser.add_argument("--scopes", default="access", help="comma-separated scopes")
    parser.add_argument("--ttl", type=int, default=3600, help="lifetime in seconds")
    args = parser.parse_args()
    scopes = [s.strip() for s in args.scopes.split(",") if s.strip()]
    print(mint(args.subject, scopes, args.ttl))


if __name__ == "__main__":
    main()

Detailed breakdown

  • The payload is a standard JWT claim set. sub is the user, iss/aud must match the server’s config, iat/exp bound the lifetime, and scope is a space-separated list (the convention FastMCP parses into AccessToken.scopes).
  • jwt.encode(..., algorithm="RS256", headers={"kid": ...}) signs the payload with the RSA private key and stamps the key id in the header. The server uses that kid to select the matching public key from the JWKS.
  • Because signing needs the private key, only this authority can produce tokens the server will trust. Verification needs only the public key.

Step 6: Verify tokens and gate scopes

Wire the verifier to the JWKS, and add a scope gate for privileged tools.

Create the file

touch auth.py

Add the code: auth.py

"""JWT verification wiring and scope-based authorization."""

from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.jwt import JWTVerifier

import config


def build_verifier() -> JWTVerifier:
    """Verify incoming JWTs against the JWKS published by the server.

    The verifier fetches the public keys from `jwks_uri` and checks each token's
    signature, `iss`, `aud`, expiry, and that it carries the required scopes.
    """
    return JWTVerifier(
        jwks_uri=config.JWKS_URL,
        issuer=config.ISSUER,
        audience=config.AUDIENCE,
        required_scopes=config.REQUIRED_SCOPES,
    )


def require_scope(access: AccessToken | None, scope: str) -> None:
    """Raise ToolError unless the caller's token carries `scope`."""
    scopes = getattr(access, "scopes", None) or []
    if scope not in scopes:
        raise ToolError(f"this tool requires the '{scope}' scope")

Detailed breakdown

  • JWTVerifier(jwks_uri=…, issuer=…, audience=…, required_scopes=…) does the heavy lifting. It fetches and caches the JWKS from the URL, then for every token verifies the RSA signature, that iss/aud match, that exp is in the future, and that the required scopes are present. Any failure rejects the request with 401 before a tool runs.
  • required_scopes=["access"] enforces a baseline entitlement at the server level. A token without access never reaches a tool.
  • require_scope adds per-tool authorization on top: rotate_secrets calls it to demand the admin scope, so an ordinary access token is turned away.

Step 7: The server

Attach the verifier and publish the JWKS on the same HTTP app, so the keys the verifier trusts are served alongside the tools.

Create the file

touch server.py

Add the code: server.py

"""A FastMCP server that verifies signed JWTs via JWKS.

Callers present a JWT signed by the token authority (mint.py). The server
verifies each token's RSA signature against the public keys it publishes at
`/.well-known/jwks.json`, plus the issuer, audience, expiry, and required
scopes. Verification applies to the HTTP transport.
"""

import os

from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
from starlette.requests import Request
from starlette.responses import JSONResponse

import keys
from auth import build_verifier, require_scope

mcp = FastMCP("macmcp", auth=build_verifier())


@mcp.tool
def whoami() -> dict:
    """Return the subject and scopes from the caller's verified token."""
    token = get_access_token()
    return {"subject": token.client_id, "scopes": token.scopes}


@mcp.tool
def word_count(text: str) -> int:
    """Count words. Available to any token with the 'access' scope."""
    return len(text.split())


@mcp.tool
def rotate_secrets() -> dict:
    """A privileged tool. Requires the 'admin' scope."""
    require_scope(get_access_token(), "admin")
    return {"status": "secrets rotated"}


@mcp.custom_route("/.well-known/jwks.json", methods=["GET"])
async def jwks(request: Request) -> JSONResponse:
    """Publish the public keys clients' tokens are verified against."""
    return JSONResponse(keys.jwks())


def main() -> None:
    if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
        mcp.run(
            transport="http",
            host=os.environ.get("MCP_HOST", "127.0.0.1"),
            port=int(os.environ.get("MCP_PORT", "8000")),
        )
    else:
        mcp.run()  # stdio (unauthenticated; local dev only)


if __name__ == "__main__":
    main()

Detailed breakdown

  • FastMCP("macmcp", auth=build_verifier()) enforces JWT verification on every HTTP request. get_access_token() inside a tool returns the verified token, so whoami reports the sub and scopes.
  • rotate_secrets demonstrates authorization: it reaches for the admin scope and raises ToolError if the token lacks it, even though the token is otherwise valid.
  • @mcp.custom_route("/.well-known/jwks.json") serves the JWKS on the same app the verifier reads from, so the deployment is self-contained. In production you would instead point jwks_uri at your identity provider’s JWKS and drop this route.

Step 8: Test the verification decision table

Verification logic is the security boundary, so test it directly. Generate an isolated key per test, mint tokens, and verify them with JWTVerifier(public_key=…). That runs the same checks the server does via JWKS, without an HTTP round-trip.

Create the files

mkdir -p tests
touch tests/__init__.py tests/test_jwt.py pytest.ini

Add the code: pytest.ini

[pytest]
asyncio_mode = auto
filterwarnings =
    ignore::DeprecationWarning

Add the code: tests/test_jwt.py

"""Test JWT verification directly against a static public key.

A fresh RSA keypair is generated per test session and its private key is written
to a temp file, so mint.py and keys.py operate on an isolated key. Verification
uses JWTVerifier(public_key=...) — the same logic the server runs via JWKS, but
without an HTTP round-trip, so the decision table is hermetic.
"""

import importlib
import time

import jwt as pyjwt
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.jwt import JWTVerifier

ISSUER = "https://auth.macmcp.local"
AUDIENCE = "macmcp"


@pytest.fixture()
def kit(tmp_path, monkeypatch):
    monkeypatch.setenv("JWT_PRIVATE_KEY", str(tmp_path / "key.pem"))
    import config
    import keys
    import mint

    importlib.reload(config)
    importlib.reload(keys)
    importlib.reload(mint)
    verifier = JWTVerifier(
        public_key=keys.public_pem(),
        issuer=ISSUER,
        audience=AUDIENCE,
        required_scopes=["access"],
    )
    return mint, keys, verifier


async def test_valid_token_verifies(kit):
    mint, _, verifier = kit
    token = mint.mint("alice", ["access", "admin"], ttl=60)
    result = await verifier.verify_token(token)
    assert result is not None
    assert result.client_id == "alice"
    assert set(result.scopes) == {"access", "admin"}


async def test_expired_token_rejected(kit):
    mint, _, verifier = kit
    token = mint.mint("alice", ["access"], ttl=-10)  # already expired
    assert await verifier.verify_token(token) is None


async def test_missing_required_scope_rejected(kit):
    mint, _, verifier = kit
    token = mint.mint("alice", ["something-else"], ttl=60)  # lacks "access"
    assert await verifier.verify_token(token) is None


async def test_wrong_audience_rejected(kit):
    mint, keys_mod, verifier = kit
    now = int(time.time())
    token = pyjwt.encode(
        {"sub": "a", "iss": ISSUER, "aud": "some-other-api",
         "scope": "access", "iat": now, "exp": now + 60},
        keys_mod.private_pem(), algorithm="RS256", headers={"kid": keys_mod.kid()},
    )
    assert await verifier.verify_token(token) is None


async def test_tampered_signature_rejected(kit):
    mint, _, verifier = kit
    token = mint.mint("alice", ["access"], ttl=60)
    tampered = token[:-4] + ("aaaa" if not token.endswith("aaaa") else "bbbb")
    assert await verifier.verify_token(tampered) is None


def test_jwks_document_shape(kit):
    _, keys_mod, _ = kit
    doc = keys_mod.jwks()
    assert doc["keys"] and doc["keys"][0]["kty"] == "RSA"
    assert doc["keys"][0]["kid"] == keys_mod.kid()
    assert doc["keys"][0]["alg"] == "RS256"


def test_require_scope_gate():
    from auth import require_scope

    admin = AccessToken(token="t", client_id="a", scopes=["access", "admin"])
    require_scope(admin, "admin")  # no raise
    with pytest.raises(ToolError):
        require_scope(AccessToken(token="t", client_id="a", scopes=["access"]), "admin")

Detailed breakdown

  • The kit fixture points JWT_PRIVATE_KEY at a temp file and reloads the modules, so each test gets a fresh, isolated key and never touches your real jwtRS256.key.
  • The verification tests cover the whole decision table: a valid token yields the right subject and scopes; expired, missing-required-scope, wrong-audience, and tampered-signature tokens all return None. Returning None is what the server turns into a 401.
  • test_jwks_document_shape confirms the published JWKS is a well-formed RSA JWK whose kid matches the signing key, which is what lets a verifier find the right key.
  • test_require_scope_gate proves the per-tool authorization independently of transport.

Run them:

uv run pytest -v

All tests pass, with no server running.

Step 9: The Makefile

Wrap development, token minting, and the launchd deployment. Plain make prints help.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

# --- Deploy configuration (launchd user agent) --------------------------------
LABEL   := com.macmcp.jwt
PLIST   := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV      := $(shell command -v uv)
DIR     := $(shell pwd)
DOMAIN  := gui/$(shell id -u)

# `make token` / `make smoke` arguments
SUBJECT ?= alice
SCOPES  ?= access,admin

.PHONY: help install serve test token smoke deploy undeploy status logs clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

install:  ## Sync runtime and dev dependencies
	uv sync

serve:  ## Run the HTTP server in the foreground (Ctrl-C to stop)
	MCP_TRANSPORT=http uv run python server.py

test:  ## Run the test suite
	uv run pytest -v

token:  ## Mint a JWT: make token SUBJECT=alice SCOPES=access,admin
	@uv run python mint.py $(SUBJECT) --scopes $(SCOPES)

smoke:  ## Call tools with a freshly minted token (SUBJECT/SCOPES overridable)
	@JWT="$$(uv run python mint.py $(SUBJECT) --scopes $(SCOPES))" uv run python scripts/smoke.py

deploy:  ## Render the launchd plist and start the service
	@mkdir -p logs
	@sed -e 's#@UV@#$(UV)#g' \
	     -e 's#@DIR@#$(DIR)#g' \
	     -e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
	     deploy/$(LABEL).plist.template > $(PLIST)
	launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
	launchctl bootstrap $(DOMAIN) $(PLIST)
	@echo "Deployed $(LABEL). Check: make status"

undeploy:  ## Stop the service and remove the launchd plist
	launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
	rm -f $(PLIST)
	@echo "Removed $(LABEL)."

status:  ## Show whether the service is registered and running
	@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
		| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."

logs:  ## Tail the service log files
	@tail -n 40 -f logs/server.out.log logs/server.err.log

clean:  ## Remove caches and logs (keeps the signing key)
	rm -rf .pytest_cache logs __pycache__ tests/__pycache__

Detailed breakdown

  • token mints a JWT you can paste into any client’s Authorization: Bearer header. smoke mints one and calls the tools in one step; override SCOPES to see the admin gate.
  • clean keeps the signing key so cleaning caches never invalidates already issued tokens. The launchd lifecycle matches the companion deploy article.

Step 10: Deploy and exercise the flow

Add the launchd plist and a smoke client, then run it end to end.

Create the files

mkdir -p deploy scripts
touch deploy/com.macmcp.jwt.plist.template scripts/smoke.py

Add the code: deploy/com.macmcp.jwt.plist.template

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.macmcp.jwt</string>

    <key>ProgramArguments</key>
    <array>
        <string>@UV@</string>
        <string>run</string>
        <string>python</string>
        <string>server.py</string>
    </array>

    <key>WorkingDirectory</key>
    <string>@DIR@</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>MCP_TRANSPORT</key>
        <string>http</string>
        <key>MCP_HOST</key>
        <string>127.0.0.1</string>
        <key>MCP_PORT</key>
        <string>8000</string>
        <key>BASE_URL</key>
        <string>http://127.0.0.1:8000</string>
        <key>JWT_PRIVATE_KEY</key>
        <string>@DIR@/jwtRS256.key</string>
        <key>PATH</key>
        <string>@PATH@</string>
    </dict>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>@DIR@/logs/server.out.log</string>

    <key>StandardErrorPath</key>
    <string>@DIR@/logs/server.err.log</string>
</dict>
</plist>

Add the code: scripts/smoke.py

"""Call the server with a JWT and print the results.

    JWT=$(uv run python mint.py alice --scopes access,admin) \
        uv run python scripts/smoke.py
"""

import asyncio
import os

from fastmcp import Client
from fastmcp.client.auth import BearerAuth

URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
TOKEN = os.environ.get("JWT", "")


async def main() -> None:
    auth = BearerAuth(TOKEN) if TOKEN else None
    async with Client(URL, auth=auth) as client:
        print("whoami ->", (await client.call_tool("whoami", {})).data)
        wc = await client.call_tool("word_count", {"text": "signed and verified"})
        print("word_count ->", wc.data)
        try:
            print("rotate_secrets ->", (await client.call_tool("rotate_secrets", {})).data)
        except Exception as exc:  # noqa: BLE001
            print("rotate_secrets DENIED ->", exc)


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • The plist sets JWT_PRIVATE_KEY to an absolute path so the deployed service reads the same signing key the minter uses. BASE_URL fixes the JWKS_URL the verifier fetches.
  • smoke.py attaches the JWT as a bearer token with BearerAuth. With no token it lets you watch the server reject an unauthenticated request.

Deploy and run the flow:

make deploy
make status                 # state = running, a live pid

Confirm the JWKS is published:

curl -s http://127.0.0.1:8000/.well-known/jwks.json
# {"keys": [{"kty": "RSA", "n": "...", "e": "AQAB", "kid": "...", "use": "sig", "alg": "RS256"}]}

An admin token reaches every tool:

make smoke
# whoami -> {'subject': 'alice', 'scopes': ['access', 'admin']}
# word_count -> 3
# rotate_secrets -> {'status': 'secrets rotated'}

An access-only token is verified but blocked from the privileged tool:

make smoke SUBJECT=bob SCOPES=access
# whoami -> {'subject': 'bob', 'scopes': ['access']}
# word_count -> 3
# rotate_secrets DENIED -> this tool requires the 'admin' scope

An expired token, or no token, cannot get in (both return 401):

JWT=$(make token SUBJECT=carol SCOPES=access) # then wait, or mint with a short ttl
uv run python mint.py carol --ttl -10 > /tmp/expired && \
  JWT=$(cat /tmp/expired) uv run python scripts/smoke.py   # 401 Unauthorized

Tear down when done:

make undeploy

Troubleshooting

  • Every token returns 401. The server cannot fetch or match the JWKS. Confirm curl $BASE_URL/.well-known/jwks.json returns a key, and that the token’s kid header matches the JWKS kid (it will, as long as the minter and server share JWT_PRIVATE_KEY).
  • 401 right after rotating the key. Old tokens were signed by the previous key and no longer verify. That is the point of rotation. Re-mint tokens after replacing jwtRS256.key.
  • invalid audience / invalid issuer. The token’s aud/iss do not match the server’s JWT_AUDIENCE/JWT_ISSUER. Mint with the same config the server runs.
  • rotate_secrets denied with a valid token. The token lacks the admin scope. Mint with --scopes access,admin.
  • Production over plain HTTP. Serve JWKS and the MCP endpoint over HTTPS in production, and set BASE_URL to the public https:// URL so jwks_uri is fetched securely.

Recap

You built a server that trusts self-contained, cryptographically signed tokens:

  • A token authority (mint.py) signs JWTs with an RSA private key; the server verifies them with the public key, so it holds no minting secret.
  • JWTVerifier(jwks_uri=…) checks signature, iss, aud, expiry, and required scopes on every request, and the server publishes its JWKS for verification.
  • Scopes provide both a baseline gate (required_scopes) and per-tool authorization (require_scope).
  • The tests cover the full decision table, and uv, make, and launchd package and deploy it.

Next improvements

  • Move the authority to a separate service and point jwks_uri at it; keep the private key off the MCP server entirely.
  • Support key rotation with multiple keys in the JWKS (verifiers pick by kid), so old and new tokens both validate during a rollover window.
  • Add short token lifetimes plus a refresh endpoint, instead of long-lived tokens.
  • Map scopes to plans and combine with the license article to sell tiered access.