{"slug": "show-hn-oauth-2-0-framework-for-mcp-servers", "title": "Show HN: OAuth 2.0 framework for MCP servers", "summary": "A new open-source Python framework, mcp-authflow, provides an OAuth 2.0 authorization server for Model Context Protocol (MCP) servers, enabling token issuance and management to protect MCP tool access. The framework supports RFC 6749, RFC 7523, RFC 7636 PKCE, and RFC 8628 Device Authorization Grant, with PostgreSQL and in-memory token storage, sliding-window rate limiting, and CORS helpers. Developers can install the package via pip and build an authorization server that issues tokens for MCP clients, with endpoints for token issuance and introspection.", "body_md": "OAuth 2.0 Authorization Server framework for [MCP](https://modelcontextprotocol.io/) servers. Issue and manage tokens that protect MCP tool access.\n\nPair with [mcp-authflow-resource](https://github.com/brooksmcmillin/mcp-authflow-resource) on the resource server side.\n\n**Token storage** with PostgreSQL and in-memory backends**RFC 6749** standardized OAuth error responses**RFC 7523** client authentication with algorithm allowlist and JTI replay protection (Redis or in-memory)`private_key_jwt`\n\n**RFC 7636 PKCE** verification (`S256`\n\n+`plain`\n\n) and input validation for the token endpoint**RFC 8628 Device Authorization Grant**— sans-IO polling state machine and code generators** Sliding-window rate limiting**for token endpoints** Input validation**for client IDs and scopes** CORS helpers**with origin allowlisting** Async-first**design, built on Starlette\n\n```\npip install mcp-authflow\n\n# With PostgreSQL token storage (production)\npip install mcp-authflow[postgres]\n```\n\nBuild an OAuth authorization server that issues tokens for MCP clients:\n\n``` python\nimport secrets\nimport time\nfrom contextlib import asynccontextmanager\n\nfrom starlette.applications import Starlette\nfrom starlette.requests import Request\nfrom starlette.responses import JSONResponse\nfrom starlette.routing import Route\n\nfrom mcp_authflow.rate_limiting import SlidingWindowRateLimiter\nfrom mcp_authflow.responses import invalid_request, rate_limit_exceeded\nfrom mcp_authflow.storage import MemoryTokenStorage\nfrom mcp_authflow.validation import parse_scope_field, validate_client_id\n\n# --- Setup ---\n\nstorage = MemoryTokenStorage()  # Use PostgresTokenStorage for production\nlimiter = SlidingWindowRateLimiter(requests_per_window=60, window_seconds=3600)\n\n# --- Token endpoint ---\n\nasync def token_endpoint(request: Request) -> JSONResponse:\n    form = await request.form()\n    client_id = str(form.get(\"client_id\", \"\"))\n\n    # Rate limit per client\n    if not limiter.is_allowed(client_id):\n        return rate_limit_exceeded(\n            \"Too many requests\",\n            retry_after=limiter.get_retry_after(client_id),\n        )\n\n    # Validate client\n    if not validate_client_id(client_id):\n        return invalid_request(\"Invalid client_id format\")\n\n    # Issue token\n    token = secrets.token_urlsafe(32)\n    scopes = parse_scope_field(form.get(\"scope\"))\n    expires_at = int(time.time()) + 3600\n\n    await storage.store_token(\n        token=token,\n        client_id=client_id,\n        scopes=scopes.split(),\n        expires_at=expires_at,\n        resource=str(form.get(\"resource\", \"\")),\n    )\n\n    return JSONResponse({\n        \"access_token\": token,\n        \"token_type\": \"bearer\",\n        \"expires_in\": 3600,\n        \"scope\": scopes,\n    })\n\n# --- Introspection endpoint (called by resource servers) ---\n\nasync def introspect_endpoint(request: Request) -> JSONResponse:\n    form = await request.form()\n    token = str(form.get(\"token\", \"\"))\n\n    token_data = await storage.load_token(token)\n    if not token_data or token_data[\"expires_at\"] < time.time():\n        return JSONResponse({\"active\": False})\n\n    return JSONResponse({\n        \"active\": True,\n        \"client_id\": token_data[\"client_id\"],\n        \"scope\": \" \".join(token_data[\"scopes\"]),\n        \"exp\": token_data[\"expires_at\"],\n        \"aud\": token_data.get(\"resource\", \"\"),\n    })\n\n@asynccontextmanager\nasync def lifespan(app):\n    await storage.initialize()\n    yield\n    await storage.close()\n\napp = Starlette(\n    routes=[\n        Route(\"/token\", token_endpoint, methods=[\"POST\"]),\n        Route(\"/introspect\", introspect_endpoint, methods=[\"POST\"]),\n    ],\n    lifespan=lifespan,\n)\n```\n\nRun with: `uvicorn myapp:app --port 8000`\n\n```\n                         MCP Client (Claude, etc.)\n                                |\n                  1. Authorization request\n                                |\n                                v\n                    +---------------------+\n                    |   Auth Server        |   <-- this package\n                    |   (mcp-authflow)  |\n                    |                     |\n                    |  /token             |   2. Issues access token\n                    |  /introspect        |   4. Validates token\n                    +---------------------+\n                                ^\n                                |\n                     4. Token introspection (RFC 7662)\n                                |\n                    +---------------------+\n                    |   Resource Server    |   <-- mcp-authflow-resource\n                    |   (MCP tools)       |\n                    |                     |\n                    |  3. Client calls    |\n                    |     MCP tools with  |\n                    |     Bearer token    |\n                    +---------------------+\n```\n\n- MCP client authenticates with the auth server\n- Auth server issues an access token (stored in PostgreSQL or memory)\n- Client calls MCP tools on the resource server with the Bearer token\n- Resource server validates the token by calling the auth server's\n`/introspect`\n\nendpoint\n\nAbstract base class with two implementations:\n\n``` python\nfrom mcp_authflow.storage import MemoryTokenStorage, PostgresTokenStorage\n\n# In-memory (development/testing)\nstorage = MemoryTokenStorage()\n\n# PostgreSQL (production) -- requires `postgres` extra\nstorage = PostgresTokenStorage(database_url=\"postgresql://user:pass@host/db\")\n# Or reads DATABASE_URL env var if no argument provided\nstorage = PostgresTokenStorage()\n\nawait storage.initialize()  # Open the connection pool (in-memory needs no setup)\n```\n\n`PostgresTokenStorage`\n\ndoes **not** create or migrate its schema — it expects\nthe tables to already exist, so you stay in control of migrations. Apply this\nDDL (e.g. via your migration tool) before first use:\n\n```\nCREATE TABLE IF NOT EXISTS mcp_access_tokens (\n    token       TEXT PRIMARY KEY,\n    client_id   TEXT NOT NULL,\n    scopes      TEXT NOT NULL DEFAULT '',\n    resource    TEXT,\n    expires_at  TIMESTAMPTZ NOT NULL,\n    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),\n    user_id     INTEGER\n);\n\n-- Only needed if you use the refresh-token methods.\nCREATE TABLE IF NOT EXISTS mcp_refresh_tokens (\n    token       TEXT PRIMARY KEY,\n    client_id   TEXT NOT NULL,\n    scopes      TEXT NOT NULL DEFAULT '',\n    resource    TEXT,\n    expires_at  TIMESTAMPTZ NOT NULL,\n    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),\n    user_id     INTEGER\n);\n```\n\n**Storage interface:**\n\n| Method | Description |\n|---|---|\n`store_token(token, client_id, scopes, expires_at, resource?, user_id?)` |\nStore an access token |\n`load_token(token) -> dict | None` |\nLook up a token |\n`delete_token(token)` |\nRevoke a token |\n`cleanup_expired_tokens() -> int` |\nPurge expired tokens, returns count |\n`get_token_count() -> int` |\nCount active tokens |\n`store_refresh_token(...)` |\nStore a refresh token (same interface) |\n`load_refresh_token(token) -> dict | None` |\nLook up a refresh token |\n`delete_refresh_token(token)` |\nRevoke a refresh token |\n`cleanup_expired_refresh_tokens() -> int` |\nPurge expired refresh tokens, returns count |\n\nToken data returned by `load_token()`\n\n:\n\n```\n{\n    \"token\": str,\n    \"client_id\": str,\n    \"scopes\": list[str],\n    \"resource\": str | None,       # RFC 8707 resource binding\n    \"expires_at\": int,            # Unix timestamp\n    \"created_at\": int,            # Unix timestamp\n    \"user_id\": int | None,\n}\n```\n\nStandardized error helpers following RFC 6749:\n\n```\nfrom mcp_authflow.responses import (\n    invalid_request,       # 400 - Missing/invalid parameters\n    invalid_client,        # 401 - Authentication failure\n    invalid_grant,         # 400 - Expired/invalid code or token\n    invalid_scope,         # 400 - Scope violation\n    slow_down,             # 400 - Device flow rate limiting\n    rate_limit_exceeded,   # 429 - Too many requests\n    server_error,          # 500 - Internal error\n    backend_timeout,       # 504 - Upstream timeout\n)\n```\n\nEach returns a Starlette `JSONResponse`\n\nwith the appropriate status code and `Cache-Control: no-store`\n\nheader.\n\n``` python\nfrom mcp_authflow.rate_limiting import SlidingWindowRateLimiter\n\nlimiter = SlidingWindowRateLimiter(\n    requests_per_window=60,   # Max requests per window\n    window_seconds=3600,      # Window duration (1 hour)\n)\n\nif not limiter.is_allowed(client_id):\n    retry_after = limiter.get_retry_after(client_id)  # Seconds until next allowed request\npython\nfrom mcp_authflow.validation import validate_client_id, parse_scope_field\n\nvalidate_client_id(\"my-client-123\")  # True (alphanumeric + hyphens/underscores)\nvalidate_client_id(\"\")               # False\n\nparse_scope_field(\"read write\")      # \"read write\"\nparse_scope_field([\"read\", \"write\"]) # \"read write\"\nparse_scope_field(None)              # \"read\" (default)\npython\nfrom mcp_authflow.cors import parse_allowed_origins, build_cors_headers\n\n# Reads ALLOWED_MCP_ORIGINS env var (comma-separated)\norigins = parse_allowed_origins()\n\n# Returns CORS headers if request origin is in allowlist\nheaders = build_cors_headers(request, origins)\n```\n\n| Env Variable | Description | Default |\n|---|---|---|\n`DATABASE_URL` |\nPostgreSQL connection string (for `PostgresTokenStorage` ) |\nRequired for postgres |\n`ALLOWED_MCP_ORIGINS` |\nComma-separated allowed CORS origins | Empty (no CORS) |\n\nMIT", "url": "https://wpnews.pro/news/show-hn-oauth-2-0-framework-for-mcp-servers", "canonical_source": "https://github.com/brooksmcmillin/mcp-authflow", "published_at": "2026-05-27 16:35:14+00:00", "updated_at": "2026-05-27 16:45:37.940075+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure"], "entities": ["MCP", "OAuth 2.0", "PostgreSQL", "Starlette", "Redis", "RFC 6749", "RFC 7523", "RFC 7636"], "alternates": {"html": "https://wpnews.pro/news/show-hn-oauth-2-0-framework-for-mcp-servers", "markdown": "https://wpnews.pro/news/show-hn-oauth-2-0-framework-for-mcp-servers.md", "text": "https://wpnews.pro/news/show-hn-oauth-2-0-framework-for-mcp-servers.txt", "jsonld": "https://wpnews.pro/news/show-hn-oauth-2-0-framework-for-mcp-servers.jsonld"}}