# AuthPlane Docs — full text Fetched from https://docs.authplane.ai/llms-full.txt — see https://docs.authplane.ai/llms.txt for an index. Each page below begins with its source path (which mirrors its URL slug) and is separated by a horizontal rule. --- ## choose-your-topology.mdx --- title: Choose your topology description: "Pick the deployment shape that matches your setup — single agent, gateway, brokered upstream, or enterprise federated." section: Getting started sectionOrder: 1 order: 4 --- # Choose your topology > **TL;DR** — Every AuthPlane deployment reduces to one of ten canonical topologies. Pick the shape that matches yours by answering three questions: how many MCP servers, who authenticates the user, and does the MCP server need upstream tokens (GitHub, Slack, Google, …). Each topology has a dedicated page with a component diagram, wire-level flow, and end-to-end configuration. ## The three questions Answer these first — they narrow you to one row of the tables below. 1. **How many MCP servers is one agent talking to?** - One → **single-resource**. - Many → **multi-resource** (fanout, gateway, folded). 2. **Who authenticates the user?** - AuthPlane's local login → any single- or multi-resource topology works as-is. - Your corporate IdP (Google, Okta, Entra, Auth0) → add [OIDC-federated user login](/topologies/oidc-federated-login) on top. - Your corporate IdP asserts the *agent's* identity (skipping per-user consent) → [Enterprise-Asserted Agent Identity (XAA)](/topologies/enterprise-xaa). 3. **Does the MCP server need to act on the user's behalf against a third-party API** (call the user's GitHub, read their Google Calendar)? - Yes → **broker** flavor of whichever shape you picked. AuthPlane vaults the refresh token and vends short-lived access tokens on demand. - No → **mint** flavor (AuthPlane signs its own JWTs for the MCP server, no upstream involvement). ## Single-resource topologies | Topology | Use case | Deep-dive | |---|---|---| | **Agent + single MCP** | One agent, one MCP server, one user — the canonical baseline. | [single-mcp](/topologies/single-mcp) | | **Agent + brokered MCP** | Agent reaches a service backed by an upstream IdP (Google Calendar, GitHub, Slack, Linear). AuthPlane stores the refresh token and vends short-lived access tokens on demand. | [broker-mcp](/topologies/broker-mcp) | | **Backend service + MCP (no user)** | Machine-to-machine — a worker, CI pipeline, or monitoring service acts as itself with a `client_credentials` grant. No consent, no browser. | [m2m-client-credentials](/topologies/m2m-client-credentials) | ## Multi-resource topologies | Topology | Use case | Deep-dive | |---|---|---| | **Direct fanout** | One agent talks to multiple MCPs; the user consents at each MCP separately. Simplest, no infra. | [direct-fanout](/topologies/direct-fanout) | | **Folded resource** | Multiple internal services hidden behind one MCP boundary. AuthPlane sees one resource; the MCP fans out privately. | [folded-resource](/topologies/folded-resource) | | **Client-credentials hop** | A gateway calls hidden infra as itself, dropping user context after the first hop. Second hop is M2M. | [client-credentials-hop](/topologies/client-credentials-hop) | | **MCP gateway → hidden Mint** | Gateway fronts a hidden MCP; AuthPlane issues JWTs to the gateway that carry an `act`-claim chain naming the downstream. | [mcp-gateway-mint](/topologies/mcp-gateway-mint) | | **MCP gateway → broker** | Gateway fronts an upstream-IdP-backed service; AuthPlane vends the upstream bearer via the gateway. | [mcp-gateway-broker](/topologies/mcp-gateway-broker) | ## Identity federation topologies These stack on top of any of the above — they change *how the user authenticates*, not the token path. | Topology | Use case | Deep-dive | |---|---|---| | **OIDC-federated user login** | Users sign in via Google Workspace, Okta, Entra ID, or any OIDC provider. AuthPlane never sees credentials; it still issues the MCP tokens. | [oidc-federated-login](/topologies/oidc-federated-login) | | **Enterprise-Asserted Agent Identity (XAA)** | Your corporate IdP signs a JWT asserting *the agent's identity*. AuthPlane accepts it via JWT Bearer grant (RFC 7523) and mints an MCP token. Skips per-user consent for policy-approved agents. | [enterprise-xaa](/topologies/enterprise-xaa) | ## Composing topologies Real deployments stack shapes. Common combos: - **OIDC-federated login + direct fanout** — users sign in via Okta once, then each agent fans out to MCPs A, B, C with per-MCP consent. - **MCP gateway → broker (Google Calendar) + MCP gateway → mint (internal scheduling API)** — one gateway serves both fronted patterns to different downstream resources. - **Enterprise XAA + MCP gateway → hidden Mint** — corporate-IdP-signed agent identity flows through the `act`-claim chain into an internal API. When in doubt, model each hop as its own topology and stack them. ## What's on the roadmap Not yet shipped — **co-authorization at first `/authorize`**: a single consent screen for multiple resources via repeated `resource=` parameters ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) multi-resource authorization). Until it lands, use direct fanout (sequential consent) or the gateway pattern (single consent). ## Three ways to configure any topology Every topology page shows the same setup in three parallel modes — pick one, stay in it: | Mode | Where | Best for | |---|---|---| | **Admin UI** | `http://localhost:9001/admin/ui/` (React SPA embedded in the AS binary) | Exploring, one-off setup, visual confirmation | | **CLI** | `authserver admin ` | Local development, scripting, single-host operators | | **REST API** | `POST /admin/...` against `:9001` | CI/CD pipelines, infrastructure-as-code, multi-environment provisioning | Auth applies uniformly: the CLI and REST API use the `AUTHPLANE_ADMIN_API_KEY` env var (or YAML `admin.api_key`). The Admin UI uses session login. Only the OIDC federation provider config and the global `xaa.*` toggles are YAML-only at v0.1.x. XAA trusted IdPs, policies, and subject mappings are managed through the admin REST API (`/admin/idps`, `/admin/xaa/policies`, `/admin/xaa/subject-mappings`) — each topology page calls that out where it applies. ## Visual conventions Every topology diagram in these docs uses the same node names: | Node | What it is | |---|---| | `User` | Human user, typically in a browser | | `Agent` | MCP agent — Claude Code, Claude Desktop, Cursor, ChatGPT, custom | | `AS` | AuthPlane authorization server | | `MCP` | A Mint resource (AS signs the token) | | `Broker` | A Broker resource (AS vends an upstream IdP token) | | `IdP` | Upstream identity provider (Google, Okta, Entra) | | `Backend` | Backend service authenticating as itself | | `Gateway` | An MCP that proxies to other resources | Edge labels carry the wire payload: `token bound to `, `grant=client_credentials`, `RFC 8693 exchange`. Subgraphs mark encapsulation boundaries (e.g. "invisible to AS"). ## Related - [Introduction](/introduction) — how AuthPlane fits into your stack - [Quickstart](/quickstart) — the single-mcp topology, running in 10 minutes - [Concepts: Architecture](/concepts/architecture) — what runs inside the AS - [Concepts: Grants & flows](/concepts/grants-and-flows) — the OAuth grants each topology uses --- ## first-mcp-server.mdx --- title: Your first MCP server description: "Walk through the Quickstart snippet line by line — what each SDK call does, and how to extend it." section: Getting started sectionOrder: 1 order: 3 --- # Your first MCP server > **TL;DR** — This page dissects the [Quickstart](/quickstart) snippet. If you copied it and it works, this is where you learn why. If you want to extend it (custom scopes, upstream tokens, per-tool policy), start here. ## What the SDK call does The Quickstart's one-liner setup is not one call — it's a stack. Whichever language you picked, `authplane_mcp_auth` / `authplaneFastMcpAuth` / `authplanemcp.NewAdapter` does the same seven things: 1. **Discovers the authorization server** — `GET {issuer}/.well-known/oauth-authorization-server` per [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414). Result cached in memory with a periodic refresh (default 3600 s). 2. **Fetches the JWKS** — `GET {issuer}/.well-known/jwks.json` per [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517). Cached with a shorter refresh (default 300 s) so key rotation is picked up quickly. 3. **Builds an `AuthplaneResource`** — an in-memory object that knows this resource's URI, its declared scopes, and its inbound DPoP policy. 4. **Wraps the resource in a token verifier** — the object that MCP-framework `authenticate` callbacks call to verify each incoming JWT. 5. **Builds a Protected Resource Metadata document** — the RFC 9728 JSON that will be served **path-scoped** at `/.well-known/oauth-protected-resource/` (for a resource at `http://host/mcp`, that's `/.well-known/oauth-protected-resource/mcp`), listing this resource's URI, its authorization servers, and its supported scopes. 6. **Wraps the underlying OAuth client** so that any `client.exchange(...)` call that raises `ConsentRequiredError` gets translated to MCP JSON-RPC `-32042` `UrlElicitationRequiredError` — the wire format that prompts the MCP client to open a consent URL. 7. **Wires an on-shutdown hook** — the `aclose()` / `Close()` you call in `finally` / `defer` releases the background JWKS refresh task and the httpx / net/http connection pool. None of this touches your request path. Every subsequent JWT validation is a local RSA/ECDSA signature check against the cached JWKS — no round-trip to AuthPlane per request. ## The pieces you need to wire yourself The `**auth` spread / `.authenticate` field / `AuthMiddleware()` handles the token check. What you own on top: ### A tool that requires a scope Scope enforcement happens at two layers: - **Request layer** — set once per adapter. If a request's token does not carry every scope in `requiredScopes` / `enforce_scopes_on_all_requests`, the SDK rejects the request with `403 insufficient_scope` before your tool code runs. - **Per-tool** — call the framework's scope guard inside each handler. This is the granular pattern that lets one tool require `tools/read` and another require `tools/write`. Use both together (as the Quickstart does): request-layer sets the baseline; per-tool enforces the fine-grained rules. str: """Requires a token with scope 'tools/write' — enforced per-call.""" require_scope("tools/write") return f"Wrote: {payload}"`} tsFile="server.ts" ts={`import { requireScopes } from "fastmcp"; server.addTool({ name: "write", description: "Requires a token with scope 'tools/write' — enforced per-call.", parameters: z.object({ payload: z.string() }), canAccess: requireScopes("tools/write"), execute: async ({ payload }) => ({ content: [{ type: "text", text: \`Wrote: \${payload}\` }] }), });`} goFile="main.go" go={`// The Go MCP SDK doesn't ship a per-tool scope guard — check the token // yourself from the request context. Claims are injected by AuthMiddleware. import "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" func writeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { claims, ok := authplanemcp.ClaimsFromContext(ctx) if !ok { return nil, fmt.Errorf("no claims in context") } if !claims.Scopes.Has("tools/write") { return nil, fmt.Errorf("insufficient scope: tools/write required") } // ... your tool logic return &mcp.CallToolResult{}, nil }`} /> Whatever scopes you use in per-tool guards must also appear in the `scopes=[...]` array passed to the adapter — that's what the SDK advertises in the PRM's `scopes_supported`. If a client asks for a scope you never declared, it gets rejected at token mint by the AS. ### Inbound DPoP enforcement — what makes it work Two things must line up: 1. **The AS must issue DPoP-bound tokens.** In the Quickstart you set `AUTHPLANE_DPOP_ENABLED=true`. Any client that presents a `DPoP` header on `/oauth/token` gets a token with a `cnf.jkt` claim binding it to that client's key. 2. **The SDK must verify the proof on every request.** That's `inbound_dpop=InboundDPoPOptions(required=True)` (Python), `inboundDPoP: { required: true }` (TS), `verifier.WithInboundDPoP(verifier.InboundDPoPOptions{Required: true})` (Go). Python has a third requirement: `install_request_context(mcp)`. The official MCP Python SDK's `FastMCP` doesn't expose a middleware seam, so the adapter has to publish the active `Request` on a `ContextVar` before the verifier runs. Without this call, DPoP-bound requests fail closed with `401` — the misconfiguration surfaces immediately rather than as a silent bypass. See the [DPoP guide](/guides/enable-dpop) for the full picture. ### Registering the resource in AuthPlane The SDK advertises your resource via PRM, but AuthPlane also needs to know about it so it can: - Include your scopes in `scopes_supported` on `/.well-known/oauth-authorization-server` - Allow token-exchange calls that name your resource as `audience` - Enforce per-resource policy (upstream token vending, runtime client binding, XAA policies) Register it with the admin CLI or Admin UI: ```bash $ authserver admin resource create \ --slug my-server \ --uri http://localhost:8080/mcp \ --backend-kind mint \ --scopes 'tools/read||Read tools' \ --scopes 'tools/write||Write tools' ``` Or via the Admin API — see [Admin API guide](/guides/admin-api). ## What happens on a real request Following one request end-to-end after everything is wired: ``` 1. MCP client sends: POST /mcp Authorization: DPoP eyJhbGci… DPoP: eyJhbGci… 2. Your SDK's middleware runs: a. Reads bearer + DPoP header b. Fetches signing key from cached JWKS (no network hop) c. Validates JWT signature (RS256 or ES256) d. Validates exp, nbf, iat, iss (== issuer), aud (== resource) e. Validates DPoP proof: - JWT format correct, alg allowed - htu matches your resource URL, htm matches request method - jti not seen before (replay protection) - ath equals SHA-256(access_token) - jkt in access_token cnf matches JWK thumbprint of proof f. Checks token scopes contain every scope in requiredScopes g. Publishes VerifiedClaims on request context 3. Your tool handler runs: a. require_scope("tools/read") passes (already checked, no-op) b. Your logic executes c. Returns response ``` All of steps 2a–2f are local. The only network calls the SDK makes are: (a) the initial metadata + JWKS fetch at startup, (b) periodic JWKS refresh in the background, (c) optional `client.exchange(...)` if you use token-exchange in your handler. ## Extending the minimum Common next steps and where to go for each: - **Vend upstream tokens (GitHub, Slack, Google) from a tool** → [Token Vault guide](/guides/token-vault) — uses `client.exchange()` with a broker resource. - **Federate login to your IdP** → [OIDC Federation guide](/guides/federate-idp). - **Enterprise-managed agent identity (skip per-user consent)** → [Enterprise-Managed Auth](/guides/xaa). - **Custom fetch settings, private CAs, SSRF policy** — see the per-SDK page for your language. - **Structured logging, Prometheus metrics, OTEL traces** → [Monitoring guide](/guides/monitoring). ## Troubleshooting - **`401 invalid_token` on the first request** — your token was issued for a different `aud` than your resource URI. Check that the client sent `resource=http://localhost:8080/mcp` on the `/oauth/authorize` and `/oauth/token` calls (RFC 8707 resource indicator). AuthPlane binds `aud` from that parameter. - **`403 insufficient_scope` on a tool that has `require_scope`** — the token's `scope` claim doesn't contain the required value. Check what the client requested and what AuthPlane granted (admin UI → Issuances). - **`401 invalid_dpop_proof` even though the client sent a DPoP header** — Python only: you forgot `install_request_context(mcp)`. Any language: the `htu` in the proof doesn't match the request URL. Reverse proxies that rewrite Host or scheme are the usual cause; see [Enable DPoP](/guides/enable-dpop). - **Background task not stopping cleanly on shutdown** — you didn't `await auth.aclose()` (Python) / `defer adapter.Close()` (Go) / `await auth.client.close()` (TS). More at [Common errors](/troubleshooting/common-errors) and [Debugging checklist](/troubleshooting/debugging). ## Related - [Quickstart](/quickstart) — the snippet this page walks through - [Choose your topology](/choose-your-topology) — decide the deployment shape - [Concepts: Grants & flows](/concepts/grants-and-flows) — auth-code, refresh, client-credentials, token-exchange, jwt-bearer - [SDK reference — Python](/sdks/python) · [TypeScript](/sdks/typescript) · [Go](/sdks/go) --- ## introduction.mdx --- title: Introduction description: "What AuthPlane is, what problem it solves, and how to read these docs." section: Getting started sectionOrder: 1 order: 1 --- # Introduction > **TL;DR** — AuthPlane is the authorization layer for the Model Context Protocol. It ships as a self-hosted OAuth 2.1 authorization server (a managed cloud offering is on the way) that runs alongside your MCP server and owns the entire token path, so your server just validates JWTs locally. These docs cover a runnable Quickstart, the concepts you need before configuring, per-SDK guides, deployment topologies, and every reference you'll want. ## What AuthPlane is AuthPlane is a complete OAuth 2.1 authorization server for the [Model Context Protocol](https://modelcontextprotocol.io/) — token issuance, discovery, IdP federation, delegation, upstream-token vault, agent identity, and an admin UI — shipped today as one self-contained binary you run next to your MCP server, with a managed cloud offering (same product, same guarantees) landing shortly. Concretely, AuthPlane owns everything the MCP authorization spec asks for: it tells the client where to send the user for consent, mints access tokens bound to a specific resource server, and enforces proof-of-possession on every call. It holds the OAuth endpoints (`/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/revoke`, `/oauth/introspect`), signs the tokens, and publishes the metadata your MCP clients discover. Your MCP server validates the tokens locally against the AuthPlane JWKS. No per-request round-trip. No library that owns your request handler. ## The problem it solves MCP puts an authorization layer between agents and the tools they call. The spec is a stack of RFCs — OAuth 2.1 (auth code + PKCE-S256), Dynamic Client Registration (RFC 7591), Protected Resource Metadata (RFC 9728), Resource Indicators (RFC 8707), DPoP proof-of-possession (RFC 9449), Token Exchange for delegation (RFC 8693), and more. Each one has to be implemented correctly *and* they all have to fit together coherently — the compliance surface is what makes this hard, not any single RFC. You have two options: - **Roll your own** — implement 16 RFCs correctly, run the compliance test suites, keep up with revisions. Real cost measured in engineer-quarters. - **Adopt AuthPlane** — spec-compliant token issuance, purpose-built for MCP, licensed under AGPL-3.0. Deploy the self-hosted binary in your perimeter today, or move to the managed offering when it lands — the wire protocol is identical. ## How AuthPlane fits into your stack ```mermaid flowchart TD Client["MCP client
(Claude, Cursor, Inspector)"] AS["AuthPlane AS
:9000 · :9001"] MCP["Your MCP server + AuthPlane SDK
· serves PRM (RFC 9728)
· verifies JWTs locally"] Client -->|OAuth authorize / token| AS AS -->|access token| Client Client -->|bearer / DPoP proof| MCP MCP -->|"JWKS (cached by SDK)"| AS ``` The client discovers the protected-resource metadata (PRM) directly from your MCP server — the AuthPlane SDK serves the RFC 9728 document off your resource. The client then obtains a token from AuthPlane once. Your MCP server validates each incoming request against the JWKS the SDK caches from AuthPlane. Auth is out of band. Your request handler stays yours. Your IdP (Google Workspace, Okta, Entra ID, Auth0, or any OIDC provider) still authenticates the humans — AuthPlane federates upstream to preserve that. What AuthPlane owns is agent-facing authorization: token issuance, scope enforcement, delegation via [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693), sender-constrained tokens via [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449), and the vault that holds refresh tokens for the third-party APIs your agents call. ## How these docs are organized Every page follows the same shape — **TL;DR** at the top, then **What it does** / **When you need this** / **Quick start** / **How it works** / **Configuration reference** / **Troubleshooting** / **Related**. You can drop in anywhere and know where you are. - **[Getting started](/introduction)** — introduction (this page), [Quickstart](/quickstart) for the ten-minute run, [Your first MCP server](/first-mcp-server) for what the SDK snippet actually does, [Choose your topology](/choose-your-topology) to pick a deployment shape. - **Concepts** — the mental model before you touch config: architecture, resource servers and PRM, grants and flows, the token vault, delegation chains, DPoP, XAA, agent identity. - **SDKs** — one page per language ([Python](/sdks/python), [TypeScript](/sdks/typescript), [Go](/sdks/go)), each covering the adapters for the major MCP frameworks. - **Guides** — task-focused, step-by-step: federate to your IdP, enable DPoP end-to-end, wire the token vault, set up XAA, monitor, admin API. - **Deployment topologies** — every deployment shape AuthPlane supports, with named-component diagrams and wire-level flow sequences. - **Operate** — Docker Compose, Kubernetes (Helm), standalone binary, Vault Transit signing, backup and upgrade. - **Reference** — [configuration schema](/reference/configuration), OpenAPI specs, RFC compliance table, error catalog, metrics catalog, CLI reference. - **Security** — threat model, token design, DPoP deep-dive, key management, vulnerability reporting. - **FAQ & Troubleshooting** — common questions, debugging checklist, where to get help. ## Where to start next - **You have an MCP server and want auth on it fast** → [Quickstart](/quickstart), then [Your first MCP server](/first-mcp-server). - **You're evaluating for adoption** → read the [Concepts](/concepts/architecture) pages end to end, then browse [Deployment topologies](/topologies/single-mcp). - **You're planning production** → [Operate overview](/operate/overview), [Security](/security/threat-model), [Configuration reference](/reference/configuration). --- AuthPlane is at **v0.1.x**. It is validated end-to-end with Claude Desktop, Claude Code, and MCP Inspector; Cursor and VS Code support is tracked separately (see [RFC compliance](/reference/rfc-compliance)). It is not SOC 2 certified — self-hosted software is your organization's certification. The code is [AGPL-3.0](https://github.com/authplane/authserver/blob/main/LICENSE) and reviewable; security disclosures go through [GitHub Private Vulnerability Reporting](https://github.com/authplane/authserver/security/advisories/new) (see [Reporting vulnerabilities](/security/reporting-vulnerabilities)). --- ## quickstart.mdx --- title: Quickstart description: "Running OAuth 2.1 + MCP authorization server with a live SDK-protected resource in ten minutes." section: Getting started sectionOrder: 1 order: 2 --- # Quickstart > **TL;DR** — Three commands: run the AuthPlane binary, install the SDK for your language, point an MCP client at your server. No account, no signup. SQLite and auto-generated keys make first run zero-config. ## Prerequisites - Docker (or the [standalone binary](/operate/standalone) if you prefer no container) - One of: Python 3.11+, Node.js 20+, or Go 1.24+ (Go 1.25+ if you use `authplanemcp`) - An MCP client for testing — [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is enough; Claude Desktop, Cursor, or VS Code all work too ## 1. Run the AuthPlane server Generate an admin API key and a session secret, then start the container: ```bash $ export AUTHPLANE_ADMIN_API_KEY="$(openssl rand -hex 32)" $ export AUTHPLANE_SESSION_SECRET="$(openssl rand -hex 32)" $ docker run -p 9000:9000 -p 9001:9001 \ -e AUTHPLANE_ADMIN_API_KEY \ -e AUTHPLANE_SESSION_SECRET \ -e AUTHPLANE_DPOP_ENABLED=true \ -v authserver-data:/data \ authplane/authserver:latest serve ``` You now have OAuth endpoints live on `http://localhost:9000` and the Admin UI on `http://localhost:9001/admin/ui/`. Open the Admin UI and paste the value of `$AUTHPLANE_ADMIN_API_KEY` when prompted — that's the key you just generated, not something the binary prints back. > **Note on defaults** — SQLite storage lives under `/data`, signing keys auto-generate on first boot, and there's no config file. `AUTHPLANE_DPOP_ENABLED=true` opts the server into DPoP proof-of-possession so the SDK sections below can enforce it inbound; leave it off for a bearer-only setup. Verify the AS is up: ```bash $ curl -s localhost:9000/.well-known/oauth-authorization-server | jq .issuer "http://localhost:9000" ``` ## 2. Protect your MCP server Install the AuthPlane SDK for your language and wire it in. Pick a tab — your choice sticks across every page in these docs. None: auth = await authplane_mcp_auth( issuer="http://localhost:9000", resource="http://localhost:8080/mcp", scopes=["tools/read"], enforce_scopes_on_all_requests=True, inbound_dpop=InboundDPoPOptions(required=True), dev_mode=True, # allow http://localhost; remove in production ) mcp = FastMCP("my-server", port=8080, json_response=True, **auth) install_request_context(mcp) # required for inbound DPoP @mcp.tool() async def read(query: str) -> str: """Return the query back — trivial demo tool.""" require_scope("tools/read") return f"You asked: {query}" try: await mcp.run_streamable_http_async() finally: await auth.aclose() asyncio.run(main())`} tsInstall="npm install @authplane/sdk @authplane/fastmcp fastmcp zod" tsFile="server.ts" ts={`import { FastMCP, requireScopes } from "fastmcp"; import { authplaneFastMcpAuth, type AuthplaneFastMcpSession } from "@authplane/fastmcp"; import { z } from "zod"; const auth = await authplaneFastMcpAuth({ issuer: "http://localhost:9000", resource: "http://localhost:8080/mcp", scopes: ["tools/read"], requiredScopes: ["tools/read"], // request-layer enforcement inboundDPoP: { required: true }, devMode: true, // allow http://localhost; remove in production }); const server = new FastMCP({ name: "my-server", version: "1.0.0", authenticate: auth.authenticate, oauth: auth.oauth, }); server.addTool({ name: "read", description: "Return the query back — trivial demo tool.", parameters: z.object({ query: z.string() }), canAccess: requireScopes("tools/read"), execute: async ({ query }) => ({ content: [{ type: "text", text: \`You asked: \${query}\` }] }), }); await server.start({ transportType: "httpStream", httpStream: { port: 8080, endpoint: "/mcp" }, });`} goInstall="go get github.com/authplane/go-sdk/mcp" goFile="main.go" go={`package main import ( "context" "fmt" "net/http" "github.com/authplane/go-sdk/core/resource/verifier" "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { ctx := context.Background() adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{ Issuer: "http://localhost:9000", Resource: "http://localhost:8080/mcp", Scopes: []string{"tools/read"}, DevMode: true, // allow http://localhost; remove in production VerifierOptions: []verifier.Option{ verifier.WithInboundDPoP(verifier.InboundDPoPOptions{Required: true}), }, }) if err != nil { panic(err) } defer adapter.Close() server := mcp.NewServer( &mcp.Implementation{Name: "my-server", Version: "1.0.0"}, nil, ) handler := mcp.NewStreamableHTTPHandler( func(_ *http.Request) *mcp.Server { return server }, nil, ) http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) http.Handle("/mcp", adapter.AuthMiddleware(handler)) fmt.Println("MCP server listening on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } }`} /> Start the server: ```bash $ python server.py # or: node server.js / go run main.go ``` What this snippet delivers, verified against the SDK source: **JWT validation** (against AuthPlane's JWKS, cached and rotated), **RFC 8414 metadata discovery**, **RFC 9728 Protected Resource Metadata publication** with `scopes_supported` advertised, **RFC 9449 DPoP inbound enforcement** (tokens without a valid proof get `401 WWW-Authenticate: DPoP error="invalid_dpop_proof"`), and **per-tool scope enforcement** via the framework's own scope guard. ## 3. Connect a client Point Claude Desktop, Cursor, or VS Code at `http://localhost:8080/mcp`. Discovery, dynamic client registration (RFC 7591), and consent happen automatically. For a fast sanity check without configuring a full client, use MCP Inspector: ```bash $ npx @modelcontextprotocol/inspector http://localhost:8080/mcp ``` The Inspector will: 1. Fetch the PRM document at `http://localhost:8080/.well-known/oauth-protected-resource/mcp` (published by the SDK — path-scoped per RFC 9728 §3.1) 2. Discover the AS at `http://localhost:9000` via the PRM's `authorization_servers` field 3. Fetch the AS metadata at `http://localhost:9000/.well-known/oauth-authorization-server` 4. Register itself dynamically at `/oauth/register` 5. Kick off the authorization code + PKCE flow 6. Present a token bound to a DPoP key and call your `read` tool ## Done. What you have now - An AuthPlane AS on `:9000` issuing DPoP-bound, scope-scoped JWTs - Admin UI on `:9001/admin/ui/` showing clients, users, resources, grants, issuances, signing keys - An MCP server on `:8080/mcp` that validates every request against AuthPlane's JWKS and enforces `tools/read` scope - A registered resource in AuthPlane (via the PRM advertisement) and a registered client (via DCR) ## Next steps - **Understand what just happened** — [Your first MCP server](/first-mcp-server) walks each line of the snippet above. - **Pick a topology** — [Choose your topology](/choose-your-topology) if you're going beyond one agent + one MCP server. - **Federate to your IdP** — [OIDC Federation guide](/guides/federate-idp). - **Move to Postgres and go to prod** — [Operate overview](/operate/overview). ## Troubleshooting - **401 on the very first tool call, no DPoP header on request** — your MCP client didn't send a DPoP proof, but the SDK is configured with `required: true`. Either the client doesn't support DPoP (some early MCP clients don't; see [compatibility matrix](/reference/rfc-compliance)) or you're testing with a plain `curl`. Remove the `inbound_dpop` / `inboundDPoP` / `WithInboundDPoP` line to accept bearer tokens. - **`invalid_dpop_proof` on every DPoP request** — you enabled inbound DPoP in the SDK but forgot `install_request_context(mcp)` (Python only). See [Enable DPoP end-to-end](/guides/enable-dpop). - **PRM 404 when your client hits `/.well-known/oauth-protected-resource`** — the RFC 9728 doc is served **path-scoped**: for a resource at `http://host/mcp`, PRM lives at `/.well-known/oauth-protected-resource/mcp` (the bare `/.well-known/oauth-protected-resource` returns 404 even when the handler is mounted). If the path-scoped URL is also 404, the SDK didn't mount the PRM handler. In Go, that's the `http.Handle(adapter.WellKnownPRMPath(), ...)` line. In Python and TS the adapter wires it automatically via the `auth` result spread. - **Inspector connects but sees no tools** — the URL is wrong; make sure it ends in `/mcp` (or whatever `endpoint` you configured), not just the host. - **Anything else** — [Debugging checklist](/troubleshooting/debugging). --- ## concepts/agent-identity.mdx --- title: Agent identity description: "AuthPlane extensions — agent_id and agent_chain claims in every JWT. Which agent is calling your MCP server + the full agent chain in multi-agent flows." section: Concepts sectionOrder: 2 order: 8 --- # Agent identity > **TL;DR** — AuthPlane extends the standard OAuth token with two claims: **`agent_id`** — the outermost agent's client_id when it's registered with `is_agent: true`, and **`agent_chain`** — an ordered list of agent client_ids extracted from the `act` chain (capped at 8). Your MCP server can gate per-agent quotas, filter by agent in audit queries, and enforce per-agent policy without walking the nested RFC 8693 `act` tree. Not standardized — AuthPlane extension. Set an OAuth client's `is_agent: true` and (optional) `agent_description` at registration to have it flagged as an agent. ## The problem RFC 8693 gives you an `act` chain for delegation (see [Concepts: Delegation & act-chain](/concepts/delegation-act-chain)). It's rich — the full delegation tree is reconstructable — but walking it in every MCP server tool handler is annoying: ``` if token.act && token.act.sub == "agent-A" { ... } else if token.act.act && token.act.act.sub == "agent-A" { ... } ``` AuthPlane emits flat claims that pre-compute the useful bits. ## `agent_id` claim Set to the **outermost** agent's client_id when it's registered with `is_agent: true`. In the vast majority of flows, that's the client who initiated the current OAuth call. Example — direct agent call (user auth-code flow with agent-A as the client): ```json { "sub": "user-42", "aud": "https://mcp.example.com/mcp", "client_id": "agent-A", "agent_id": "agent-A" } ``` Example — delegated call (agent-A exchanged the user token, sub-agent B is now holding): ```json { "sub": "user-42", "aud": "https://downstream.example.com", "client_id": "agent-B", "act": { "sub": "agent-B", "actor_type": "agent", "act": { "sub": "agent-A" } }, "agent_id": "agent-B" } ``` `agent_id` names the current actor — same as `act.sub` when there's an `act` chain, same as `client_id` otherwise. Precomputed for convenience. ## `agent_chain` claim Ordered list of every `client_id` in the `act` chain, capped at 8 entries. **First entry = originator (root of the chain), last entry = current actor.** AuthPlane walks the nested `act` structure once and then reverses so the list reads in causal order. Example — chained delegation A delegates to B, B delegates to C (C is now calling your MCP server): ```json { "sub": "user-42", "act": { "sub": "agent-C", "act": { "sub": "agent-B", "act": { "sub": "agent-A" } } }, "agent_id": "agent-C", "agent_chain": ["agent-A", "agent-B", "agent-C"] } ``` Your MCP server can filter/log/gate on the chain without recursion: ```python if token.agent_chain[0] == "agent-A": # This request originated from agent-A (the root of the delegation chain) ``` ## Registering a client as an agent At registration time, set `is_agent: true`: ```bash authserver admin client create \ --name my-research-agent \ --agent \ --agent-description "Research assistant that reads GitHub + Notion" \ --grant-types authorization_code,refresh_token,urn:ietf:params:oauth:grant-type:token-exchange \ --scopes 'tools/read||Read tools' ``` REST: ```json POST /admin/clients { "client_name": "my-research-agent", "is_agent": true, "agent_description": "Research assistant that reads GitHub + Notion", "grant_types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"], "scope": "tools/read" } ``` **`agent_description`** — max 255 chars — shown on consent screens ("Do you want to grant *research assistant that reads GitHub + Notion* access to your data?"). Optional but recommended for user-facing agents. DCR clients can also self-register as agents by including `"agent": true` in the registration payload. ## When `agent_id` and `agent_chain` are present - `agent_id` present when the outermost actor's client has `is_agent: true`. - `agent_chain` present when the **issuing (outermost) client** is registered as an agent; in that case it contains **every hop** in the delegation chain, including any non-agent service clients. There is no filtering by `is_agent` on intermediate hops. **Absent** when the flow is entirely non-agent — machine-to-machine service calls, admin operations, plain user auth-code with a non-agent client. Your MCP server should treat missing claims as "not an agent-mediated call". ## Optional: `agents.enable_jwks_listing` When `agents.enable_jwks_listing: true` (config), AuthPlane publishes a JWKS document per agent at `/.well-known/agents/{agent_id}.json` — useful for third-party verification of agent-issued signatures if agents sign anything themselves. Off by default; enable only if agents actually publish their own JWKs. ## AS metadata advertisement `/.well-known/oauth-authorization-server` includes: ```json { "authplane_agent_identity_supported": true, ... } ``` Clients can key off this to know AuthPlane emits `agent_id` / `agent_chain` claims. ## What downstream services should do with agent identity - **Per-agent rate limits** — throttle at the MCP layer by `agent_id`. - **Per-agent quotas** — count tool invocations per (user, agent_id) pair for billing / usage-based limits. - **Enhanced audit** — log `agent_id` + `agent_chain` on every request; enables "show me every action this agent has ever taken across our fleet" queries. - **Agent-scoped policy** — deny certain tools based on agent identity (e.g., "financial writeback tools not allowed for agents in `agent_chain`"). **What NOT to do:** don't authorize solely on `agent_chain` inner entries. Same rule as RFC 8693 §4.1 ¶6 — inner-hop identities (originator + intermediaries) are informational, not authoritative. Only `agent_id` (the current actor, the last entry in the chain) is trustworthy for access-control decisions. ## Multi-agent example User asks orchestrator agent → orchestrator delegates to research agent → research agent hits summarizer: ```json Token at summarizer: { "sub": "user-42", "client_id": "agent-summarizer", "act": { "sub": "agent-summarizer", "act": { "sub": "agent-research", "act": { "sub": "agent-orchestrator" } } }, "agent_id": "agent-summarizer", "agent_chain": ["agent-orchestrator", "agent-research", "agent-summarizer"], "scope": "tools/summarize" } ``` Summarizer knows: - Currently holding: `agent-summarizer` (authoritative for authorization). - Delegation chain: orchestrator started, delegated to research, who delegated to summarizer. - Ultimate principal: user-42. Everything reconstructable from the token alone. No external correlation needed. ## Related - [Concepts: Delegation & act-chain](/concepts/delegation-act-chain) — the RFC 8693 `act` semantics that `agent_id` / `agent_chain` derive from - [Concepts: Cross-App Access (XAA)](/concepts/xaa) — enterprise-asserted agent identity via JWT Bearer - [Reference: RFC compliance → Agent identity claims](/reference/rfc-compliance#agent-identity-claims) - [Guides: Admin API → Register a client](/guides/admin-api#register-an-mcp-client-manual-not-via-dcr) — how to register with `--agent` flag - [Full source — `authserver/docs/agents/agent-identity.md`](https://github.com/authplane/authserver/blob/main/docs/agents/agent-identity.md) --- ## concepts/architecture.mdx --- title: Architecture description: "Hexagonal layers, request flow, domain model, storage, and observability — what actually runs when you start the AuthPlane binary." section: Concepts sectionOrder: 2 order: 1 --- # Architecture > **TL;DR** — AuthPlane is one Go binary with two HTTP servers: `:9000` for public OAuth traffic, `:9001` for the Admin API and Admin UI. Inside, it's a hexagonal (ports & adapters) design: HTTP handlers call input ports, services run business logic, output ports abstract storage and key material, adapters plug in SQLite/Postgres/Keyfile/Vault. No framework, stdlib `net/http`. This page is the mental model — one read makes the rest of the docs make sense. ## What runs where ```mermaid flowchart TD Client["MCP Client
(Claude Desktop, Cursor, …)"] Browser["User Browser"] subgraph AS9000["authserver :9000"] Discovery["Discovery
AS meta
JWKS"] OAuth["OAuth
Authorize
Token
Introspect
Revoke"] LoginConsent["Login/Consent"] Services["Services
Authorize · Token · DCR/CIMD · Consent
UserAuth · JWKS · Admin · Audit
Introspection · Connect · BrokerIssuer
TokenExchange · JWTBearer · AgentIdent."] Store["Store
SQLite
Postgres
AES enc"] Signing["Signing
Keyfile
Postgres
Vault XT"] Discovery --> Services OAuth --> Services LoginConsent --> Services Services --> Store Services --> Signing end subgraph AS9001["authserver :9001"] Admin["Admin API (REST) + Admin UI (React SPA, embedded)
Clients · Users · Resources · Providers · Grants
Issuances · Signing Keys · Audit · System"] end Client -->|"1. Discover PRM on your MCP server
2. Discover AS metadata
3. Register client (DCR or CIMD)
4. Login
5. Consent
6. Exchange code for tokens"| Browser Client --> AS9000 Browser --> AS9000 ``` - **`:9000`** — public OAuth endpoints (`/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/revoke`, `/oauth/introspect`), discovery (`/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration`, `/.well-known/jwks.json`), health, and the login/consent HTML pages. RFC 9728 protected-resource metadata is **not** served here — the SDK on your MCP server publishes it at `/.well-known/oauth-protected-resource/`. - **`:9001`** — Admin REST API under `/admin/*` and the built-in React Admin UI at `/admin/ui/`. Requires `AUTHPLANE_ADMIN_API_KEY` (API) or session login (UI). Not intended for public exposure — front with a reverse proxy or Kubernetes NetworkPolicy in production. ## Hexagonal architecture Ports & adapters, strict dependency rules — no framework, stdlib `net/http`. ```mermaid flowchart TD L1["api/http/ · api/admin/ · web/admin/
OAuth handlers · Admin REST+UI · React SPA
Primary adapters (inbound)"] L2["internal/ports/input/
What the world asks us to do
Input ports (interfaces)"] L3["internal/services/
Orchestrates domain operations
Business logic"] L4["internal/ports/output/
What we need from the world
Output ports (interfaces)"] L5["internal/adapters/sqlite/ keyfile/ oidc/
internal/adapters/postgres/ cimd/
internal/adapters/aesmaster/ connector/ hcvault/
Secondary adapters (outbound)"] L6["internal/domain/
Pure business types — stdlib only
Domain entities + errors"] L1 --- L2 --- L3 --- L4 --- L5 --- L6 ``` ### Dependency rules | Package | Can import | Cannot import | |---|---|---| | `internal/domain/` | Go stdlib, `gofrs/uuid` | Everything else | | `internal/ports/` | `domain/` | `adapters/`, `services/`, `config/` | | `internal/services/` | `ports/`, `domain/`, `crypto/` | `adapters/` directly | | `api/` (handlers) | `ports/input/`, `domain/`, `config/` | `adapters/`, `services/` | | `internal/adapters/` | `ports/output/`, `domain/` | Other adapters | | `cmd/` | Everything | — | Only `cmd/authserver/serve.go` is allowed to know about concrete adapters. It's the orchestrator: it constructs adapters, wires them into services through output ports, and hands the services to HTTP handlers through input ports. Handlers never import services directly. Why this matters: swapping SQLite for Postgres is a one-line config change and a different adapter registration in `serve.go`. Business logic never had to know which storage was underneath. ## Request flow An authorization-code + PKCE request end to end: ```mermaid sequenceDiagram participant Client participant Handler as api/http participant Service as internal/services participant Store as SQLite adapter participant Crypto as crypto/ participant Signing as keyfile / Vault Transit Client->>Handler: 1. GET /oauth/authorize?client_id=…&code_challenge=…&resource=…
oauthHandler (api/http) Handler->>Service: AuthorizePort.StartAuthorization (input port)
AuthorizeService Service->>Store: ClientStore.GetByID (output port → SQLite adapter) Service->>Store: SessionStore.Create (output port → SQLite adapter) Handler-->>Client: 302 to /login Client->>Handler: 2. POST /login (email + password)
loginHandler Handler->>Service: UserAuthPort.Authenticate
UserAuthService Service->>Store: UserStore.GetByEmail (output port → SQLite adapter) Service->>Crypto: bcrypt.Compare Handler-->>Client: 302 to /consent Client->>Handler: 3. POST /consent (approve)
consentHandler Handler->>Service: ConsentPort.GrantConsent
ConsentService Service->>Store: ConsentStore.Create (output port → SQLite adapter) Handler-->>Client: 302 back to /oauth/authorize → 302 to client with ?code=… Client->>Handler: 4. POST /oauth/token (code + code_verifier)
oauthHandler Handler->>Service: TokenPort.ExchangeCode
TokenService Service->>Store: SessionStore.ConsumeAuthCode (atomic, output port → SQLite adapter) Service->>Crypto: PKCE verify (crypto/) Service->>Signing: JWT sign (crypto/ → keyfile or Vault Transit adapter) Service->>Store: TokenStore.Create (output port → SQLite adapter) Handler-->>Client: 200 { access_token, refresh_token } ``` Every subsequent request from the MCP client presents `Authorization: Bearer ` (or `Authorization: DPoP ` + a `DPoP` proof header) directly to your MCP server. AuthPlane is out of the request path from here on — your server validates the JWT locally against the cached JWKS. ## Domain model Every entity is a pure Go type in `internal/domain/`. Cross-domain imports are forbidden — `domain/client` cannot import `domain/token`. | Entity | Package | Purpose | |---|---|---| | `Client` | `domain/client/` | OAuth client, with DCR/CIMD state machine | | `User` | `domain/user/` | Local user (email/password or federated OIDC) | | `TokenFamily` | `domain/token/` | Groups refresh tokens for reuse detection | | `RefreshToken` | `domain/token/` | Individual refresh token in a family | | `AuthSession` | `domain/session/` | In-flight authorization (code + PKCE state) | | `Grant` | `domain/consent/` | User's consent decision for a client + scopes | | `AuditEvent` | `domain/audit/` | Security audit log entry | | `Resource` | `domain/resource/` | Unified Mint/Broker resource (`resources` table) | | `BrokerProvider` | `domain/resource/` | Upstream OAuth provider (`broker_providers`) | | `ConsentGrant` | `domain/resource/` | Per-(user, agent, resource) consent attestation | | `BrokerGrant` | `domain/resource/` | Per-(user, provider) upstream grant (encrypted refresh) | | `Issuance` | `domain/resource/` | Forensic audit row for every Mint or Broker issuance | Domain errors live in a single file — `internal/domain/errors.go`. Each carries an OAuth error code for wire-level mapping: | Error | OAuth code | Meaning | |---|---|---| | `ErrInvalidGrant` | `invalid_grant` | Expired code, wrong verifier, or refresh reuse | | `ErrInvalidClient` | `invalid_client` | Unknown client, wrong secret | | `ErrInvalidScope` | `invalid_scope` | Scope not declared on the target resource server | | `ErrCodeConsumed` | `invalid_grant` | Auth code replay | | `ErrFamilyRevoked` | `invalid_grant` | Refresh token theft detected | | `ErrInvalidPKCE` | `invalid_grant` | PKCE verification failed | | `ErrRateLimited` | `slow_down` | Too many requests | The full catalog is in [Reference: Errors](/reference/errors). ## Unified Resource model Every resource AuthPlane speaks to is one row in the `resources` table, discriminated by `backend_kind`: - **`mint`** — AuthPlane issues an AS-signed JWT for a downstream MCP server. The historical "resource server" path. Used by [single-mcp](/topologies/single-mcp), [direct-fanout](/topologies/direct-fanout), [folded-resource](/topologies/folded-resource), and the mint variant of gateway topologies. - **`broker`** — AuthPlane vends an upstream-provider access token via [RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693), gated by the three-bound consent model. Used by [broker-mcp](/topologies/broker-mcp) and the broker variant of gateway topologies. `BrokerProvider` rows define the upstream OAuth providers; one Broker resource references one provider via `broker_provider_id`. The `BrokerProtocol` output port is satisfied by adapters at `internal/brokerproto/{oauth,api_key,service_account}`. ### Connect flow (user-facing) User goes to `/connect/{provider}` → AuthPlane runs an OAuth handshake with the upstream provider → callback stores an encrypted row in `broker_grants`. That row is what powers subsequent token vending. ### Exchange flow (MCP server-facing) Your MCP server calls `POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` and `resource=`. AuthPlane dispatches to `BrokerIssuer`, which enforces three bounds: ``` requested_scopes ⊆ consent_grants.scopes (per-agent attestation) ⊆ broker_grants.scopes_granted (per-provider grant) ``` If any bound fails, AuthPlane responds with `error=consent_required` and a `consent_url` — either `/connect/{provider}` (upstream re-auth needed) or `/authorize?resource=…` (AS-side re-consent needed). The SDK translates this into an MCP JSON-RPC `-32042` `UrlElicitationRequiredError` so your tool code doesn't have to. Upstream refresh grants are encrypted at rest with AES-256-GCM or HashiCorp Vault Transit — never plaintext on disk. See [Concepts: Token Vault](/concepts/token-vault) and [Guides: Wire up the Token Vault](/guides/token-vault). ## Storage Both backends implement the same output port interfaces — the storage driver is selected at startup via config. **SQLite (default)** — Pure Go via `modernc.org/sqlite` (no CGO). WAL mode enabled by default for concurrent reads. Recommended for single-instance deployments. Data lives under `/data` in the container. **PostgreSQL** — via `jackc/pgx/v5`. Required for multi-instance HA. Migrations managed by `authserver migrate`. Uses `LISTEN/NOTIFY` for cross-instance signing-key rotation. Storage details, migrations, and backup procedures live in [Operate](/operate/overview). ## Observability Every request is instrumented: - **Structured logs** (`slog`) with `trace_id`, `span_id`, `request_id` on every line - **OpenTelemetry traces** (optional) spanning HTTP → service → adapter - **Prometheus metrics** on `/metrics` — token issuance, auth denials, latency, DPoP proofs validated/rejected, key rotations, JWKS cache hit rate, and more Complete list in [Guides: Monitoring](/guides/monitoring) and [Reference: Metrics](/reference/metrics-and-cli). ## Docker packaging The production image uses a multi-stage build: 1. `golang:1.25-alpine` — compiles the binary with `CGO_ENABLED=0` 2. `gcr.io/distroless/static-debian12:nonroot` — runtime (no shell, no package manager) Runs as UID 65534 (nonroot), exposes ports `9000` and `9001`, weighs under 50 MB compressed. Same image works from local `docker run` to production Kubernetes. ## Related - [Concepts: Grants & flows](/concepts/grants-and-flows) — the OAuth grants that ride on top of this architecture - [Concepts: Resource servers & PRM](/concepts/resource-servers-prm) — how your MCP server is modeled - [Reference: Configuration](/reference/configuration) — every knob exposed by every layer - [Guides: Monitoring](/guides/monitoring) — turning observability on - [Security: Threat model](/security/threat-model) — trust boundaries and 16 named threats --- ## concepts/delegation-act-chain.mdx --- title: Delegation & act-chain description: "RFC 8693 act-claim semantics — how AuthPlane records every hop when one agent calls another that calls a third, so the full delegation chain is reconstructable from any exchanged token." section: Concepts sectionOrder: 2 order: 5 --- # Delegation & act-chain > **TL;DR** — When agent A calls agent B that calls agent C on behalf of user U, AuthPlane records the full chain in the token's `act` claim per RFC 8693 §4.1. `sub` stays as U (the original principal); `act.sub` names the current actor; `act.act.sub` names the actor before them. Full chain reconstructable from the token alone — no external correlation needed. AuthPlane caps chain depth (`token_exchange.max_chain_depth`, default 5) to prevent runaway nesting. Only the outermost actor is authoritative for access control per §4.1 ¶6 — inner-hop metadata is audit-only. ## The problem Multi-agent flows lose accountability. Agent A hands work to agent B who fans out to C and D. Downstream services see a token — whose token, exactly? U's? A's? B's? Auditing "who actually made this call" requires correlating across N systems and hoping the correlation IDs match. `act` claim solves this: the token IS the audit trail. Any downstream service can read the token and reconstruct the delegation chain. ## Simple delegation User U's agent A wants to hand work to sub-agent B: ``` 1. A holds a token: sub=user-42 2. A → AS POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token= subject_token_type=urn:ietf:params:oauth:token-type:access_token resource=https://downstream.example.com scope=tools/read (optional: actor_token=<...> + REQUIRED actor_token_type) 3. AS mints a delegated token — the actor identity is the *authenticated client* on this token endpoint call (RFC 8693 §4.1 Figure 6). Passing actor_token is only needed when the client wants to override that. ``` The issued token: ```json { "sub": "user-42", // preserved from subject_token "aud": "https://downstream.example.com", "scope": "tools/read", "act": { "sub": "agent-B", // whoever's acting on user's behalf "actor_type": "agent" } } ``` - `sub` = original user (audit trail says "this ultimately serves user-42"). - `act.sub` = the acting agent (audit trail says "agent-B is the one actually holding this token"). - `act.actor_type` = "agent" or "service" — hint at whether the actor is a human-serving agent or a machine. Downstream sees the token, knows: request is on behalf of user-42, currently held by agent-B. ## Chained delegation (A → B → C) Agent B now hands to sub-agent C: ``` 1. B holds the delegated token: sub=user-42, act.sub=agent-B 2. B → AS POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token= resource=https://downstream.example.com client_id=agent-C (or whoever's initiating) ``` The issued token now has TWO levels of `act`: ```json { "sub": "user-42", "act": { "sub": "agent-C", // outermost — current actor "actor_type": "agent", "act": { "sub": "agent-B", // previous actor "actor_type": "agent" } } } ``` Reading: - `sub = user-42` — this call serves user-42. - `act.sub = agent-C` — agent-C is holding this token right now. - `act.act.sub = agent-B` — agent-C got the token from agent-B, who was acting on user-42's behalf. Full chain: `user-42 → agent-B → agent-C`. Reconstructable from the token alone. ## Only the outermost actor is authoritative Per RFC 8693 §4.1 ¶6, downstream services **MUST** use only top-level claims + the *outermost* `act.sub` for access-control decisions. Inner-hop metadata (`act.act.sub`, `act.act.act.sub`, ...) is **informational** — audit + display only. Do NOT gate access on inner hops. Why: only the outermost actor's identity is directly verifiable from the OAuth client authentication that made the exchange call. Inner-hop values are self-reported and only as trustworthy as the issuer that stamped them. ## Trust model — cross-issuer subject tokens Inner `act` values are only as trustworthy as the issuer of the original subject token. AuthPlane only accepts its own tokens on the exchange path today, so every inner-hop value that exists was stamped by AuthPlane on a prior exchange — inherently trustworthy. If AuthPlane ever gains federation on the exchange path (accepting subject tokens from other AS issuers), this changes: the AS you're federating with could stamp arbitrary `act` values that AuthPlane would then pass through unverified. The `SanitizeNonIdentityClaims` function in `internal/domain/token/exchange.go` proactively strips structural claims (`exp`, `nbf`, `aud`, `iat`, `jti`) from inner hops so a hypothetical federated future can't smuggle those either. ## Chain depth limit Chains can nest indefinitely without a cap. AuthPlane enforces `token_exchange.max_chain_depth` (default 5) — an exchange that would produce a chain deeper than the limit is rejected with `chain_too_deep`. Bump for pathological multi-hop deployments; keep low for normal flows. ## Impersonation vs delegation RFC 8693 distinguishes: - **Impersonation** — no `act` claim in the result. The exchanged token looks like it was directly issued to the original subject. Legitimate for narrow-scoping (e.g., "give me a version of my own token with fewer scopes"). Guarded by `token_exchange.allow_self_exchange` (default `false` — reject self-impersonation) and per-resource `policy.exchange.allowed_client_ids`. - **Delegation** — `act` claim added, growing the chain. Under RFC 8693 §4.1 the actor identity comes from the authenticated client on the token endpoint call; an `actor_token` is only needed when the client wants to assert an identity other than its own (and when present, `actor_token_type` is REQUIRED per §2.1). This is the multi-agent case above. AuthPlane's default posture is to allow delegation and refuse self-impersonation. Change with `token_exchange.allow_self_exchange: true` if you specifically need self-narrowing. ## The `actor_type` extra claim AuthPlane adds `actor_type` inside `act` — `"agent"` when the actor is registered with `is_agent: true`, `"service"` otherwise. Downstream services can distinguish "an agent is acting on the user's behalf" from "a machine-to-machine service in the chain". Informational; not authoritative. ## `agent_id` and `agent_chain` — AuthPlane extensions Alongside RFC 8693 `act`, AuthPlane emits two flat claims: - `agent_id` — the outermost agent's client_id when it's registered with `is_agent: true`. - `agent_chain` — ordered list of agent client_ids extracted from the `act` chain (capped at 8). These are AuthPlane extensions (not standardized). They make it easy for downstream services to filter/log by agent without walking the nested `act` tree. See [Concepts: Agent identity](/concepts/agent-identity). ## Related - [Concepts: Grants & flows → Token exchange](/concepts/grants-and-flows#token-exchange-rfc-8693) - [Concepts: Agent identity](/concepts/agent-identity) - [Concepts: Token Vault](/concepts/token-vault) — the other primary use of token exchange (vending upstream tokens) - [Reference: Configuration → Optional grants](/reference/configuration#optional-grants--disabled-by-default) — `token_exchange.max_chain_depth` - [Reference: RFC compliance → RFC 8693](/reference/rfc-compliance#rfc-8693--oauth-20-token-exchange) - [Full source — `internal/domain/token/exchange.go`](https://github.com/authplane/authserver/blob/main/internal/domain/token/exchange.go) — `ActClaim`, `SanitizeNonIdentityClaims`, chain traversal --- ## concepts/dpop.mdx --- title: DPoP description: "The mental model — bearer tokens are house keys, DPoP-bound tokens are keys tied to your fingerprint. RFC 9449 in one page. Deep spec detail in Security." section: Concepts sectionOrder: 2 order: 6 --- # DPoP > **TL;DR** — A regular Bearer token is like a house key: whoever holds it can use it. DPoP (RFC 9449) binds each token to a public key the client holds and proves possession of on every request. Steal the token from a log or a network tap → useless without the private key. Additive: existing bearer clients keep working. This is the mental model — enablement steps live in [Guides: Enable DPoP end-to-end](/guides/enable-dpop) and the spec-level depth in [Security: DPoP](/security/dpop). ## The three-line explanation 1. Client generates an asymmetric key pair (once, at startup). 2. Every OAuth call includes a signed "proof" JWT saying "I'm making this specific request right now" — includes the request URL, method, and a fresh nonce. 3. AuthPlane checks the proof and stamps the token with the client's **public key thumbprint** (`cnf.jkt` claim). The MCP server checks: does the proof's key match the stamp in the token? If someone steals just the token → useless (they don't have the private key). If someone steals just a proof → useless (proofs are single-use and time-bound, ~60 seconds). ## When it matters - **Multi-hop deployments** — token transits multiple services. Any hop could log it or expose it. DPoP means a leak at hop 2 doesn't compromise hop 3. - **High-security environments** — compliance requires proof-of-possession. - **Environments where network traffic might be observable** — VPCs shared with other tenants, transient debug proxies, dev environments that touch prod. ## When to skip - **Local dev** — everything on `localhost`, single tenant. DPoP just adds complexity. - **All communication over mTLS on trusted internal network** — proof-of-possession already provided at a lower layer. - **Very short token expiry** (< 5 min) — the risk window is small; the operational cost of managing DPoP keys may not be worth it. DPoP costs ~1 ms per request in crypto. Proof is ~500 bytes. Real deployments barely notice. ## The two headers to remember **On the request:** ``` Authorization: DPoP ← note "DPoP", not "Bearer" DPoP: ``` **On the token issued by AuthPlane** (JWT payload): ```json { "token_type": "DPoP", "cnf": { "jkt": "0iaeMlxbX3MR9k0DBjKBR9HBkfHTwIsuw_2dPjsLwBE" } } ``` `cnf.jkt` = base64url-encoded SHA-256 thumbprint of the client's public JWK. Deep detail in [Security: DPoP](/security/dpop). ## Additive — no big-bang migration DPoP is opt-in per client. `dpop.enabled: true` at the AS side lets DPoP-capable clients get bound tokens; clients that don't send a DPoP header still get regular bearer tokens. Roll out in stages: 1. Enable AS-side (`dpop.enabled: true`) — nothing breaks. 2. Advertise DPoP support in your MCP servers' PRM (`inbound_dpop: {required: false}`) — DPoP-capable clients start getting verified proofs. 3. Wait for all clients to migrate; monitor `authplane_dpop_proofs_validated_total`. 4. Flip to `required: true` — bearer-only requests now rejected. ## The three switches every deployment needs Real DPoP enforcement requires three things aligned: 1. **AS-side** — `AUTHPLANE_DPOP_ENABLED=true`. 2. **SDK-side** — `inbound_dpop: {required: true}` (Python), `inboundDPoP: {required: true}` (TS), `verifier.WithInboundDPoP(...)` (Go). 3. **Python-only extra step** — `install_request_context(mcp)` for the official MCP Python SDK. Missing any one and either (a) DPoP isn't enforced (fine for dev) or (b) fails closed with `401 invalid_dpop_proof` (broken for the wrong reason). See [Guides: Enable DPoP end-to-end](/guides/enable-dpop). ## The reverse-proxy trap DPoP's `htu` claim is the URL the client thinks it's calling. The server derives its own `htu` from the incoming request. A reverse proxy that rewrites scheme or host without setting `X-Forwarded-Proto` / `X-Forwarded-Host` correctly breaks the comparison silently — every request fails `invalid_dpop_proof` even though the proof is fine. Fix: configure proxy headers correctly. See [Guides: Enable DPoP → reverse-proxy trap](/guides/enable-dpop#reverse-proxy-gotchas--the-htu-trap). ## Why not just short-lived tokens? Short-lived bearer tokens narrow the window of exposure but don't eliminate the class of attacks. A bearer stolen in the first second of its 15-minute life is still usable for the rest. DPoP eliminates the class — even a bearer stolen at issuance time is worthless without the private key. Belt and suspenders: DPoP + short-lived tokens is stronger than either alone. ## Related - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — enablement steps + gotchas - [Security: DPoP](/security/dpop) — RFC 9449 §4.3 proof structure, algorithms, nonces, HTU derivation - [SDKs: Python](/sdks/python#authplane-fastmcp-dpop-caveat) — the PrefectHQ fastmcp caveat - [Reference: Configuration → DPoP](/reference/configuration#optional-grants--disabled-by-default) — every knob - [Reference: Errors → DPoP codes](/reference/errors#dpop-specific-codes-rfc-9449) - [Security: Threat model → T14/T15](/security/threat-model) --- ## concepts/grants-and-flows.mdx --- title: Grants & flows description: "The five OAuth grants AuthPlane implements — auth code + PKCE, refresh, client credentials, token exchange, JWT bearer — and when to use each." section: Concepts sectionOrder: 2 order: 3 --- # Grants & flows > **TL;DR** — AuthPlane implements five OAuth grants. **Authorization code + PKCE** for interactive users (the default), **refresh token** for silent renewal with reuse detection, **client credentials** for machine-to-machine, **token exchange** (RFC 8693) for delegation and vending upstream tokens, and **JWT bearer** (RFC 7523) for enterprise-asserted agent identity. Pick the grant by asking "who is authenticating, and against what?". ## The five grants at a glance | Grant | Who authenticates | Typical use | RFC | |---|---|---|---| | **Authorization code + PKCE** | A human, in a browser | An MCP agent needs a user token to call your MCP server | [RFC 6749 §4.1](https://www.rfc-editor.org/rfc/rfc6749#section-4.1) + [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) | | **Refresh token** | The client, with a token issued to it earlier | Silent token renewal without another consent screen | [RFC 6749 §6](https://www.rfc-editor.org/rfc/rfc6749#section-6) | | **Client credentials** | The client, with its own client_id + client_secret | A backend worker, CI pipeline, or automated agent — no user | [RFC 6749 §4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4) | | **Token exchange** | A client presents a token issued to someone else | On-behalf-of delegation; vending an upstream provider token | [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) | | **JWT bearer** | An external IdP signs an assertion; client presents it | Enterprise-asserted agent identity (XAA) | [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) | All five arrive at the same endpoint — `POST /oauth/token` — differentiated by the `grant_type` parameter. ## Authorization code + PKCE The default flow when a human is in the loop. PKCE-S256 is **mandatory** — AuthPlane rejects the `plain` challenge method entirely. **Wire sequence:** ``` 1. Agent → AS GET /oauth/authorize?response_type=code &client_id=… &redirect_uri=… &code_challenge= &code_challenge_method=S256 &scope=tools/read &resource=https://api.example.com/mcp &state= 2. AS → User 302 Location: /login (if no session) 3. User → AS POST /login (email + password, or OIDC federated) 4. User → AS POST /consent (approve) 5. AS → Agent 302 Location: ?code=&state= 6. Agent → AS POST /oauth/token grant_type=authorization_code code= code_verifier= client_id=… redirect_uri=… resource=https://api.example.com/mcp 7. AS → Agent 200 { access_token, refresh_token, expires_in, token_type } ``` **Key details:** - **`code_challenge` + `code_verifier`** — the client generates a random string (`verifier`), sends its SHA-256 hash on `/authorize`, and reveals the raw string on `/token`. AuthPlane verifies the hash matches. Prevents authorization code interception (the [T1 threat](/security/threat-model#t1-authorization-code-interception)). - **`resource` parameter** — [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource indicator. AuthPlane binds the token's `aud` claim to this URI. Your MCP server should reject tokens whose `aud` doesn't include its own URI. - **`state` parameter** — client-generated CSRF nonce. The client verifies it comes back unchanged on the redirect. - **Consent flow** — AuthPlane records the user's approval in `consent_grants`. Re-authorization requests for the same client + scopes skip the consent screen unless the grant was revoked or scopes changed. - **Token binding to DPoP** — if the client includes a `DPoP` header on `/oauth/token`, the resulting token gets `token_type: "DPoP"` and a `cnf.jkt` claim binding it to the client's key. See [DPoP concept](/concepts/dpop). **Deep dive:** [Guides: Connect an MCP client](/guides/connect-mcp-client) walks the full flow with a real client (Claude Desktop, Inspector). ## Refresh token Access tokens are short-lived by design (default 15 minutes) so a leak has a small blast radius. Refresh tokens let the client renew without another consent screen. **Refresh token rotation** is mandatory — every refresh returns a *new* refresh token and invalidates the old one. If AuthPlane ever sees the same refresh token used twice, it treats the entire *family* as compromised: every token derived from it is revoked. This is [refresh token reuse detection](https://www.rfc-editor.org/rfc/rfc9700#section-4.14). ``` Agent → AS POST /oauth/token grant_type=refresh_token refresh_token= client_id=… AS → Agent 200 { access_token: , refresh_token: , ← rotated expires_in: 900 } ``` Refresh tokens are opaque handles (not JWTs) — stored server-side, revocable per-family. See [Security: Token design](/security/token-design) for the rationale. ## Client credentials Machine-to-machine. The client (a backend service, CI job, automation agent) proves its own identity with `client_id` + `client_secret` and gets a token with the scopes it was registered for. No user, no consent, no refresh token. ``` Backend → AS POST /oauth/token grant_type=client_credentials client_id=… client_secret=… scope=tools/echo tools/query_database resource=https://api.example.com/mcp AS → Backend 200 { access_token, expires_in, token_type } ``` **Key details:** - **Enable it explicitly** — disabled by default. Turn on with `client_credentials.enabled: true` or `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true`. - **Scope resolution** — if the request includes `scope`, the token gets the intersection with the client's registered scopes. If it doesn't, the token gets all registered scopes. - **`sub` claim** = the client_id (not a user). Your MCP server can distinguish machine tokens from user tokens by this. - **No refresh token** — clients re-authenticate instantly. Smaller blast radius on token leak, simpler rotation logic. - **DPoP-compatible** — pass a `DPoP` header at `/oauth/token` to get a bound token. **Deep dive:** [Guides: Wire up client credentials](/topologies/m2m-client-credentials) and the full [grant reference in the authserver repo](https://github.com/authplane/authserver/blob/main/docs/grants/client-credentials.md). ## Token exchange (RFC 8693) Two very different use cases share this grant: ### On-behalf-of delegation An orchestrator agent (agent A) receives a user's token and wants to hand a narrower one to a sub-agent (agent B). Or an MCP server wants a token naming itself as the actor, on behalf of the original user, to call a downstream MCP. ``` Agent A → AS POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token= subject_token_type=urn:ietf:params:oauth:token-type:access_token actor_token= ← optional resource=https://api.example.com/downstream scope=tools/read AS → Agent A 200 { access_token, token_type, issued_token_type } ``` The issued token carries `sub` = original user, and an `act` claim naming the actor: ```json { "sub": "user-42", "act": { "sub": "agent-A", "actor_type": "agent" } } ``` Chained exchanges (A → B → C) nest: `act` = the outermost actor, `act.act` = the actor before them. Full chain reconstructable from the token alone. See [Concepts: Delegation & act-chain](/concepts/delegation-act-chain). ### Vending upstream tokens (Broker resources) Same grant, different target — the MCP server names an upstream provider (`resource=github`) and gets back a bearer token for the user's GitHub account: ``` MCP → AS POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token= resource=github scope=repo client_id= client_secret= AS → MCP 200 { access_token: "gho_xxx", ← GitHub's own token token_type: "Bearer", expires_in: 3600 } ``` AuthPlane enforces the three-bound consent model: `requested_scopes ⊆ consent_grants ⊆ broker_grants`. If the user never connected GitHub, or granted narrower scopes, you get `error=consent_required` with a `consent_url` — the SDK translates this to MCP `-32042` elicitation. **Enable it:** `token_exchange.enabled: true` or `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true`. **Deep dive:** [Concepts: Token Vault](/concepts/token-vault) and [Guides: Wire up the Token Vault](/guides/token-vault). ## JWT bearer — Cross-App Access (XAA) Your enterprise IdP (Okta, Entra ID, Auth0) signs a JWT asserting the agent's identity — an "ID-JAG" (identity JWT-authorization grant). Your MCP client presents that assertion to AuthPlane, which validates it against the IdP's registered JWKS and mints an AuthPlane token. Skips per-user consent for policy-approved agents. ``` Agent → AS POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer assertion= client_id= resource=https://api.example.com/mcp scope=tools/read AS → Agent 200 { access_token, expires_in, token_type } ``` **Key details:** - **Enable it:** requires XAA — `xaa.enabled: true`. Then register a trusted IdP via the admin REST API (`POST /admin/idps`; no CLI subcommand). - **Assertion validation** — signature verified against the IdP's JWKS (cached, TTL configurable), `iss`/`aud`/`exp`/`nbf`/`iat` checked, replay guarded via `jti` store. - **Subject mapping** — `auto_map` (default) uses the assertion's `sub` directly; `strict` requires an explicit `xaa_subject_mappings` row and rejects unmapped subjects. - **Policy engine** — optional. `POST /admin/xaa/policies` (admin REST API; no CLI subcommand) lets you allow/deny mints based on assertion claims (department, group membership, agent name, etc.). - **Client must be registered with `jwt-bearer` in `grant_types`** — DCR-registered clients don't get this by default. **Deep dive:** [Concepts: Cross-App Access (XAA)](/concepts/xaa), [Guides: Enterprise-Managed Auth](/guides/xaa), and [Topologies: Enterprise-Asserted Agent Identity](/topologies/enterprise-xaa). ## Which grant should I use? Ask two questions. **"Is a human authenticating?"** - **Yes, and they'll consent in a browser** → authorization code + PKCE (with refresh for renewal). - **Yes, and my corporate IdP handles it** → authorization code + PKCE with [OIDC federation](/guides/federate-idp); no code change, AuthPlane delegates login upstream. - **No, a service is authenticating as itself** → client credentials. - **No, my corporate IdP is asserting the agent's identity** → JWT bearer (XAA). **"Do I need to reach a third party on the user's behalf?"** - **No, I just need my own JWT for my MCP server** → authorization code (users) or client credentials (machines). - **Yes, I have the user's AuthPlane token and want a narrower AuthPlane token for a downstream MCP** → token exchange (delegation). - **Yes, I have the user's AuthPlane token and want their GitHub / Slack / Google token** → token exchange with a Broker resource. ## Combining grants Real deployments use several at once: - **User agent** → authorization code (user login) → refresh (silent renewal) → token exchange to a Broker (vend upstream token when a tool needs GitHub). - **Enterprise deployment** → OIDC federation (user login via Okta) → authorization code → token exchange to Broker (Slack). - **Enterprise agent with XAA** → JWT bearer (Okta signs ID-JAG for agent) → token exchange (delegate to sub-agent). - **Background worker** → client credentials → token exchange to a Broker (if the worker needs upstream API access via a stored user grant — the [service-account-user pattern](https://github.com/authplane/authserver/blob/main/docs/grants/token-exchange.md#scenario-4-bot-or-background-service-needs-upstream-vault-access)). ## Related - [Concepts: Architecture](/concepts/architecture) — where each grant lives in the codebase - [Concepts: DPoP](/concepts/dpop) — sender-constrained variants of every grant above - [Concepts: Delegation & act-chain](/concepts/delegation-act-chain) — deeper on RFC 8693 act semantics - [Concepts: Token Vault](/concepts/token-vault) — Broker resources and the three-bound consent model - [Concepts: Cross-App Access (XAA)](/concepts/xaa) — JWT bearer + policy engine end to end - [Reference: RFC compliance](/reference/rfc-compliance) — coverage matrix for every RFC listed on this page --- ## concepts/resource-servers-prm.mdx --- title: Resource servers & PRM description: "Your MCP server is an OAuth 2.1 resource server. RFC 9728 Protected Resource Metadata is how it tells clients where to authenticate. What the SDKs publish for you." section: Concepts sectionOrder: 2 order: 2 --- # Resource servers & PRM > **TL;DR** — In OAuth 2.1 language, your MCP server is a **resource server**. AuthPlane is the **authorization server**. The MCP client is the **client**. RFC 9728 **Protected Resource Metadata (PRM)** is how your resource server tells clients "here's where to authenticate, here's what scopes I know about, here's the audience to bind tokens to." Every AuthPlane SDK publishes PRM for you; understanding what it contains + how discovery works is essential for debugging. ## The three OAuth roles in MCP ```mermaid sequenceDiagram participant Client as MCP Client
(Claude, Cursor, ...) participant RS as Resource Server
(Your MCP server) participant AS as Authorization Server
(AuthPlane) Client->>RS: 1. Where do I authenticate? RS-->>Client: PRM: authorization server = Client->>AS: 2. Auth flow AS-->>Client: access token Client->>RS: 3. Call with Bearer RS->>AS: 4. JWKS lookup (cached) AS-->>RS: JWKS RS-->>Client: response ``` Everything the client needs to know about *how* to authenticate lives in your PRM. Everything the client needs to *actually* authenticate lives in the AS metadata. The client discovers one from the other. ## What PRM says The PRM document served at `/.well-known/oauth-protected-resource/` on your MCP server (RFC 9728 §3 places the well-known suffix **between** the host and the resource's path, so a resource at `https://mcp.example.com/mcp` publishes at `https://mcp.example.com/.well-known/oauth-protected-resource/mcp`). MCP clients try the path-scoped form first and only fall back to the root `/.well-known/oauth-protected-resource` when the path-scoped form 404s: ```json { "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com"], "scopes_supported": ["tools/read", "tools/write"], "bearer_methods_supported": ["header"] } ``` Field by field: - **`resource`** — the URI clients must bind tokens to (`aud` claim). Must match the URL clients use to reach your MCP endpoint, byte-for-byte. See [Configuration: Resources → URI matching](/reference/configuration#resources). - **`authorization_servers`** — where clients go to obtain tokens. AuthPlane's issuer URL. - **`scopes_supported`** — every scope your MCP server accepts. Clients use this to know what to request on `/oauth/authorize`. - **`bearer_methods_supported`** — OPTIONAL per RFC 9728 §2 (valid values are `header|body|query`). The SDKs default to `["header"]` for MCP because the MCP spec mandates the `Authorization` header for token transmission and forbids the `query` form — publishing this field just makes that convention explicit. ## What the SDKs publish for you Every AuthPlane SDK builds and serves this document automatically: - **Python `authplane-mcp`** — auto-mounted via the middleware `authplane_mcp_auth()` returns. - **Python `authplane-fastmcp`** — auto-mounted via the `oauth` return field. - **TypeScript `@authplane/*`** — you mount with `app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler)`. - **Go `authplanemcp`** — you mount with `http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())`. The SDK reads `resource`, `scopes`, and the AS metadata to fill in every field. You don't build the JSON yourself. ## Discovery flow — how a client finds the AS MCP 2025-11-25 makes the flow *challenge-triggered*: the client speaks first, gets a `401` with a `WWW-Authenticate` header carrying `resource_metadata`, and follows that. The well-known probe only happens as a fallback. ```mermaid sequenceDiagram participant Client participant Resource as Resource Server participant AS Client->>Resource: 0. POST /mcp (no Authorization) Resource-->>Client: 401 WWW-Authenticate: Bearer realm="…",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" Client->>Resource: 1. GET
(falls back to /.well-known/oauth-protected-resource/,
then to /.well-known/oauth-protected-resource, if step 0 omits the URL) Resource-->>Client: 2. PRM {authorization_servers: [], ...} Client->>AS: 3. GET /.well-known/oauth-authorization-server AS-->>Client: 4. AS metadata Client->>AS: 5. CIMD / DCR / pre-registered → authorize → token ``` Clients **MUST** use the `resource_metadata` URL from the `WWW-Authenticate` challenge when it's present (RFC 9728 §5.1). Clients cache both the PRM and the AS metadata; TTL is configurable per SDK, defaulting to minutes to hours. **Failure modes on this path:** - No `WWW-Authenticate` on the initial 401 → the SDK didn't wire the challenge helper. Every AuthPlane SDK adds this automatically; if you're getting bare 401s, verify you're using the auth middleware rather than open-coding the token check. - PRM 404 → the SDK didn't mount the PRM handler (Go only — Python/TS auto-mount). Or the client only tried the root form and the SDK only publishes the path-scoped form; both should be handled. - PRM present but `authorization_servers` empty → the client has nowhere to authenticate. Fix: pass the correct `issuer` to your adapter. - AS metadata 404 → the URL in `authorization_servers` is wrong. Fix: check your adapter's `issuer` config. ## Audience binding — the `resource` parameter (RFC 8707) `resource` in the PRM is the value clients echo back on `/oauth/authorize` and `/oauth/token` as the `resource` parameter (RFC 8707 Resource Indicator). AuthPlane binds the token's `aud` claim to this value. Your SDK verifies `aud` on every request. **Why it matters:** a token issued for MCP-A cannot be replayed against MCP-B, because MCP-B's SDK rejects any token whose `aud` doesn't match its own resource URI. This is the audience-binding guarantee that makes multi-resource topologies safe. **The catch:** the `resource` string must be identical everywhere: 1. In your PRM document, 2. In AuthPlane's resource registration (`admin resource create --uri ...`), 3. In what the client sends on `/oauth/authorize`, 4. In the `aud` claim on the token. A one-character mismatch → every token rejected. See [Configuration → Resources](/reference/configuration#resources) for the trap in detail. ## `scopes_supported` — what makes clients ask for the right scopes `scopes_supported` in your PRM tells the client what scopes your server accepts. Well-behaved clients read this and only ask for scopes on this list — reduces `invalid_scope` errors at `/oauth/authorize`. At the AuthPlane SDK level, `scopes_supported` is only populated when you pass `enforce_scopes_on_all_requests=true` (Python) / `requiredScopes` (TS) / `Scopes` (Go). Without one of these, PRM advertises an empty scopes array, and clients fall back to the AS's global `scopes_supported`. ## PRM lives at your MCP server, NOT at AuthPlane `/.well-known/oauth-protected-resource` is on YOUR server (port 8080 typically). It's how your server *identifies* itself to clients. `/.well-known/oauth-authorization-server` (and `/.well-known/openid-configuration`) is on AUTHPLANE (`:9000`). It's how AuthPlane identifies itself. Every resource server serves its own PRM. If you have multiple MCPs on the same host, each mounts its own PRM at its own path prefix (e.g., `mcp-a.example.com/.well-known/oauth-protected-resource` and `mcp-b.example.com/.well-known/oauth-protected-resource`). ## Related - [Concepts: Architecture](/concepts/architecture) — where PRM fits in the request flow - [Guides: Connect an MCP client](/guides/connect-mcp-client) — client-side discovery in practice - [Reference: RFC compliance → RFC 9728](/reference/rfc-compliance#rfc-9728--oauth-20-protected-resource-metadata) - [Reference: RFC compliance → RFC 8707](/reference/rfc-compliance#rfc-8707--resource-indicators-for-oauth-20) - [SDKs overview](/sdks/overview) — which SDKs auto-mount PRM vs which need manual mounting --- ## concepts/token-vault.mdx --- title: Token Vault description: "The mental model behind Broker resources — how AuthPlane stores third-party OAuth refresh grants encrypted at rest and vends short-lived upstream access tokens on demand." section: Concepts sectionOrder: 2 order: 4 --- # Token Vault > **TL;DR** — When your MCP server needs to call GitHub / Slack / Google / Linear on a user's behalf, the naïve approach is to store each user's refresh token per MCP. AuthPlane centralizes this: the user connects each upstream provider once via the Connect flow, AuthPlane encrypts the refresh grant, and any authorized MCP server vends a short-lived upstream token on demand via RFC 8693 token exchange. Three-bound consent gates every vend. "Token Vault" is the mental name; the implementation is the unified Resource model with `backend_kind: broker` — `mint` resources are AS-signed, `broker` resources vend upstream tokens. ## The problem it solves Your MCP server "GitHub PR reviewer" needs to make GitHub API calls **on the user's behalf**. Without central credential management: 1. Every MCP server implements its own OAuth flow with each upstream. 2. Every MCP stores refresh tokens somewhere (usually badly — plaintext DB, leaked logs). 3. When a user revokes access at GitHub, every MCP is a stale reference. 4. Auditing "which agent called what upstream on my behalf" is spread across N systems. The Token Vault pattern moves credential storage + refresh + audit to one place — AuthPlane. Every MCP server calls one endpoint (`/oauth/token` with token-exchange grant), gets a fresh short-lived upstream token, forwards it, done. ## Mental model — Mint vs Broker Every AuthPlane resource has a `backend_kind`: - **`mint`** — AuthPlane signs its own JWT for the resource. Standard OAuth resource server. - **`broker`** — AuthPlane vends a token minted by an upstream provider. AuthPlane doesn't sign the vended token — GitHub does. But AuthPlane holds the stored credential + orchestrates consent + audits the vend. `broker` resources have a `broker_provider_slug` pointing at a registered upstream OAuth provider. The provider's config includes the OAuth client credentials AuthPlane uses when running the Connect flow with the upstream. ## The full picture ```mermaid flowchart TD Connect(["Connect flow (one-time)"]) AuthPlane["AuthPlane
broker_grants
per-user
encrypted at rest
(AES-GCM or Vault Transit)"] MCP["Your MCP Server
(tool handler)"] IdP["Upstream IdP
(GitHub)"] API["Upstream API
(github.com)"] Connect -->|writes here| AuthPlane AuthPlane -->|exchange call| MCP IdP -->|upstream OAuth token endpoint| AuthPlane MCP ---|"upstream bearer (Bearer gh_xxx)"| IdP MCP --> API ``` Two flows: - **Connect flow (one-time per user × provider)** — user browses to `/connect/github`, approves at GitHub, AuthPlane stores the encrypted refresh grant in `broker_grants`. Never plaintext on disk. - **Vend flow (per request)** — your MCP calls `/oauth/token` with token-exchange grant naming a broker resource. AuthPlane checks the three-bound consent, uses the stored refresh grant to get a fresh upstream token, returns it. Your MCP forwards it. ## The three-bound consent model Every vend is gated by three bounds: ``` requested_scopes ⊆ consent_grants.scopes (per-agent attestation) ⊆ broker_grants.scopes_granted (per-provider grant) ``` Concretely, five checks: | Gate | Check | Failure | |---|---|---| | A | Scopes are recognized for this broker resource | `invalid_scope` | | B | `consent_grants` row exists for (user, agent, resource) | `consent_required` (AS-side re-consent URL) | | C | requested ⊆ `consent_grants.scopes` | `consent_required` (AS-side re-consent URL) | | D | `broker_grants` row exists for (user, provider) | `consent_required` (upstream `/connect/{provider}` URL) | | E | upstream-mapped scopes ⊆ `broker_grants.scopes_granted` | `consent_required` (upstream re-connect URL) | The `cause` sub-discriminator (`consent_missing` vs `scope_insufficient`) tells the SDK which `consent_url` to attach. All of these translate to MCP JSON-RPC `-32042` `UrlElicitationRequiredError` in your tool code — your handler doesn't need `try/except`. ## Vending is never cached Every `/oauth/token` for a broker resource hits the upstream IdP (or the stored refresh grant if the current AT is still fresh). AuthPlane doesn't hold upstream access tokens in memory — the vended token flows through and is your MCP's responsibility until it expires. Concurrent vends are serialized via optimistic locking. Two simultaneous vends that both need refresh → the second gets HTTP `423 Locked`. SDK clients retry once on 423. ## What happens on upstream revocation If the user revokes AuthPlane's OAuth app at GitHub, the next refresh attempt gets rejected. AuthPlane responds `400 consent_required cause=consent_missing consent_url=/connect/github`. Client walks the user through re-connecting; the encrypted `broker_grants` row is replaced. ## Why encrypted at rest The `data_encryption` driver (AES-256-GCM or Vault Transit) is a **hard requirement** when `broker_providers` are configured — boot fails otherwise. Stored plaintext refresh grants are a compliance and blast-radius nightmare; AuthPlane refuses to run without encryption. - **`aes_master`** — local AES-256-GCM with HKDF-derived per-purpose subkeys. Master key via env var (`AUTHPLANE_DATA_ENCRYPTION_KEY`). - **`vault_transit_encrypt`** — plaintext never touches the AuthPlane process; delegated to Vault Transit. ## Retirement of "Token Vault" terminology Historically this was called "Token Vault". The name was retired in v0.1.0-rc1 in favor of the unified Resource model — same on the wire, cleaner mental model. The `broker_grants` table name reflects the current terminology; the `vault_transit_*` driver names refer to HashiCorp Vault Transit (an external KMS) and are unrelated. ## Related - [Guides: Wire up the Token Vault](/guides/token-vault) — end-to-end setup walkthrough - [Guides: Upstream connections](/guides/upstream-connections) — per-protocol broker provider setup - [Topologies: Agent + brokered MCP](/topologies/broker-mcp) — the topology diagram - [Concepts: Grants & flows → Token exchange](/concepts/grants-and-flows#token-exchange-rfc-8693) - [Reference: Configuration → broker_providers + connect](/reference/configuration#broker_providers) - [Reference: Errors → Broker/Vault](/reference/errors#broker--vault) --- ## concepts/xaa.mdx --- title: Cross-App Access (XAA) description: "The mental model — your enterprise IdP asserts agent identity via a signed JWT (ID-JAG), AuthPlane accepts it and mints an MCP token. Skips per-user consent for policy-approved agents." section: Concepts sectionOrder: 2 order: 7 --- # Cross-App Access (XAA) > **TL;DR** — Two different things called "enterprise IdP integration". [OIDC federation](/concepts/architecture) is about **user login** — the user's identity comes from your corporate IdP. **XAA (Cross-App Access)** is about **agent identity** — your IdP signs a JWT asserting *the agent's* identity, AuthPlane validates it against the IdP's JWKS and mints an MCP token *without a consent screen*. Central policy replaces per-user consent for enterprise-controlled agent fleets. Uses the JWT Bearer grant (RFC 7523) with the ID-JAG assertion type (`oauth-id-jag+jwt`) — an emerging IETF draft referenced by the MCP Authorization spec's Enterprise-Managed Authorization profile. ## The problem it solves Per-user consent screens are fine for one user picking one integration. They break down when: - You have a fleet of enterprise agents (headless CI runners, background jobs, backend services acting as themselves) that can't consent. - You want **central policy control** — "these agents can access these tools with these scopes" — determined by the enterprise IT team, not by each user clicking approve. - Compliance requires that agent access is enterprise-attested, not user-consented. XAA moves consent from "user clicks approve" to "enterprise-issued ID-JAG proves agent identity → AuthPlane policy allows". ## The wire flow ``` 1. Your enterprise IdP signs an ID-JAG for an agent: JWT header: {typ: "oauth-id-jag+jwt"} Payload: {iss: , aud: , sub: , exp, iat, jti} Signed with IdP's private key. 2. Agent → AuthPlane POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer assertion= client_id=$AGENT_CLIENT_ID client_secret=$AGENT_SECRET resource=https://mcp.example.com/mcp scope=tools/read 3. AuthPlane validates: - Signature (against IdP's cached JWKS) - iss matches a registered trusted IdP - aud matches the IdP's registered audience - exp within max_assertion_age (default 5m) - jti not previously used (replay guard) 4. AuthPlane runs policy engine: - (IdP × client × scope × resource) tuple must match at least one policy - Deny by default 5. AuthPlane mints an MCP access token — no consent screen involved 6. Agent ← AuthPlane { access_token, expires_in: 3600 } ``` ## Two independent-but-related concepts Enterprises often need **both** at the same time: - **OIDC federation** ([topologies/oidc-federated-login](/topologies/oidc-federated-login)) — human users log into AuthPlane via their corporate IdP. - **XAA** — enterprise agents get MCP tokens from AuthPlane via ID-JAG. They stack. Users log in via SSO; agents authenticate via XAA. Same corporate IdP, two different authentication paths at AuthPlane. ## What an ID-JAG contains The JWT your IdP signs. AuthPlane accepts one very specific `typ`: `oauth-id-jag+jwt`. **Header:** ```json { "alg": "RS256|ES256|PS256", "typ": "oauth-id-jag+jwt", "kid": "" } ``` **Payload:** ```json { "iss": "https://acme.okta.com", // your IdP's issuer "aud": "https://auth.example.com", // AuthPlane's audience "sub": "", // per your IdP convention "exp": 1708762500, "iat": 1708762200, "jti": "unique-assertion-id" } ``` Extra claims (`groups`, `department`, custom) are accepted but ignored by policy today — future versions may make them policy inputs. ## Policy engine Policies describe which (IdP × client × scope × resource) combinations are allowed. ``` Policy: "Allow Acme agents" IdP: idp_abc123 (Acme Okta) Client IDs: [my-mcp-client] (or empty = any client registered against this IdP) Scopes: [tools/echo, tools/search] (or empty = client's default scopes) Resources: [https://mcp.example.com/mcp] (or empty = any resource) ``` Multiple policies can apply; if ANY allows, the request proceeds. **Deny by default** — an ID-JAG that matches no policy is rejected with `access_denied`. Scope narrowing at issue time: `issued_scope = intersection(requested_scope, policy.scopes)`. Explicit least-privilege. ## Subject mapping Two modes: - **`auto_map`** (default) — external subjects accepted as-is. Token's `sub` = `{iss}:{sub}` (e.g., `https://acme.okta.com:alice@acme.com`). - **`strict`** — only explicitly mapped subjects allowed. Unmapped subjects → `access_denied`. Explicit mappings translate external subjects to your own local user IDs: ``` Mapping: idp_abc123 / alice@acme.com → usr_local_alice ``` Useful when you want AuthPlane audit rows tied to your own user IDs, not the IdP's opaque strings. ## Replay guard Every ID-JAG's `jti` is stored on first use (in `assertion_jti_store`). A second use → `invalid_grant "assertion jti already used"`. Assertions are single-use. The `assertion_jti` table is one of the targets of `authserver purge` — schedule externally per [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge#scheduled-purge). ## Metrics - `authplane_xaa_policy_evaluation_total{decision="allow|deny"}` — every policy check - `authplane_xaa_idp_operations_total` — IdP registry CRUD + JWKS refresh - `authplane_xaa_subject_resolutions_total` — subject mapping resolutions Alert on `rate(authplane_xaa_policy_evaluation_total{decision="deny"}[5m]) > 0` in normal ops — either policy misconfig or unauthorized attempts. ## Test playground `xaa.dev` — public XAA test IdP built on Okta. Two-minute setup with ngrok, no enterprise tenant required. Full walkthrough in [`authserver/docs/how-to/test-xaa-with-xaa-dev.md`](https://github.com/authplane/authserver/blob/main/docs/how-to/test-xaa-with-xaa-dev.md). ## Related - [Guides: Enterprise-Managed Auth](/guides/xaa) — end-to-end enablement - [Topologies: Enterprise-Asserted Agent Identity](/topologies/enterprise-xaa) — full topology diagram - [Concepts: Grants & flows → JWT Bearer](/concepts/grants-and-flows#jwt-bearer--cross-app-access-xaa) - [Reference: Configuration → xaa](/reference/configuration#xaa-and-jwt-bearer--yaml-only-at-v01x) - [Concepts: Agent identity](/concepts/agent-identity) — how the agent identity appears in tokens - [Test XAA with xaa.dev (authserver repo)](https://github.com/authplane/authserver/blob/main/docs/how-to/test-xaa-with-xaa-dev.md) --- ## guides/admin-api.mdx --- title: Admin API guide description: "Task-focused walkthroughs for the Admin API and CLI — register a client, rotate keys, revoke tokens, inspect issuances, manage broker providers." section: Guides sectionOrder: 4 order: 9 --- # Admin API guide > **TL;DR** — Task-focused recipes for the Admin API (`:9001`) and the equivalent CLI. Every operation has a REST endpoint and an `authserver admin ...` CLI command that hit the same underlying service. This guide has the six most-used flows — register a client, register a resource, rotate keys, revoke tokens, inspect issuances, manage broker providers. Full OpenAPI reference in [Reference: Admin API](/reference/admin-api). ## Setup Every admin request needs the API key. Use it as a Bearer token: ```bash export AUTHPLANE_ADMIN_API_KEY="$(cat /path/to/admin-key)" curl -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ http://localhost:9001/admin/clients ``` CLI: same env var. Set once in your shell and every `authserver admin ...` command picks it up. **Every command in this guide can be run as either curl or CLI** — they're equivalent. The CLI is more ergonomic for humans; curl fits CI/CD better. ## Register an MCP client (manual, not via DCR) When you want a stable `client_id` (not DCR-generated) or a confidential client with a specific secret: **CLI:** ```bash authserver admin client create \ --grant-types authorization_code,refresh_token \ --redirect-uris https://app.example.com/oauth/callback \ --scopes 'tools/read||Read tools' \ --scopes 'tools/write||Write tools' \ --auth-method client_secret_post \ --name "My App" ``` **REST:** ```bash curl -X POST http://localhost:9001/admin/clients \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "client_name": "My App", "grant_types": ["authorization_code", "refresh_token"], "redirect_uris": ["https://app.example.com/oauth/callback"], "scope": "tools/read tools/write", "token_endpoint_auth_method": "client_secret_post" }' ``` Response includes `client_id` + (for confidential clients) `client_secret`. **The secret is returned once** and stored as a bcrypt hash — save it now or you'll need to register a new client. For public clients (PKCE-only), use `--auth-method none` (or omit `client_secret_post`). ## Register a resource (Mint or Broker) Mint resource (your MCP server): ```bash authserver admin resource create \ --slug my-mcp-server \ --uri http://mcp.example.com/mcp \ --backend-kind mint \ --display-name "My MCP Server" \ --scopes 'tools/read||Read the data' \ --scopes 'tools/write||Modify the data' ``` Note the scope format: `--scopes 'name|upstream|description'` — pipe-delimited, repeat the flag for each scope. Leave `upstream` empty for Mint resources. The `uri` must match the resource string your MCP server publishes in its PRM byte-for-byte (see [Configuration: Resources → URI matching](/reference/configuration#resources)). Broker resource (upstream provider target): ```bash authserver admin resource create \ --slug github-repos \ --backend-kind broker \ --broker-provider github \ --display-name "GitHub Repos" \ --scopes 'repo:read|repo|Read repositories' \ --scopes 'repo:write|repo|Push changes' ``` The Broker's scope `name` is what MCP clients ask for; the underlying `upstream` scope (what AuthPlane asks GitHub) is configured separately. For multi-scope upstream mappings, see [Guides: Upstream connections](/guides/upstream-connections#attaching-a-broker-provider-to-a-broker-resource). ## Rotate signing keys Zero-downtime. Previous key stays in JWKS so outstanding tokens keep verifying. ```bash # CLI authserver admin key rotate # REST curl -X POST http://localhost:9001/admin/keys/rotate \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" ``` Response: ```json { "current_kid": "kid_new_abc", "previous_kid": "kid_old_xyz", "rotated_at": "2026-07-01T00:00:00Z" } ``` On multi-instance deployments with `postgres_key` or `vault_transit`, propagation is automatic via `LISTEN/NOTIFY` (ms). Single-instance `keyfile` may want a SIGHUP after rotation for immediate reload: ```bash docker kill -s HUP $(docker compose ps -q authplane) # or kill -HUP $(pidof authserver) ``` Rotation cadence + policy in [Security: Key management](/security/key-management). ## Revoke a token / a whole client's tokens **Revoke a specific token** — use the OAuth revocation endpoint (public, not admin): ```bash curl -X POST http://localhost:9000/oauth/revoke \ -d "token=$ACCESS_OR_REFRESH_TOKEN" \ -d "client_id=$CLIENT_ID" \ -d "client_secret=$CLIENT_SECRET" ``` Returns 200 per RFC 7009 spec. **Revoke everything issued for a client** — suspend the client (via admin REST API; no CLI subcommand): ```bash curl -s -X PATCH http://localhost:9001/admin/clients/$CLIENT_ID/suspend \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" ``` While suspended, the client can't get new tokens; existing tokens continue to verify until they expire (JWTs are stateless), UNLESS you also enable introspection revocation checks on your MCP server (SDK `revocation_checker`). Reactivate: ```bash curl -s -X PATCH http://localhost:9001/admin/clients/$CLIENT_ID/reactivate \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" ``` **Revoke a user's consent** — the client can't get new tokens for this user until the user consents again: ```bash authserver admin grant list-user-grants --user $USER_ID # find the consent_grant id authserver admin grant revoke-consent --id $CONSENT_GRANT_ID ``` Cascades onto live Mint issuances — those tokens are revoked immediately. ## Inspect issuances (forensic audit) Every token issuance writes a row to `issuances` — queryable by user, client, resource, or time range: ```bash # Last 50 issuances for a user authserver admin issuance list --user user-42 --limit 50 # Everything issued for a client in the last day authserver admin issuance list --client my-client --since 24h # Filter by resource authserver admin issuance list --resource github-repos --limit 20 # Full detail on one issuance (all claims, DPoP jkt, act chain) authserver admin issuance get --id iss_abc123 ``` REST equivalent: `GET /admin/issuances?user_id=...&client_id=...&resource=...&since=...&limit=...`. Combined with `audit_events` (which includes non-issuance actions like consent granted, key rotated, provider added), issuances are the forensic long-term record. Retention is until you `purge machine-tokens` — issuances aren't purged automatically. ## Manage broker providers Manage upstream OAuth providers used by Broker resources. **Register** — the `--config-data` flag takes a path to a JSON file with the provider config: ```bash cat > github-provider.json <<'EOF' { "client_id": "github-oauth-app-client-id", "client_secret_ref": "CONNECTOR_GITHUB_SECRET", "authorize_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token" } EOF authserver admin provider create \ --slug github \ --protocol oauth \ --display-name "GitHub" \ --config-data ./github-provider.json ``` **Update** — e.g., rotating credentials (edit the JSON file, then re-apply): ```bash authserver admin provider update --id $PROVIDER_ID \ --config-data ./github-provider.json ``` **Delete** — fails if any resource still references this provider: ```bash authserver admin provider delete --id $PROVIDER_ID ``` Details on the `config_data` shape per protocol in [Guides: Upstream connections](/guides/upstream-connections). ## Bulk operations from JSON Every subcommand accepts `--json` flag for JSON output, and CLI can read from stdin via `-`: ```bash # Dump every client authserver admin client list --json > clients-backup.json # Restore (per-item — no batch endpoint yet) jq -c '.[]' clients-backup.json | while read c; do echo "$c" | authserver admin client create --json - done ``` For real backup, back up the underlying storage (SQLite file or `pg_dump`). See [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge). ## Admin UI (`/admin/ui/`) Every action above has a UI equivalent. Open `http://localhost:9001/admin/ui/` and paste `$AUTHPLANE_ADMIN_API_KEY` when prompted. Stored in `sessionStorage` — clears on tab close. The UI mirrors the API's structure: sidebar with Clients / Users / Resources / Providers / Grants / Issuances / Signing Keys / Audit / System, table view for lists, drawer for details + edit. Same underlying REST endpoints — API-only ops just aren't in the UI (yet). ## Common failure modes | Symptom | Cause | Fix | |---|---|---| | `401 Unauthorized` on every admin request | API key wrong or missing | Set `AUTHPLANE_ADMIN_API_KEY`; check `admin.api_key` in config | | `409 conflict` on `client create` | Duplicate `client_name` | Pick unique names or use auto-generated IDs | | `409 conflict` on `resource create` | Duplicate `slug` | Slugs are unique per instance | | `403 access_denied` on `provider delete` | A resource still references this provider | Delete/update the resource first | | Admin UI shows "Failed to fetch" | CORS not set for the admin origin | Set `AUTHPLANE_SERVER_ALLOWED_ORIGINS` | ## Related - [Reference: Admin API](/reference/admin-api) — full OpenAPI spec (Rapidoc) + auth model - [Reference: Metrics & CLI](/reference/metrics-and-cli#cli-reference) — every `admin *` command with all flags - [Security: Threat model → T8](/security/threat-model) — admin API is threat T8; hardening notes - [Operate: Kubernetes → Ingress](/operate/kubernetes#ingress--split-public--admin) — how to restrict admin access with a separate ingress --- ## guides/connect-mcp-client.mdx --- title: Connect an MCP client description: "Point Claude Desktop, Cursor, VS Code Copilot, or MCP Inspector at your AuthPlane-protected MCP server — configs and known quirks per client." section: Guides sectionOrder: 4 order: 1 --- # Connect an MCP client > **TL;DR** — Your MCP server is running, tokens issue, everything is wired. Now point a client at it. The MCP flow is: first request → `401` with `WWW-Authenticate` → fetch PRM → discover the AS → pick a client-identification path (CIMD, DCR, or pre-registered) → run OAuth → present tokens on `tools/*` calls. This page has the per-client config (Claude Desktop, Cursor, VS Code Copilot, MCP Inspector) plus known quirks like Claude Code omitting `scope` on `/authorize`. ## Prerequisites - Your MCP server is running at `https://mcp.example.com/mcp` (or local equivalent). - Your SDK wired PRM at `/.well-known/oauth-protected-resource/mcp` (RFC 9728 §3 path-scoped form) — verify with `curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp | jq`. - AuthPlane AS is reachable at the URL your PRM's `authorization_servers` field points to. - Your MCP client can reach both hosts (no CORS blocks, no firewall). If any of these are broken, no client will connect — start with [Debugging checklist](/troubleshooting/debugging) before this page. ## What every MCP client does Per MCP 2025-11-25, the client kicks off with an *unauthenticated* request and lets the resource server point at the PRM. It then picks a client-identification path — MCP mandates client-metadata discovery (**CIMD**) as the SHOULD, with **DCR** (RFC 7591) and **pre-registered** client_ids as the two other MAY paths. All three converge on the same OAuth authorize/token exchange: ``` 0. Client → your MCP server POST /mcp (no Authorization header) ← 401 WWW-Authenticate: Bearer resource_metadata="…" 1. Client → your MCP server GET (path-scoped: /.well-known/oauth-protected-resource/mcp) (falls back to root /.well-known/oauth-protected-resource only if step 0 didn't carry a URL) ← PRM {authorization_servers, scopes_supported} 2. Client → AuthPlane GET /.well-known/oauth-authorization-server ← AS metadata 3. Client → AuthPlane *one of*: · CIMD lookup (SHOULD) · POST /oauth/register (DCR, RFC 7591) · use its pre-registered client_id ← client_id (+ secret if confidential) 4. Client → AuthPlane GET /oauth/authorize?response_type=code &code_challenge=… &code_challenge_method=S256 &resource=https://mcp.example.com/mcp &scope=tools/read ← redirect through login + consent → code 5. Client → AuthPlane POST /oauth/token ← access_token + refresh_token (token_type=DPoP if client sent a DPoP header) 6. Client → your MCP server POST /mcp Authorization: Bearer (or DPoP) ← tool result ``` Clients **MUST** honor the `resource_metadata` URL from the `WWW-Authenticate` challenge when present (MCP 2025-11-25) — the well-known lookup at step 1 is the *fallback* path for clients that already know the resource, or when the challenge is missing. Every client below implements this same flow with slight configuration and UI differences. ## MCP Inspector The reference client — fastest way to smoke-test everything. ```bash npx @modelcontextprotocol/inspector https://mcp.example.com/mcp ``` Or for local dev: ```bash npx @modelcontextprotocol/inspector http://localhost:8080/mcp ``` **The URL must include the `/mcp` path** (or whatever endpoint your SDK is configured for). `http://localhost:8080` alone points at the server root and Inspector will report "no tools". Inspector opens a browser tab with the OAuth flow, walks through discovery, DCR, authorize, consent, and lands you on a UI where you can invoke tools with a valid token. Every scenario is covered by [`e2e/scenarios/mcp_inspector_test.go`](https://github.com/authplane/authserver/blob/main/e2e/scenarios/mcp_inspector_test.go) in the authserver repo — if Inspector works, your server is spec-compliant. Full walkthrough in [Guides: Testing with MCP Inspector](/guides/mcp-inspector). ## Claude Desktop **Config file location:** - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` - Linux: `~/.config/Claude/claude_desktop_config.json` **Add your server to `mcpServers`:** ```json { "mcpServers": { "my-server": { "type": "streamable-http", "url": "https://mcp.example.com/mcp" } } } ``` Restart Claude Desktop. On first tool use, Claude Desktop pops up the OAuth flow in a browser window. Once you approve consent, the tokens are cached and subsequent tool calls are silent (with refresh happening in the background). **No API key or client_id needed** in the config — Claude Desktop uses DCR to register itself dynamically. ## Claude Code Command-line invocation: ```bash claude --mcp-server https://mcp.example.com/mcp ``` Or add to `~/.claude/config.json`: ```json { "mcpServers": { "my-server": { "type": "streamable-http", "url": "https://mcp.example.com/mcp" } } } ``` ### Known Claude Code quirks Track [`authserver/docs/compatibility.md`](https://github.com/authplane/authserver/blob/main/docs/compatibility.md) for the current state. As of writing: - **Omits `scope` on `/oauth/authorize`** ([anthropics/claude-code#12077](https://github.com/anthropics/claude-code/issues/12077)). Without a workaround, the AS issues a zero-scope token and you get `403` on every tool call. - **Fix:** set `oauth.require_scope: false` (or `AUTHPLANE_OAUTH_REQUIRE_SCOPE=false`). AuthPlane then defaults missing scope to all registered scopes for the resource (ADR-012). - **Omits `scope` on DCR** ([#4540](https://github.com/anthropics/claude-code/issues/4540)) — harmless; AuthPlane doesn't enforce scope at registration. - **Omits `offline_access`** ([#7744](https://github.com/anthropics/claude-code/issues/7744)) — harmless; AuthPlane issues refresh tokens for all `authorization_code` grants regardless. - **Omits `resource` on `/oauth/authorize`** ([#10572](https://github.com/anthropics/claude-code/issues/10572)) — token's `aud` will be empty; ADR-012 scope defaulting still applies. **Minimal Claude-Code-compatible config:** ```yaml oauth: require_scope: false # workaround for anthropics/claude-code#12077 ``` Or env var: `AUTHPLANE_OAUTH_REQUIRE_SCOPE=false`. ## Cursor Cursor's config lives at: - macOS/Linux: `~/.cursor/mcp.json` - Windows: `%APPDATA%\Cursor\mcp.json` ```json { "mcpServers": { "my-server": { "url": "https://mcp.example.com/mcp", "type": "streamable-http" } } } ``` Restart Cursor. First tool call triggers the OAuth flow in an embedded webview. ## VS Code Copilot Chat Uses `settings.json`: ```json { "github.copilot.chat.mcp.servers": { "my-server": { "url": "https://mcp.example.com/mcp", "type": "streamable-http" } } } ``` Compatibility status: **Pending** — not yet in AuthPlane's automated E2E suite. Manual validation required. If you hit issues, file a compat report following the [MCP compatibility template](https://github.com/authplane/authserver/blob/main/.github/ISSUE_TEMPLATE/mcp-compatibility.md). ## Once connected — verifying with the Admin UI Open `https://auth-admin.internal.example.com/admin/ui/` (or the local `http://localhost:9001/admin/ui/`) and check: 1. **Clients** — every MCP client that registered via DCR appears here with its `client_id`, redirect URIs, and grant types. 2. **Grants** — user consent decisions. One row per (user, client, resource) after consent. 3. **Issuances** — every token issued, with `sub`, `client_id`, `resource`, `scope`, `token_type` (Bearer or DPoP), and `expires_at`. 4. **Audit log** — the full trail of `token_issued`, `token_revoked`, `consent_granted` events with timestamps. If a client connected but tool calls fail, the token IS in Issuances but not usable — inspect its `scope` and `aud` claims against what your MCP server requires. See [Reference: Errors](/reference/errors) for the top-20 debugging fixes. ## Common failure modes | Symptom | Cause | Fix | |---|---|---| | "No tools available" in Inspector | URL missing `/mcp` path | Point at `http://host:port/mcp` (or your configured endpoint) | | Client hangs on "authenticating..." | AS unreachable from client | Verify `curl /.well-known/oauth-authorization-server` from where the client runs | | `403` on every tool call from Claude Code | Zero-scope token due to `scope` omission bug | Set `AUTHPLANE_OAUTH_REQUIRE_SCOPE=false` | | Client shows OAuth flow but Consent screen 500s | Session secret unstable or missing | Set `AUTHPLANE_SESSION_SECRET` to a stable 32+ byte value | | Discovery fetches, but redirects loop | Reverse proxy rewriting scheme/host without `X-Forwarded-Proto` | Fix proxy headers; AuthPlane derives issuer URL from `server.issuer`, not the incoming request | | PRM 404 at your MCP server | SDK didn't mount the PRM handler (Go only) | `http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())` | More at [Reference: Errors](/reference/errors) and [Troubleshooting: Debugging](/troubleshooting/debugging). ## Related - [Guides: Testing with MCP Inspector](/guides/mcp-inspector) — deep dive on Inspector-driven validation - [Quickstart](/quickstart) — the full server-side setup that unlocks these clients - [Reference: Configuration → OAuth](/reference/configuration#oauth) — `oauth.require_scope` and other client-facing knobs - [Compatibility matrix (authserver repo)](https://github.com/authplane/authserver/blob/main/docs/compatibility.md) — living document, updated per client release - [SDKs overview](/sdks/overview) — server-side adapter matrix --- ## guides/enable-dpop.mdx --- title: Enable DPoP end-to-end description: "Turn on RFC 9449 sender-constrained tokens across AuthPlane and your SDK — with the reverse-proxy and Python fastmcp gotchas that trip people up." section: Guides sectionOrder: 4 order: 3 --- # Enable DPoP end-to-end > **TL;DR** — Three switches: AS-side (`AUTHPLANE_DPOP_ENABLED=true`), SDK-side (`inbound_dpop: {required: true}` in your adapter's options), and Python `authplane-mcp` needs `install_request_context(mcp)`. If any one is missing, you either accept unbound Bearer tokens (fine for dev) or fail closed with `401 invalid_dpop_proof` (broken for the wrong reason). This guide walks the full setup, gotchas included. ## What you get Regular Bearer tokens are like house keys — whoever has them can use them. DPoP ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) binds each token to a public key the client holds and proves possession of on every request. Steal the token from a log, a network tap, or a compromised proxy → useless without the private key. - **When to enable:** production, multi-hop deployments, environments where token leak is a realistic threat, compliance requirements. - **When to skip:** local dev, single-tenant lab, everything behind mTLS on a trusted network. See [Concepts: DPoP](/concepts/dpop) for the trade-off. - **Performance:** ~1 ms of crypto per request. Proofs are ~500 bytes. Deep spec detail lives in [Security: DPoP](/security/dpop). This page is the enablement guide. ## The three switches ### 1. AS-side — issue DPoP-bound tokens ```yaml dpop: enabled: true proof_lifetime: 60s # max age of a client's DPoP proof (10s..300s) nonce_ttl: 60s # how long a server-issued nonce stays valid require_nonce: false # true → extra round-trip, but tighter replay protection ``` Or env vars: ```bash AUTHPLANE_DPOP_ENABLED=true AUTHPLANE_DPOP_PROOF_LIFETIME=60s AUTHPLANE_DPOP_NONCE_TTL=60s AUTHPLANE_DPOP_REQUIRE_NONCE=false ``` Restart AuthPlane. `/.well-known/oauth-authorization-server` now advertises `dpop_signing_alg_values_supported: ["ES256", "RS256", "PS256"]`. Any client that sends a `DPoP` header on `/oauth/token` will get back a token with `token_type: DPoP` and a `cnf.jkt` claim binding it to their key. **Additive** — clients that DON'T send a DPoP header still get regular Bearer tokens. Enable AS-side first without touching clients; nothing breaks. ### 2. SDK-side — verify DPoP proofs on incoming requests `required: true` means: reject bearer-only requests when the server expects DPoP. `required: false` (or omitting the option entirely) advertises DPoP support in the PRM but still accepts bearer tokens — useful during migration when some clients don't support DPoP yet. ### 3. Python only — `install_request_context(mcp)` **Only for `authplane-mcp` on the official MCP Python SDK.** Not needed for TS, Go, or Python's `authplane-fastmcp` (which has a separate DPoP caveat — see below). The official MCP Python SDK's `FastMCP` class doesn't expose a middleware hook, so the adapter wraps `mcp.streamable_http_app` and installs `AuthplaneRequestContextMiddleware` that publishes the active Starlette `Request` on a `ContextVar` before the verifier runs. The verifier reads it to build a `DPoPRequestContext` and forward it to `AuthplaneResource.verify()`. **Without `install_request_context(mcp)`, DPoP-bound requests fail closed with `401 WWW-Authenticate: DPoP error="invalid_dpop_proof"`** — the misconfiguration surfaces immediately rather than as a silent bypass. So if you're on `authplane-mcp` with `inbound_dpop=...(required=True)` and every request comes back 401, this is the first thing to check. The call is idempotent — safe to invoke twice. ## Client-side — construct a DPoP proof Your MCP client is responsible for generating the proof. Every AuthPlane SDK's outbound client does this automatically when configured with a `DPoPProvider`; you only need to write proof-construction code if you're implementing a client from scratch. **Generate a key pair** — once, at client startup. ECDSA P-256 recommended (compact + fast): ```bash openssl ecparam -name prime256v1 -genkey -noout -out dpop_key.pem openssl ec -in dpop_key.pem -pubout -out dpop_pub.pem ``` **Never share the private key.** Never include it in a proof — AuthPlane rejects proofs that carry the private key in the `jwk` header. **Proof shape** — a JWT with: | Header | Value | |---|---| | `typ` | `dpop+jwt` (exact string) | | `alg` | `ES256`, `RS256`, or `PS256` | | `jwk` | Your **public** key as a JWK | | Claim | Required | Notes | |---|---|---| | `jti` | Always | UUID. Server rejects reused values | | `htm` | Always | HTTP method (`POST`, `GET`, …) | | `htu` | Always | HTTP URL scheme + authority + path (authority includes non-default port; query stripped; compared after RFC 3986 normalization) | | `iat` | Always | Unix timestamp, within `proof_lifetime` of server time | | `nonce` | When server requires it | Value from `DPoP-Nonce` response header | | `ath` | At resource server | `base64url(SHA-256(access_token))` — binds proof to token | Pseudocode: ```python def dpop_proof(private_key, method, url, nonce=None, access_token=None): header = { "typ": "dpop+jwt", "alg": "ES256", "jwk": public_key_jwk(private_key), # PUBLIC key only } payload = { "jti": uuid4(), "htm": method, "htu": strip_query(url), "iat": int(time.time()), } if nonce: payload["nonce"] = nonce if access_token: payload["ath"] = base64url(sha256(access_token)) return jwt_sign(header, payload, private_key) ``` **At the token endpoint** — proof included but no `ath` (you don't have the token yet). Response has `token_type: DPoP` and a `cnf.jkt` claim binding it to your key. **At the resource server** — proof included WITH `ath`. `Authorization: DPoP ` (not `Bearer`). ## Nonce flow (when `require_nonce: true`) Extra round-trip on the first request. The AS and the RS use **different mechanisms** — RFC 9449 keeps them separate: - **AS (`/oauth/token`, per §8)** — HTTP **400** with a JSON body `{"error":"use_dpop_nonce", …}` (the RFC 6749 §5.2 error envelope) plus a `DPoP-Nonce: ` header. No `WWW-Authenticate`. - **RS (per §9)** — HTTP **401** with `WWW-Authenticate: DPoP error="use_dpop_nonce"` plus `DPoP-Nonce: `. Client behavior is the same on both: read the `DPoP-Nonce` header from the response, cache it, and include it as the `nonce` claim in every subsequent proof. SDKs handle both variants automatically. Also note the resource-server SDKs' `use_dpop_nonce` challenge at `/mcp` uses HTTP **401 + `WWW-Authenticate: DPoP`** — that's §9's mechanism, not §8's. ## Verifying it worked **Request a token with DPoP** and inspect the response: ```bash $ curl -s -X POST http://localhost:9000/oauth/token \ -H "DPoP: eyJhbGci..." \ -d "grant_type=client_credentials&client_id=…&client_secret=…" | jq . { "access_token": "eyJhbGci...", "token_type": "DPoP", ← DPoP, not Bearer "expires_in": 3600 } ``` **Decode the access token** — `cnf.jkt` should be present: ```bash $ echo "eyJhbGci..." | cut -d. -f2 | base64 -d | jq .cnf { "jkt": "0iaeMlxbX3MR9k0DBjKBR9HBkfHTwIsuw_2dPjsLwBE" } ``` `jkt` is the base64url-encoded SHA-256 thumbprint of your public JWK (per [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638)). If it matches the thumbprint of the key your client is using, you're DPoP-bound. **Test at the resource server** — replay the token WITHOUT a DPoP header: ```bash $ curl -i -X POST http://mcp.example.com/mcp \ -H "Authorization: DPoP eyJhbGci..." \ -d '{}' HTTP/1.1 401 Unauthorized WWW-Authenticate: DPoP error="invalid_dpop_proof", error_description="DPoP proof is invalid", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" ``` Confirms the SDK is enforcing. A leaked token off the bound key is useless. ## Metrics to watch Once DPoP is in production: - `authplane_dpop_proofs_validated_total` — successful verifications - `authplane_dpop_proofs_rejected_total` — failures. Labels indicate reason (`replay`, `nonce_missing`, `htu_mismatch`, `signature_invalid`, etc.) - `authserver_auth_denied_total{reason="dpop_*"}` — auth denials attributable to DPoP `rate(authplane_dpop_proofs_rejected_total[5m])` spiking without a corresponding `validated` spike is the signature of a client bug or an attack — alert on it. ## Reverse-proxy gotchas — the `htu` trap DPoP's `htu` claim is the URL the client thinks it's calling. The server derives its own `htu` from the incoming request and compares. **A reverse proxy that rewrites scheme or host without setting `X-Forwarded-Proto` / `X-Forwarded-Host` correctly breaks this comparison silently** — every request comes back `401 invalid_dpop_proof` even though the proof looks fine. - **Symptom:** Every DPoP request fails. Non-DPoP (bearer) requests succeed. - **Fix:** Configure your proxy to set `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. AuthPlane and the SDKs honor these when reconstructing the request URL. - **Verify:** log the reconstructed URL on your MCP server and compare against the `htu` claim in the failing proof. Caddy and nginx example configs are in [Operate: Standalone → Reverse proxy](/operate/standalone#reverse-proxy). ## Migration path — enable without breaking existing clients Because DPoP is additive, roll it out in stages: 1. **Turn on AS-side.** `dpop.enabled: true`. All existing bearer flows keep working; DPoP-capable clients start requesting bound tokens. 2. **Advertise inbound DPoP in PRM without enforcing.** SDK config: `inbound_dpop: {required: false}` (or omit `required`). PRM now announces DPoP support; clients that already send DPoP proofs get them verified. 3. **Wait for all clients to migrate.** Monitor `authplane_dpop_proofs_validated_total` per client_id. 4. **Flip to `required: true`.** Now bearer-only requests are rejected. Clients that didn't migrate see `401`. Skip stages 2-3 for greenfield deployments — `required: true` from day one. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Every DPoP request → `401 invalid_dpop_proof` | Python `authplane-mcp`: no `install_request_context(mcp)` | Add the call after `mcp = FastMCP(...)` | | Every DPoP request → `401 invalid_dpop_proof`, non-Python SDK | Reverse proxy rewriting scheme/host without `X-Forwarded-*` | Fix proxy headers | | First DPoP request → `401 use_dpop_nonce` | Server has `require_nonce: true` | Client picks up `DPoP-Nonce` header, includes in next proof's `nonce` claim | | Second DPoP request from same client → `invalid_dpop_proof` "jti already used" | Client bug: reusing `jti` across requests | Generate a fresh UUID per request | | Token comes back as `Bearer` instead of `DPoP` | Client didn't send a `DPoP` header on `/oauth/token` | Verify client's outbound `DPoPProvider` is wired | | `authplane-fastmcp` (PrefectHQ) doesn't enforce DPoP despite `inbound_dpop` set | Known caveat: PrefectHQ FastMCP's `BearerAuthBackend` rejects `Authorization: DPoP` before the adapter sees it | See [SDKs: Python — DPoP caveat](/sdks/python#authplane-fastmcp-dpop-caveat) | | `invalid_dpop_proof` on the very first proof, `iat` looks fine | Clock skew between client and server exceeds `proof_lifetime` | Sync clocks (NTP), or bump `proof_lifetime` to 120s | Full failure catalog in [Reference: Errors](/reference/errors) and [Security: DPoP](/security/dpop). ## Related - [Concepts: DPoP](/concepts/dpop) — mental model, when to enable - [Security: DPoP](/security/dpop) — full spec detail (§4.3 proof structure, allowed algs, nonce semantics) - [SDKs: Python](/sdks/python) — the `install_request_context` requirement + fastmcp caveat - [SDKs: TypeScript](/sdks/typescript) — `inboundDPoP` + AsyncLocalStorage double-invocation handling - [SDKs: Go](/sdks/go) — `verifier.WithInboundDPoP` - [Reference: Configuration → DPoP](/reference/configuration#optional-grants--disabled-by-default) — every DPoP knob - [Reference: Errors — DPoP codes](/reference/errors#dpop-specific-codes-rfc-9449) — full error catalog --- ## guides/federate-idp.mdx --- title: Federate to your IdP description: "Delegate user authentication to Google Workspace, Okta, Entra ID, Auth0, or any OIDC provider — AuthPlane still issues the MCP tokens." section: Guides sectionOrder: 4 order: 2 --- # Federate to your IdP > **TL;DR** — Instead of AuthPlane managing passwords, delegate the "who is this user" question to Google, Okta, Entra ID, Auth0, or any OIDC provider. Users click "Sign in with ``", authenticate with their existing credentials, and AuthPlane auto-provisions or links a local account. The OAuth token issuance still happens in AuthPlane — federation only changes login. One upstream provider at a time (use Dex if you need multiple). ## When you need this - Your org already uses Google Workspace, Okta, Entra ID, or Auth0 for SSO. - You don't want to manage passwords in AuthPlane. - You need to enforce your IdP's MFA, conditional access, session policies. - You're deploying AuthPlane for a team that already has corporate accounts. If none of these apply, skip federation — local login works out of the box. ## How the flow works ``` User clicks "Sign in with " → AuthPlane redirects to your IdP's /authorize → User authenticates with your IdP (MFA, SSO, whatever) → Your IdP redirects to AuthPlane's /oidc/callback with an auth code → AuthPlane exchanges the code for ID token claims (email, name) → AuthPlane creates or links the local user account → OAuth flow continues normally — /authorize → consent → /token ``` **The key point:** OIDC federation handles *who the user is*. Consent, tokens, scopes, and everything downstream work exactly the same as with local passwords. ## Prerequisites (any provider) Before touching AuthPlane's config, you need at your IdP: 1. A registered OAuth/OIDC application. 2. The IdP's issuer URL. 3. Client ID + client secret from the IdP. 4. Redirect URI: **`https:///oidc/callback`** — registered at your IdP. ## Setup by provider ### Google Workspace 1. Go to [Google Cloud Console](https://console.cloud.google.com/) → APIs & Services → Credentials. 2. Create an OAuth 2.0 Client ID (type: Web application). 3. Add `https://auth.example.com/oidc/callback` as an authorized redirect URI. 4. Configure AuthPlane: ```yaml oidc: enabled: true issuer: https://accounts.google.com client_id: "your-google-client-id.apps.googleusercontent.com" client_secret: "your-google-client-secret" display_name: Google redirect_uri: https://auth.example.com/oidc/callback scopes: [openid, email, profile] include_groups_scope: false # Google doesn't support groups scope ``` ### Okta 1. Okta Admin Console → Applications → Create App Integration. 2. Choose OIDC → Web Application. 3. Set sign-in redirect URI to `https://auth.example.com/oidc/callback`. 4. Configure AuthPlane: ```yaml oidc: enabled: true issuer: https://your-org.okta.com client_id: "okta-client-id" client_secret: "okta-client-secret" display_name: Okta redirect_uri: https://auth.example.com/oidc/callback scopes: [openid, email, profile] include_groups_scope: true # Okta supports groups ``` ### Microsoft Entra ID (Azure AD) 1. Azure Portal → App registrations → New registration. 2. Set redirect URI to `https://auth.example.com/oidc/callback` (Web platform). 3. Create a client secret under Certificates & secrets. 4. Configure AuthPlane (replace `{tenant-id}` with your Azure AD tenant ID, or `common` for multi-tenant): ```yaml oidc: enabled: true issuer: https://login.microsoftonline.com/{tenant-id}/v2.0 client_id: "entra-client-id" client_secret: "entra-client-secret" display_name: Microsoft redirect_uri: https://auth.example.com/oidc/callback scopes: [openid, email, profile] ``` ### Auth0 1. Auth0 Dashboard → Applications → Create Application → Regular Web Application. 2. Under Settings → Allowed Callback URLs, add `https://auth.example.com/oidc/callback`. 3. Configure AuthPlane: ```yaml oidc: enabled: true issuer: https://your-tenant.auth0.com/ client_id: "auth0-client-id" client_secret: "auth0-client-secret" display_name: Auth0 redirect_uri: https://auth.example.com/oidc/callback scopes: [openid, email, profile] ``` ### Dex — for multiple upstream providers AuthPlane supports **one** upstream OIDC provider directly. For multiple providers (e.g., Google + Okta simultaneously), use [Dex](https://dexidp.io/) as an OIDC broker and point AuthPlane at Dex: ```yaml oidc: enabled: true issuer: https://dex.example.com client_id: "dex-client-id" client_secret: "dex-client-secret" display_name: SSO redirect_uri: https://auth.example.com/oidc/callback connector_id: github # optional: pre-select a Dex connector ``` Without `connector_id`, Dex shows its own login page listing every configured connector. With it, users skip straight to that upstream. ## Env-var equivalents Every OIDC field can also be set via env vars — useful for CI/CD and Kubernetes Secrets: ```bash AUTHPLANE_OIDC_ENABLED=true AUTHPLANE_OIDC_ISSUER=https://accounts.google.com AUTHPLANE_OIDC_CLIENT_ID=your-client-id AUTHPLANE_OIDC_CLIENT_SECRET=your-client-secret AUTHPLANE_OIDC_DISPLAY_NAME=Google AUTHPLANE_OIDC_REDIRECT_URI=https://auth.example.com/oidc/callback AUTHPLANE_OIDC_SCOPES=openid,email,profile ``` Full mapping in [Reference: Configuration → OIDC](/reference/configuration#oidc--upstream-oidc-federation). ## Kubernetes (Helm) — don't hard-code secrets Don't put `client_secret` in `values.yaml`. Two patterns: **Env-var injection from a Secret:** ```yaml config: oidc: enabled: true client_secret_ref: AUTHPLANE_OIDC_CLIENT_SECRET extraEnv: - name: AUTHPLANE_OIDC_CLIENT_SECRET valueFrom: secretKeyRef: name: oidc-credentials key: client-secret ``` **Full config as sealed Secret** (GitOps friendly): ```yaml existingConfigSecret: my-sealed-config ``` Both patterns work with Sealed Secrets, External Secrets Operator, or SOPS. See [Operate: Kubernetes → Secrets management for OIDC](/operate/kubernetes#secrets-management-for-oidc-client-secrets). ## What happens to user accounts **New user via OIDC** — AuthPlane auto-provisions a local user using the ID token's `email` and `name` claims. **Existing user with matching email** — AuthPlane links the OIDC identity to the existing local account. The user can then log in via either method (password OR OIDC) if `show_local_login: true`. **Force everyone through the IdP** — set `oidc.show_local_login: false` to hide the password form entirely. Existing local users can still be managed via the Admin API but can't log in with passwords via the UI. ## Validation and boot rules AuthPlane enforces on boot when `oidc.enabled: true`: - `oidc.issuer` is required. - `oidc.client_id` is required. - Exactly one of `oidc.client_secret` **or** `oidc.client_secret_ref` (env-var name) must be set. - `oidc.redirect_uri` is required. - `oidc.issuer` and `oidc.redirect_uri` must use HTTPS in production (i.e., when `server.issuer` is not localhost). The `oidc.scopes` list is **not** boot-checked; you should still include `openid` (and typically `email` and `profile`) so account provisioning has data to fill in — but a misconfigured list won't fail boot, it'll fail at first login. Boot fails with a structured error if any of the required keys is missing. ## Limitations - **Single upstream provider** at a time. Use Dex as a broker for multi-IdP setups. - **`email` claim required** in the ID token. Without it, AuthPlane can't create or match accounts. - **No group/role sync yet** — the `include_groups_scope` option pulls groups into the ID token when your IdP supports it, but AuthPlane doesn't use them for authorization decisions. Consent grants and per-tool scope enforcement remain the authorization model. ## Troubleshooting | Problem | Likely cause | Fix | |---|---|---| | "OIDC authentication failed" | Wrong `client_secret` or unreachable IdP | Check AuthPlane logs for `oidc` errors; verify `curl /.well-known/openid-configuration` from the AuthPlane host works | | User not created after successful login | IdP didn't return an `email` claim | Verify `email` is in `oidc.scopes` AND that your IdP app is configured to return email claims | | Redirect loop between AuthPlane and IdP | `oidc.redirect_uri` misconfigured | Must point to `https:///oidc/callback` — NOT your IdP's callback | | `redirect_uri mismatch` error from IdP | URI at IdP ≠ `oidc.redirect_uri` | Check exact match including trailing slash and port | | OIDC button doesn't appear on login page | `oidc.enabled: false` or config not loaded | Check config; if using env vars verify `AUTHPLANE_OIDC_ENABLED=true` in the running container | | Users authenticate but end up on a blank page | Session secret unstable (regenerated on restart) | Set `AUTHPLANE_SESSION_SECRET` to a stable 32+ byte value | ## Related - [Concepts: XAA (Cross-App Access)](/concepts/xaa) — different from OIDC federation. XAA lets your IdP assert *agent* identity; OIDC federation is about *user* identity. - [Reference: Configuration → OIDC](/reference/configuration#oidc--upstream-oidc-federation) — every field - [Topologies: OIDC-federated user login](/topologies/oidc-federated-login) — the full topology diagram - [Guides: Enterprise-Managed Auth (XAA)](/guides/xaa) — for enterprise agent identity, not user login - [Operate: Kubernetes → Secrets management](/operate/kubernetes#secrets-management-for-oidc-client-secrets) — production secret patterns --- ## guides/mcp-inspector.mdx --- title: Testing with MCP Inspector description: "Use the official MCP Inspector as a fast smoke test — verify PRM discovery, DCR, PKCE, consent, tool calls, and scope enforcement end to end." section: Guides sectionOrder: 4 order: 4 --- # Testing with MCP Inspector > **TL;DR** — `npx @modelcontextprotocol/inspector http://localhost:8080/mcp` runs the full OAuth flow against your server in the browser, exercising PRM discovery, RFC 8414 metadata, DCR, PKCE, authorize, consent, and tool calls with a real bearer token. If Inspector works, your server is spec-compliant. Every scenario Inspector exercises is also covered by AuthPlane's automated E2E suite (`e2e/scenarios/mcp_inspector_test.go`), so failures usually indicate operator misconfiguration, not spec drift. ## Why Inspector first MCP Inspector is the reference test client. It exercises **every** part of the spec MCP servers implement — the same wire moves Claude Desktop, Cursor, and VS Code Copilot Chat make — but with a debug UI that shows you every request, response, and token claim as they happen. For AuthPlane specifically, Inspector is the fastest way to answer "is my SDK adapter wired correctly?" because: - It runs the full OAuth flow against your PRM + AuthPlane in the browser. - It shows the discovered PRM document and AS metadata inline. - It shows the decoded access token so you can verify `aud`, `scope`, `cnf.jkt`. - Every failing step surfaces the raw `WWW-Authenticate` challenge and error body. ## Install and run ```bash npx @modelcontextprotocol/inspector http://localhost:8080/mcp ``` - **URL must include the `/mcp` path** (or whatever endpoint you configured — some SDKs default to something else). `http://localhost:8080` alone points at the server root, and Inspector reports "no tools". - Uses `npx` — no install step, always pulls the latest. - Opens `http://localhost:6274` (Inspector's own UI) in your default browser. For a remote server: ```bash npx @modelcontextprotocol/inspector https://mcp.example.com/mcp ``` ## What Inspector does — step by step Inspector runs the same seven-step OAuth flow every MCP client does. Watching each step in Inspector's UI is the fastest way to spot where things break. ### 1. Trigger the challenge → fetch PRM Inspector first hits your MCP endpoint unauthenticated, gets `401 WWW-Authenticate` with a `resource_metadata` URL, then follows that URL: ``` POST https://mcp.example.com/mcp # no Authorization → 401 challenge GET https://mcp.example.com/.well-known/oauth-protected-resource/mcp # path-scoped (RFC 9728 §3) ``` Inspector shows the response inline. Verify: - `resource` matches the URL you gave Inspector, byte-for-byte - `authorization_servers` contains a URL for your AuthPlane instance - `scopes_supported` lists your resource's declared scopes If the initial `POST /mcp` returns a bare `401` without `WWW-Authenticate`, your SDK's auth middleware is bypassed — the SDK generates that challenge automatically. If PRM is 404 at the path-scoped URL, your SDK didn't mount the PRM handler (Go only — Python/TS auto-mount). Fix: `http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())`. ### 2. Fetch AS metadata ``` GET https://auth.example.com/.well-known/oauth-authorization-server ``` Inspector uses `authorization_servers[0]` from the PRM. Verify: - `issuer` matches the URL Inspector is calling (no mismatch — one character off = every future token rejected) - `token_endpoint`, `authorization_endpoint`, `registration_endpoint`, `jwks_uri` all present - `grant_types_supported` includes `authorization_code` - `code_challenge_methods_supported: ["S256"]` (no `plain` — AuthPlane rejects it) ### 3. DCR — register itself as a client ``` POST https://auth.example.com/oauth/register { "redirect_uris": ["http://localhost:6274/oauth/callback"], "token_endpoint_auth_method": "none", ... } ``` Inspector uses `token_endpoint_auth_method: none` (public client + PKCE). AuthPlane returns a `client_id`. **If DCR fails** with `403 access_denied`: `dcr.mode` is `admin_only`. Either bump to `approved_redirects` with `http://localhost:*` in the list, or `open` for local dev. ### 4. Authorize + PKCE Inspector redirects your browser to: ``` GET https://auth.example.com/oauth/authorize?response_type=code &client_id=… &redirect_uri=http://localhost:6274/oauth/callback &code_challenge=…&code_challenge_method=S256 &resource=https://mcp.example.com/mcp &scope= &state=… ``` You land on the AuthPlane login page → log in → consent screen → approve. ### 5. Consent AuthPlane shows the scopes the client is requesting with their descriptions (pulled from the resource's `scopes` config). Approve. ### 6. Token exchange ``` POST https://auth.example.com/oauth/token grant_type=authorization_code&code=…&code_verifier=… ``` Inspector receives `access_token`, `refresh_token`, `token_type`. Its UI decodes and displays the JWT payload: - `sub` — your user - `aud` — should equal your resource URI - `scope` — should include what you approved on the consent screen - `iss` — should equal AuthPlane's issuer - `exp` — 15 minutes ahead by default - `cnf.jkt` — only if DPoP is enabled AND Inspector sent a DPoP header (Inspector doesn't do DPoP as of 0.14; token stays as bearer) ### 7. Tool calls ``` POST https://mcp.example.com/mcp Authorization: Bearer { "method": "tools/call", "params": { "name": "read", ... } } ``` Inspector's UI lets you invoke tools and shows the response. This is where scope enforcement, resource binding, and your handler logic all run for real. ## AuthPlane's coverage — Inspector is under E2E Every scenario above is exercised by `e2e/scenarios/mcp_inspector_test.go` in the authserver repo. The E2E suite spins up a real AuthPlane instance, runs Inspector programmatically, and asserts on the full wire flow. | Scenario | E2E test | |---|---| | PRM + AS metadata discovery | `TestMCPInspector_MetadataDiscovery` | | Dynamic Client Registration | `TestMCPInspector_DCR` | | PKCE (S256; `plain` rejected) | `TestMCPInspector_PKCEFlow` | | Token exchange (auth-code → tokens) | `TestMCPInspector_TokenExchange` | | Refresh token rotation | `TestMCPInspector_TokenRefresh` | | Tool call with Bearer token | `TestMCPInspector_ListTools` | If Inspector works against your AuthPlane instance, you're passing the same conformance tests AuthPlane's CI runs on every PR. ## Debugging with Inspector's request panel Inspector shows every request/response in a side panel. Useful patterns: - **PRM 404** → look at the `.well-known/oauth-protected-resource` request. Response body will be your server's 404 page. - **Discovery works but authorize 500s** → response body has the error. Common: `session.secret` not set in production config. - **Consent approves but `/oauth/token` returns `invalid_grant`** → `code_verifier` mismatch (client bug — unlikely for Inspector) OR the auth code expired (`AuthCodeTTL = 10 minutes` — if you *really* took your time on consent, or your client clock is skewed). - **Tool call returns 401** → response `WWW-Authenticate` header tells you why. `error="invalid_token"` = signature/aud/exp. `error="insufficient_scope"` = scope missing. - **Tool call returns "unknown tool"** despite the tool existing → PrefectHQ FastMCP filters unauthorized tools OUT of `tools/list` — check the token's scope claim against what `require_scope` demands. ## Using Inspector's CLI mode Non-interactive form for scripting: ```bash npx @modelcontextprotocol/inspector --cli https://mcp.example.com/mcp \ --bearer-token ``` Skips the OAuth flow — you provide a token you got another way (e.g., from `curl`). Useful for CI smoke tests where the OAuth flow can't run interactively. ## Common false alarms Not every "failure" is a real bug in your setup. - **DPoP not verified in Inspector** — Inspector 0.14.x doesn't construct DPoP proofs. Even with `dpop.enabled: true` on AuthPlane and `inbound_dpop: {required: true}` on your SDK, Inspector's tokens are plain bearer. To verify DPoP end-to-end, use a client that does construct proofs (a real MCP client, or your own SDK-based test client). - **`403` on the first tool call from Claude Code but Inspector works fine** — Claude Code's known bug: omits `scope` on `/authorize`. See [Guides: Connect an MCP client → Known Claude Code quirks](/guides/connect-mcp-client#known-claude-code-quirks). Fix: `AUTHPLANE_OAUTH_REQUIRE_SCOPE=false`. - **Inspector connects but you don't see the tool you just added** — check that you're on the latest revision of your MCP server; Inspector caches `tools/list` per-session but re-fetches on reconnect. ## Related - [Quickstart](/quickstart) — walks through Inspector as the smoke test at the end - [Guides: Connect an MCP client](/guides/connect-mcp-client) — Claude Desktop / Cursor / VS Code configs - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — why Inspector doesn't help verify DPoP - [Compatibility matrix](https://github.com/authplane/authserver/blob/main/docs/compatibility.md) — the full list of what Inspector tests - [Reference: Errors](/reference/errors) — decoding the responses Inspector shows you - [Troubleshooting: Debugging](/troubleshooting/debugging) — when Inspector fails but you don't know at which step --- ## guides/monitoring.mdx --- title: Monitoring & observability description: "Prometheus scrape config, alerting rules, structured logging, and OpenTelemetry traces — full observability wiring across every AuthPlane deployment mode." section: Guides sectionOrder: 4 order: 8 --- # Monitoring & observability > **TL;DR** — AuthPlane emits Prometheus metrics on `/metrics` by default, structured slog with trace/span/request IDs, and optional OpenTelemetry traces + metrics via OTLP. This guide has the Prometheus scrape config, six alert rules worth pasting straight in, a Grafana dashboard skeleton, and the OTEL config for the collector. Metrics catalog + names live in [Reference: Metrics & CLI](/reference/metrics-and-cli). ## Prometheus scrape AuthPlane exposes `/metrics` on the **admin port** (`9001` by default; registered ahead of the admin API-key auth, so scrapers don't need credentials) in Prometheus text format. Path and provider are configurable: ```yaml observability: metrics: provider: prometheus # "prometheus" | "otel" | "both" | "none" path: /metrics ``` Sample `prometheus.yml`: ```yaml scrape_configs: - job_name: authplane metrics_path: /metrics scrape_interval: 15s basic_auth: username: metrics # any non-empty username password: ${AUTHPLANE_ADMIN_API_KEY} static_configs: - targets: ['authplane:9001'] ``` For Kubernetes with the AuthPlane Helm chart, the chart ships a `ServiceMonitor` (Prometheus Operator CRD): ```yaml serviceMonitor: enabled: true interval: 15s ``` ## Six alerts worth having Start here, tune thresholds to your traffic. ```yaml groups: - name: authplane rules: - alert: AuthPlaneAuthDeniedSpike expr: rate(authserver_auth_denied_total[5m]) > 10 for: 5m labels: severity: warning annotations: summary: "Auth denials spiking on {{ $labels.instance }}" description: "> 10 auth denials/sec over 5 min — possible attack or misconfig" - alert: AuthPlaneRefreshTokenReuse expr: rate(authserver_refresh_token_reuse_total[5m]) > 0 for: 1m labels: severity: critical annotations: summary: "Refresh-token theft detected on {{ $labels.instance }}" description: "Refresh-token family revocation triggered — investigate immediately" - alert: AuthPlaneDPoPRejectionSpike expr: rate(authplane_dpop_proofs_rejected_total[5m]) > 5 for: 5m labels: severity: warning annotations: summary: "DPoP rejections spiking on {{ $labels.instance }}" description: "> 5 DPoP proof rejections/sec — client bug, replay attack, or reverse-proxy htu mismatch" - alert: AuthPlaneTokenIssuanceSlow expr: histogram_quantile(0.99, rate(authserver_token_issuance_duration_seconds_bucket[5m])) > 0.5 for: 10m labels: severity: warning annotations: summary: "p99 token issuance > 500 ms on {{ $labels.instance }}" description: "DB or Vault Transit latency degrading the token endpoint" - alert: AuthPlaneKeyRotationStale # No gauge for last-rotation timestamp; alert if the counter has not moved # in the target window. Combine with an absent() check to catch fresh # deployments where the counter simply hasn't fired yet. expr: (increase(authserver_key_rotation_total[90d]) == 0) or absent(authserver_key_rotation_total) for: 1h labels: severity: warning annotations: summary: "Signing key not rotated in > 90 days on {{ $labels.instance }}" description: "Rotate via `authserver admin key rotate` or POST /admin/keys/rotate" - alert: AuthPlaneUpstreamRefreshFailing expr: rate(authserver_upstream_token_refresh_total{outcome="failed"}[15m]) > 0 for: 15m labels: severity: warning annotations: summary: "Upstream provider refresh failing on {{ $labels.instance }}" description: "Broker refresh grant rejected by upstream — user may need to reconnect" ``` ## Grafana dashboard skeleton Panels worth having on day one: | Panel | PromQL | |---|---| | Requests/sec by grant type | `sum by (grant_type) (rate(authserver_tokens_issued_total[5m]))` | | Auth denials/sec | `rate(authserver_auth_denied_total[5m])` | | Token issuance p50/p95/p99 | `histogram_quantile(0.99, rate(authserver_token_issuance_duration_seconds_bucket[5m]))` | | DPoP validated vs rejected | `rate(authplane_dpop_proofs_validated_total[5m])` and `..._rejected_total` on same axis | | Introspection latency | `histogram_quantile(0.95, rate(authserver_introspection_duration_seconds_bucket[5m]))` | | Refresh-token rotations | `rate(authserver_tokens_refreshed_total[5m])` | | Reuse-detected revocations | `rate(authserver_refresh_token_reuse_total[5m])` — should be near-zero | | Upstream vends/sec | `rate(authserver_upstream_token_issued_total[5m])` per Broker resource | | Active token families | `authserver_active_token_families` | | HTTP request rate by status | `sum by (status) (rate(authserver_http_requests_total[5m]))` | Full metric catalog with descriptions in [Reference: Metrics & CLI](/reference/metrics-and-cli#metrics-catalog). ## Structured logging (slog) AuthPlane logs via Go's stdlib `slog` — JSON by default in production, plain text in dev. Every request emits: ``` 2026-07-01T00:14:20Z INFO msg="token issued" grant_type=authorization_code client_id=my-client sub=user-42 resource=https://mcp.example.com/mcp scope="tools/read" jti=jti_abc123 request_id=r_abc123 trace_id=t_def456 span_id=s_ghi789 ``` Configure: ```yaml observability: logging: level: info # debug | info | warn | error format: json # json | text add_source: false # true = include file:line outputs: stdout: true # print to stdout (typical container pattern) otel: false # ship via OTLP to a log backend otel_endpoint: "" insecure: false ``` Shipping to Loki, Elasticsearch, Splunk, or CloudWatch: use their standard container-log scraper (Promtail, Fluent Bit, CloudWatch agent) reading stdout. Every field is JSON-queryable. ## OpenTelemetry — traces + logs + metrics Wire an OTEL collector for distributed tracing across your MCP client → AuthPlane → your MCP server: ```yaml observability: logging: outputs: otel: true otel_endpoint: otel-collector.monitoring:4317 insecure: true # allow plaintext gRPC (fine inside cluster) tracing: enabled: true endpoint: otel-collector.monitoring:4317 insecure: true sample_rate: 1.0 # 0.0..1.0 — 1.0 = everything metrics: provider: both # scrape via /metrics AND push OTLP otel_endpoint: otel-collector.monitoring:4317 insecure: true ``` Sample rate matters at scale — start at `1.0` for a week of observability, then drop to `0.01`–`0.1` and use tail-based sampling in the collector for latency outliers + errors. ## What traces cover Each incoming request generates a span with children for: - HTTP handler - Service-layer operation (`AuthorizeService.StartAuthorization`, `TokenService.ExchangeCode`, etc.) - Storage-adapter operations (Postgres query, SQLite exec) - Crypto operations (JWT signing, DPoP proof verification) - Outbound HTTP (upstream OIDC callback, JWKS refresh, broker provider vend) `trace_id` propagates from any inbound `traceparent` header (W3C Trace Context). Ship your MCP client's traces to the same OTEL backend and you can see the full request path from client tool call → AuthPlane token issuance → your MCP server tool handler in one trace. ## Health checks ``` GET /health # 200 OK / 503 (DB unreachable) GET /ready # 200 OK / 503 (not ready to serve) ``` Both are unauthenticated and cheap. Wire to your container orchestrator's liveness/readiness probes. Kubernetes example: ```yaml livenessProbe: httpGet: path: /health port: 9000 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /ready port: 9000 initialDelaySeconds: 5 periodSeconds: 10 ``` ## Audit log Every security-relevant event (login, consent, token issuance, revocation, admin action) writes an `audit_events` row queryable via: ``` GET /admin/audit?since=2026-06-01T00:00:00Z&kind=token_issued&user_id=user-42&limit=50 ``` The audit records are exposed only over the admin API — there's no dedicated `admin audit …` CLI subcommand. Use `curl` (or your admin client) against the endpoint above. Combined with the structured logs, the audit log is the queryable long-term record. Structured logs are the ephemeral, high-cardinality stream; audit is the durable forensic record. ## Related - [Reference: Metrics & CLI](/reference/metrics-and-cli) — every metric name, label, and description - [Reference: Configuration → observability](/reference/configuration#observability) — every knob - [Operate: Kubernetes](/operate/kubernetes#observability) — Helm-side wiring (`serviceMonitor`, `extraEnv` for OTEL) - [Operate: Docker Compose](/operate/docker-compose) — SIGHUP for hot key reload (useful in scripted rotation with monitoring) - [Troubleshooting: Debugging](/troubleshooting/debugging) — using logs + metrics to diagnose failures --- ## guides/runtime-client-binding.mdx --- title: Runtime client binding description: "policy.runtime.client_ids — declare which OAuth client_ids are authorized to act AS a given Mint resource. Default-deny; used by the broker actor-attestation gate." section: Guides sectionOrder: 4 order: 7 --- # Runtime client binding > **TL;DR** — Every Mint resource can carry a `policy.runtime.client_ids` list — the OAuth `client_id`s allowed to act AS that resource at `/oauth/token`. Empty list means **default-deny**. You need this when an agent or gateway exchanges a token *for* a Broker resource and there's no fronting link covering the source→target pair — the broker actor-attestation gate uses this list to resolve which resource the caller represents. Everywhere else, ignore it. ## OAuth client vs Resource — two identities `client_id` and `resource` are separate concepts with an N:N runtime relationship — not 1:1. | Concept | Identifier | Lifecycle | |---|---|---| | OAuth client | `client_id` (auto-generated, opaque, rotatable) | Issued at registration; rotated independently | | Resource | `slug` (operator-meaningful, human-readable) | Managed in the resources table; embedded in URLs, audit rows, scope strings | One OAuth client can act in different roles across calls (admin, agent, broker actor). One resource can be served by multiple OAuth client identities (prod tier, canary, multi-region). The role a client plays in any given call **cannot be derived from `client_id` alone** — it needs explicit operator declaration. That's what `runtime.client_ids` provides. ## When you need it Set `runtime.client_ids` on a **Mint resource** whenever an OAuth client authenticates to `/oauth/token` and the **broker actor-attestation gate** must resolve that client to its actor MCP. Concretely — any time an agent or gateway exchanges a token *for* a Broker resource, and there is no fronting link covering the (source→target) pair. ## When you DON'T need it - Exchange is for a **Mint** resource (the actor-attestation gate runs only on the broker dispatch path). - A **fronting link** covers the (source → target) pair (the fronted-broker bypass replaces this gate with the operator-vouching declaration). - The dispatch path doesn't hit the gate — **direct user→MCP, fronted Mint→Mint, refresh, client_credentials, jwt-bearer, authorization_code** — none of these use `runtime.client_ids`. If your topology is [Agent + single MCP](/topologies/single-mcp) or [Direct fanout](/topologies/direct-fanout), skip this whole page. ## Default semantics — default-deny (unlike `exchange.allowed_client_ids`) Empty `runtime.client_ids` = **no client may act AS this resource.** This is the **opposite** default of `policy.exchange.allowed_client_ids` (which is permissive when empty). Both defaults are deliberate: - **`exchange.allowed_client_ids`** (empty = "any allowed") — defense-in-depth lives elsewhere on this gate (the per-MCP consent_grants check). - **`runtime.client_ids`** (empty = "no one allowed") — this gate is the *only* place the runtime resolves the actor role. Permissive empty would defeat it. ## Configuring it ### Admin UI Resources drawer → **Runtime clients (act AS this resource)** section. Pick clients from the dropdown; click a chip to remove. Save sends only the dirty section — unrelated edits can't accidentally widen the list. ### Admin API ```bash # Add a client curl -X POST http://localhost:9001/admin/resources/mcp-gw/policy/runtime/client-ids \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"client_id":"gw_prod_id"}' # List authorized clients curl http://localhost:9001/admin/resources/mcp-gw/policy/runtime/client-ids \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Remove a client curl -X DELETE http://localhost:9001/admin/resources/mcp-gw/policy/runtime/client-ids/gw_prod_id \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" ``` ### CLI ```bash authserver admin resource runtime-client add --slug mcp-gw --client-id gw_prod_id authserver admin resource runtime-client list --slug mcp-gw authserver admin resource runtime-client remove --slug mcp-gw --client-id gw_prod_id ``` ### YAML (seed data — first boot only) ```yaml resources: - slug: mcp-gw backend_kind: mint policy: exchange: allowed_client_ids: [agent_x, agent_y] # who may exchange INTO this Resource runtime: client_ids: [gw_prod_id, gw_canary_id] # who may act AS this Resource ``` ## Multi-tier deployments Where `runtime.client_ids` really pays off: you rotate the OAuth client for your prod gateway (`gw_prod_v2_id`) while the canary tier still runs the old client (`gw_prod_id`). Both are simultaneously valid actors for the same Mint resource because both are on the list. Zero downtime, no code change on the gateway side. ## Failure modes | Symptom | Cause | Fix | |---|---|---| | `400 invalid_grant "client is not an authorized actor for this resource"` on broker exchange | The calling client_id isn't in `runtime.client_ids` for the source Mint resource | Add via `admin resource runtime-client add` | | Same error, but the client IS in the list per `admin resource runtime-client list` | Wrong resource slug — the client is on a different resource's list | Confirm which source resource the exchange is naming | | Gateway rotation broke exchange right after rollout | You added the new client to `runtime.client_ids` but forgot the old one; or vice versa | Keep both while the fleet transitions | ## Related - [Topologies: MCP gateway → Broker](/topologies/mcp-gateway-broker) — the topology where this gate applies - [Topologies: MCP gateway → hidden Mint](/topologies/mcp-gateway-mint) — the fronting-link case where `runtime.client_ids` is bypassed - [Reference: Configuration → resources](/reference/configuration#resources) — the `policy` block - [Guides: Wire up the Token Vault](/guides/token-vault) — the broker dispatch path that consults this list --- ## guides/token-vault.mdx --- title: Wire up the Token Vault description: "End-to-end setup for vending upstream OAuth tokens (GitHub, Slack, Google) from your MCP tools — encryption, broker providers, Connect flow, URL elicitation." section: Guides sectionOrder: 4 order: 4 --- # Wire up the Token Vault > **TL;DR** — Four steps: turn on data encryption at rest, register broker providers (GitHub, Slack, Google, custom), configure the Connect flow, then vend upstream tokens from your MCP tools via RFC 8693 token exchange. The SDK translates upstream consent gaps into MCP JSON-RPC `-32042` elicitation so your tool code stays clean. The "Token Vault" name was retired in v0.1.0-rc1; the unified Resource model with `mint` / `broker` backends is what actually implements it. ## The four steps at a glance 1. **Encryption** — `data_encryption` block (AES master key or Vault Transit). 2. **Broker providers** — register each upstream OAuth provider you'll vend from. 3. **Connect flow** — `connect.*` config for the browser-based user grant flow. 4. **Vend from tools** — `client.exchange()` in your handler code. Each step is a few lines. Full source: `authserver/docs/how-to/upstream-connections.md`. ## Step 1 — Data encryption at rest Upstream refresh grants live in `broker_grants` — never plaintext on disk. Pick a driver: ### AES master key (simpler) ```yaml data_encryption: driver: aes_master aes_master: key_env: AUTHPLANE_DATA_ENCRYPTION_KEY ``` ```bash export AUTHPLANE_DATA_ENCRYPTION_KEY=$(openssl rand -hex 32) ``` ### HashiCorp Vault Transit (enterprise) ```yaml data_encryption: driver: vault_transit_encrypt vault_transit_encrypt: address: https://vault:8200 auth_method: approle mount_path: transit key_name: authserver-data approle: role_id_env: VAULT_APPROLE_ROLE_ID secret_id_env: VAULT_APPROLE_SECRET_ID ``` Plaintext never touches the AuthPlane process. Details in [Operate: Vault Transit](/operate/vault-transit). ## Step 2 — Register broker providers Each `broker_providers:` entry is a third-party OAuth upstream (or API key / service account) that AuthPlane can vend from. Manage via the Admin API in production; YAML below is convenient seed data on first boot. ```yaml broker_providers: - slug: github display_name: GitHub protocol: oauth config_data: client_id: "your-github-oauth-app-client-id" client_secret_ref: CONNECTOR_GITHUB_SECRET authorize_url: https://github.com/login/oauth/authorize token_url: https://github.com/login/oauth/access_token - slug: slack display_name: Slack protocol: oauth config_data: client_id: "your-slack-app-client-id" client_secret_ref: CONNECTOR_SLACK_SECRET authorize_url: https://slack.com/oauth/v2/authorize token_url: https://slack.com/api/oauth.v2.access response_format: form - slug: google display_name: Google protocol: oauth config_data: client_id: "your-google-oauth-client-id" client_secret_ref: CONNECTOR_GOOGLE_SECRET authorize_url: https://accounts.google.com/o/oauth2/v2/auth token_url: https://oauth2.googleapis.com/token extra_auth_params: access_type: offline # Google refresh token ``` **Registering the upstream OAuth app** (GitHub example): 1. GitHub → Settings → Developer settings → OAuth Apps → New. 2. Set callback URL to `http://localhost:9000/connect/github/callback` (or your production URL). 3. Copy client_id + secret into the config. Repeat per provider. Each broker provider is paired with one or more Broker `resources:` that name the fine-grained scopes AuthPlane may vend. Full example: [`authserver/examples/configs/resources-broker-providers.yaml`](https://github.com/authplane/authserver/tree/main/examples/configs). **Already-seeded providers:** YAML `broker_providers:` is only applied when a provider doesn't already exist. Subsequent edits don't propagate — use `PATCH /admin/broker-providers/{id}` or the Admin UI. ## Step 3 — Connect flow config ```yaml connect: state_secret: AUTHPLANE_CONNECT_STATE_SECRET allowed_return_urls: - http://localhost:* - https://myapp.example.com/* redirect_base_url: http://localhost:9000 ``` ```bash export AUTHPLANE_CONNECT_STATE_SECRET=$(openssl rand -base64 32) ``` `allowed_return_urls` prevents open-redirect attacks — only URLs matching the patterns can be used as `return_url` on `/connect/{provider}?return_url=...`. ## Step 4 — Vend from your tool code dict[str, str]: require_scope("tools/read_calendar") inbound = get_access_token() downstream = await auth.client.exchange(TokenExchangeOptions( subject_token=inbound.token, resources=("google",), # broker provider slug scope="https://www.googleapis.com/auth/calendar", )) # downstream.access_token is a real Google Calendar token, expires_in ~1h return {"token_type": downstream.token_type, "expires_in": str(downstream.expires_in)}`} tsFile="server.ts" ts={`server.addTool({ name: "read_calendar", parameters: z.object({}), canAccess: requireScopes("tools/read_calendar"), execute: async () => { const inbound = /* your framework's access-token accessor */; const downstream = await auth.client.exchange({ subjectToken: inbound, resources: ["google"], scope: "https://www.googleapis.com/auth/calendar", }); // downstream.access_token — real Google Calendar bearer return { content: [{ type: "text", text: \`token_type=\${downstream.token_type}\` }] }; }, });`} goFile="main.go" go={`func readCalendarHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { inbound, _ := authplanemcp.TokenFromContext(ctx) resp, err := adapter.Client().Exchange(ctx, authplane.ExchangeRequest{ SubjectToken: inbound, Resources: []string{"google"}, Scope: "https://www.googleapis.com/auth/calendar", }) if err != nil { return nil, err } // resp.AccessToken — real Google Calendar bearer return mcp.NewToolResultTextf("token_type=%s", resp.TokenType), nil }`} /> Requirements checklist: - `token_exchange.enabled: true` in AuthPlane config (or `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true`). - Your MCP server's OAuth client has `urn:ietf:params:oauth:grant-type:token-exchange` in its `grant_types`. - The Broker resource's `policy.exchange.allowed_client_ids` either includes your MCP server's client_id, or is empty (allow any consented client). ## Connect flow — user grants access Direct the user's browser to: ``` GET /connect/github?return_url=http://localhost:3000/connected ``` The AS runs the OAuth handshake with GitHub, callback stores an encrypted row in `broker_grants`, then redirects the browser to `return_url`. First time only per (user, provider). ## URL elicitation — the SDKs handle it for you If your tool calls `client.exchange(...)` for a provider the user hasn't connected yet, or with scopes exceeding their grant, AuthPlane returns `error=consent_required` with a `consent_url` pointing at `/connect/{provider}`. The SDK **auto-translates** this into MCP JSON-RPC `-32042` `UrlElicitationRequiredError` before it reaches your handler: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32042, "message": "URL elicitation required", "data": { "elicitation_id": "elic_...", "url": "https://auth.example.com/connect/github?..." } } } ``` The MCP client shows the URL to the user, they complete the Connect flow, then the client retries the original `tools/call`. **No try/except in your tool code needed** — see [SDKs: Python](/sdks/python) / [TypeScript](/sdks/typescript) / [Go](/sdks/go) elicitation sections. ## The three-bound consent model — what AuthPlane checks per vend Every `client.exchange(...)` runs through five gates: | Gate | Check | Failure | |---|---|---| | A | Requested scopes are recognized for this resource | `invalid_scope` | | B | `consent_grants` row exists for `(user, agent, resource)` | `consent_required` (AS-side re-consent) | | C | requested ⊆ `consent_grants.scopes` | `consent_required` (AS-side re-consent) | | D | `broker_grants` row exists for `(user, broker_provider)` | `consent_required` (upstream `/connect/{provider}`) | | E | upstream-mapped scopes ⊆ `broker_grants.scopes_granted` | `consent_required` (upstream re-connect) | The `cause` sub-discriminator (`consent_missing` vs `scope_insufficient`) picks the right `consent_url`. The SDK translates all of these into `-32042` for you. ## Refresh + concurrent vends Upstream tokens expire. AuthPlane refreshes them transparently using the stored refresh-grant when a vend request comes in for an expired token. **Concurrent vends** — if two vend requests arrive at the same time and both trigger a refresh, optimistic locking serializes them. The second gets HTTP `423 Locked`. **Retry once on 423** — SDK clients handle this automatically. **Refresh failure** (user revoked upstream access) — HTTP `400` `error=consent_required`, `cause=consent_missing`, `consent_url` back to `/connect/{provider}`. Same elicitation flow. ## Listing and disconnecting ``` GET /connections # user's session cookie — list connected providers DELETE /connections/{provider} # user disconnects a single provider ``` Admin API surfaces the same data per-user under `/admin/grants`. ## Related - [Concepts: Token Vault](/concepts/token-vault) — mental model + the three-bound consent - [Guides: Upstream connections](/guides/upstream-connections) — deep dive on broker providers per protocol - [Reference: Configuration → broker_providers + connect](/reference/configuration#broker_providers) — every knob - [Topologies: Agent + brokered MCP](/topologies/broker-mcp) — the topology diagram end to end - [SDKs: Python](/sdks/python) / [TypeScript](/sdks/typescript) / [Go](/sdks/go) — per-language elicitation handling --- ## guides/upstream-connections.mdx --- title: Upstream connections description: "Register broker providers of three protocol types (oauth, apikey, service_account), configure the Connect flow, and understand the three-bound consent model." section: Guides sectionOrder: 4 order: 5 --- # Upstream connections > **TL;DR** — Broker providers are the abstraction AuthPlane uses to vend third-party access. Three protocol adapters: **`oauth`** (GitHub, Slack, Google, any RFC 6749 provider), **`api_key`** (static per-user API key with rotation semantics), **`service_account`** (impersonation via a service account credential). Each provider's `config_data` shape is protocol-specific. This guide covers the per-protocol setup + the Connect flow policy knobs. ## When to use each protocol | Protocol | Use case | Examples | |---|---|---| | **`oauth`** | Third-party services that support OAuth 2.0 / 2.1 with refresh tokens | GitHub, Slack, Google, Linear, Notion, Atlassian | | **`api_key`** | Services that only support static API keys — user pastes their key into the Connect UI, AuthPlane stores it encrypted | Anthropic, OpenAI, weather APIs, internal legacy APIs | | **`service_account`** | Machine identity — one credential shared across users (with per-user consent recorded separately) | GCP service accounts, machine tokens for internal APIs | ## Registering a `oauth` provider Standard OAuth 2.0 flow. `config_data` fields: ```yaml broker_providers: - slug: github display_name: GitHub protocol: oauth config_data: client_id: "your-github-oauth-app-client-id" client_secret_ref: CONNECTOR_GITHUB_SECRET # env var name; NOT the secret itself authorize_url: https://github.com/login/oauth/authorize token_url: https://github.com/login/oauth/access_token response_format: standard # one of: standard, form extra_auth_params: # optional; per-provider access_type: offline # Google refresh token prompt: consent # forces refresh_token when combined with access_type=offline ``` **Register the upstream OAuth app first** — set callback URL to `https:///connect/{slug}/callback` (e.g., `/connect/github/callback`). Then copy the client_id and secret name into `config_data`. ## Registering an `api_key` provider For services with no OAuth flow — user pastes an API key into the Connect UI: ```yaml broker_providers: - slug: anthropic display_name: Anthropic API protocol: api_key config_data: header_name: X-Api-Key # required — header the key is sent under header_prefix: "" # optional — e.g., "Bearer " for Authorization issuance_instructions_url: https://console.anthropic.com/settings/keys # optional — link shown on Connect form ``` Vending returns the stored key as `access_token` plus the `header_name` / `header_prefix` so the SDK can present it correctly. No refresh — the key is static until the user rotates it by re-running the Connect flow (`GET /connect/{slug}`) with a new key. ## Registering a `service_account` provider For machine identity — one credential per provider, per-user consent still recorded: ```yaml broker_providers: - slug: gcp-sa display_name: GCP Service Account protocol: service_account config_data: token_url: https://oauth2.googleapis.com/token sa_email: mcp-broker@your-project.iam.gserviceaccount.com sa_key_ref: GCP_SERVICE_ACCOUNT_JSON # env var name holding the JSON credential ``` The service account credential is stored once; per-user consent grants are still tracked in `consent_grants` for audit purposes. Vending returns a fresh short-lived Google token minted from the service account. ## Attaching a broker provider to a Broker resource Every broker provider needs at least one Broker `resources:` entry: ```yaml resources: - slug: github-repos display_name: GitHub Repo Access backend_kind: broker broker_provider_slug: github # reference by slug scopes: - name: repo:read description: Read the user's repositories upstream: repo # scope requested from GitHub - name: repo:write description: Push commits and create PRs upstream: "repo,user" # multi-scope upstream mapping ``` The `scopes[].name` is what AuthPlane consumers see (in consent screens, PRM `scopes_supported`); `upstream` is what AuthPlane requests from the provider. Multi-scope upstream lets one AuthPlane scope cover multiple provider scopes atomically. ## Connect flow — policy knobs Global `connect:` config: ```yaml connect: state_secret: AUTHPLANE_CONNECT_STATE_SECRET allowed_return_urls: # global default - http://localhost:* - https://myapp.example.com/* redirect_base_url: http://localhost:9000 # base for OAuth callbacks ``` **Per-resource override** — each Broker resource can narrow the return URLs: ```yaml resources: - slug: github-repos backend_kind: broker broker_provider_slug: github policy: connect: allowed_return_urls: - https://myapp.example.com/gh-callback # only THIS URL for this resource ``` ## User's-eye view of Connect Direct the user's browser to: ``` GET /connect/github?return_url=http://localhost:3000/connected ``` Flow: 1. AS renders/redirects to a login page if the user isn't signed in. 2. AS redirects to GitHub's `authorize_url` with client_id + scopes + state token. 3. User approves at GitHub. 4. GitHub redirects to `/connect/github/callback?code=...&state=...`. 5. AS validates state, exchanges code at GitHub's `token_url`, encrypts the refresh-grant, writes a `broker_grants` row. 6. AS redirects browser to `return_url` (validated against `allowed_return_urls`). ## Listing + disconnecting **User endpoints** (session cookie auth): ``` GET /connections # user's session cookie — list connected providers DELETE /connections/{provider} # user disconnects a single provider GET /connect/{provider} # re-running Connect rotates the key / re-consents ``` **Admin endpoints** (API key auth): ``` GET /admin/users/{id}/grants # all consent + broker grants for a user DELETE /admin/grants/broker/{id} # revoke a specific broker grant DELETE /admin/grants/consent/{id} # revoke a per-agent consent attestation ``` Revoking a broker grant clears AuthPlane's local record and stops future vends — it does **not** cascade a revocation to the upstream provider (upstream tokens are not AS-revocable). The user must also disconnect at the provider if that matters. ## Encryption at rest Every `broker_grants` row is encrypted before write. Driver picked via `data_encryption:` — either AES-256-GCM with HKDF-derived per-purpose subkeys (`aes_master`), or delegated to Vault Transit (`vault_transit_encrypt`). See [Guides: Wire up the Token Vault → Step 1](/guides/token-vault#step-1--data-encryption-at-rest) and [Operate: Vault Transit](/operate/vault-transit). ## Concurrent vends and refresh Upstream tokens expire; AuthPlane refreshes using the stored refresh-grant. Under concurrent vend load: - **Two vends arrive simultaneously, both need refresh** — optimistic locking serializes. Second returns HTTP `423 Locked`. **Retry once on 423** — SDK clients do this automatically. - **Refresh fails** (upstream rejected the refresh grant — user revoked access, credential expired, etc.) — HTTP `400 consent_required cause=consent_missing`, `consent_url` back to `/connect/{provider}`. SDK translates to MCP `-32042`. ## Security considerations - **Never put secrets directly in `config_data`** — always use the `_ref` variants (`client_secret_ref`, `sa_key_ref`), which point at env-var names rather than the secret itself. The YAML config is deliberately not marked secret; the env vars are. - **`allowed_return_urls` is the open-redirect defense** — never leave it empty in production. Broad patterns like `https://*.example.com/*` are fine; `*` alone is not. - **`state_secret` must be 32+ bytes** — HMAC-signed state tokens prevent CSRF on the callback. - **Broker grants are encrypted at rest** — data-encryption is a hard requirement, not optional, when `broker_providers:` is non-empty. Boot fails otherwise. ## Related - [Guides: Wire up the Token Vault](/guides/token-vault) — the higher-level "get me a GitHub token from my tool" walkthrough - [Concepts: Token Vault](/concepts/token-vault) — three-bound consent model in depth - [Concepts: Grants & flows](/concepts/grants-and-flows#token-exchange-rfc-8693) — the token-exchange grant used for vending - [Reference: Configuration → broker_providers](/reference/configuration#broker_providers) — every field with defaults - [Topologies: Agent + brokered MCP](/topologies/broker-mcp) — end-to-end topology - [Reference: Errors → Broker/Vault](/reference/errors#broker--vault) — full error catalog for this path --- ## guides/xaa.mdx --- title: Enterprise-Managed Auth (XAA) description: "End-to-end setup for JWT Bearer + the ID-JAG assertion profile (emerging IETF draft) — your enterprise IdP asserts agent identity, AuthPlane mints MCP tokens without per-user consent." section: Guides sectionOrder: 4 order: 6 --- # Enterprise-Managed Auth (XAA) > **TL;DR** — Your corporate IdP (Okta, Entra ID, Auth0) signs an "ID-JAG" JWT asserting agent identity. AuthPlane validates it against the IdP's JWKS and mints an MCP token — skipping per-user consent for policy-approved agent×scope×resource combinations. Requires `xaa.enabled: true`, one or more trusted IdP registrations, at least one policy, and MCP clients registered with the `jwt-bearer` grant. YAML-only at v0.1.x (env-var overrides not exposed). ## When XAA fits Use XAA when: - Enterprise wants central control over which agents can call which tools, at which scope levels. - Per-user consent screens don't fit the environment (headless agents, CI runners, no browser). - Your IdP can be extended to sign a custom JWT type (ID-JAG) — Okta, Entra ID, Auth0, and any OIDC provider that supports custom token types. **When NOT to use XAA**: you have interactive users who can consent in a browser — use plain [OIDC federation](/guides/federate-idp) instead. XAA replaces user consent with enterprise policy; that's the whole point. ## Prerequisites - AuthPlane running with `xaa.enabled: true` - A trusted IdP that can sign ID-JAG assertions (JWT with `typ: oauth-id-jag+jwt`) - Admin API key (`AUTHPLANE_ADMIN_API_KEY`) ## Enable XAA ```yaml xaa: enabled: true token_expiry: 1h # issued access-token lifetime max_assertion_age: 5m # max age of an ID-JAG at time of exchange require_resource: false # require ?resource=... on token requests subject_mode: auto_map # "auto_map" or "strict" jwks_cache_ttl: 1h # how long IdP JWKS keys are cached ``` At v0.1.x, all XAA config is **YAML-only** — no env-var overrides. ## Step 1 — Register a trusted IdP Tell AuthPlane which IdPs it should trust for XAA assertions. The IdP's issuer + JWKS URI + audience. ```bash curl -s -X POST http://localhost:9001/admin/idps \ -H "Authorization: Bearer $ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp Okta", "issuer": "https://acme.okta.com", "jwks_uri": "https://acme.okta.com/.well-known/jwks.json", "audience": "https://auth.example.com" }' ``` - `audience` — the value the IdP will put in the ID-JAG's `aud` claim. Defaults to your AS's issuer URL if omitted. - `jwks_uri` — auto-discovered from `{issuer}/.well-known/openid-configuration` if omitted (SSRF-protected fetch). Response includes `id: "idp_..."` — the ID you'll reference in policies. JWKS keys are cached for `xaa.jwks_cache_ttl`; force refresh with `POST /admin/idps/{id}/refresh-keys`. There's no CLI subcommand for XAA IdPs — the `curl` above is the canonical way to register a trusted IdP. Every other XAA resource (policies, subject mappings) is admin-REST-only too. ## Step 2 — Create an authorization policy Policies control which (IdP × client × scope × resource) combinations AuthPlane will mint tokens for. ```bash curl -s -X POST http://localhost:9001/admin/xaa/policies \ -H "Authorization: Bearer $ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Allow Acme agents", "idp_id": "idp_abc123", "client_ids": ["my-mcp-client"], "scopes": ["tools/echo", "tools/search"], "resources": ["https://mcp.example.com/mcp"] }' ``` | Field | Required | Semantics | |---|---|---| | `idp_id` | yes | Which trusted IdP this policy applies to | | `client_ids` | no | Restrict to specific MCP client IDs. Empty = all clients registered with this IdP | | `scopes` | no | **Max** allowed scopes. Issued token's scope = intersection(requested, this list). Empty = client's default scopes | | `resources` | no | Allowed target resources. Empty = all resources | Deny by default — a request that matches no policy is rejected. Multiple policies can apply; if any allows, request proceeds. ## Step 3 — Subject mapping (optional) Controls how the external IdP's `sub` claim maps to a local user identity. **Two modes** (set via `xaa.subject_mode` in YAML): - **`auto_map`** (default) — external subjects accepted as-is. The issued token's `sub` claim uses the format `{issuer}:{idp_subject}` (e.g., `https://acme.okta.com:alice@acme.com`). - **`strict`** — only explicitly mapped subjects are allowed. Unmapped subjects → `access_denied`. Create explicit mappings: ```bash curl -s -X POST http://localhost:9001/admin/xaa/subject-mappings \ -H "Authorization: Bearer $ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "idp_id": "idp_abc123", "idp_subject": "alice@acme.com", "local_user_id": "usr_local_alice" }' ``` When a mapping exists (in either mode), the token's `sub` uses `local_user_id` instead of the federated format — useful when you want AuthPlane audit rows tied to your own user IDs, not the IdP's opaque subject strings. ## Step 4 — Register an MCP client with `jwt-bearer` grant The MCP client must have `urn:ietf:params:oauth:grant-type:jwt-bearer` in its `grant_types`: ```bash curl -s -X POST http://localhost:9000/oauth/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "enterprise-agent", "grant_types": ["urn:ietf:params:oauth:grant-type:jwt-bearer"], "token_endpoint_auth_method": "client_secret_post" }' ``` Response includes the `client_id` and `client_secret` to use on token requests. Confidential clients (`client_secret_post` or `client_secret_basic`) are required — public clients can't do XAA. ## Step 5 — Exchange an ID-JAG for an access token The IdP signs an ID-JAG assertion. The MCP client presents it to AuthPlane: ```bash curl -s -X POST http://localhost:9000/oauth/token \ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ -d "assertion=$ID_JAG_JWT" \ -d "client_id=$CLIENT_ID" \ -d "client_secret=$CLIENT_SECRET" \ -d "scope=tools/echo" \ -d "resource=https://mcp.example.com/mcp" ``` Response: ```json { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "scope": "tools/echo" } ``` The issued token is a standard RFC 9068 AT-JWT — validate it the same way as any other AuthPlane token in your MCP server. DPoP works the same way (send a `DPoP` header on the `/oauth/token` call to get a DPoP-bound token). ## ID-JAG assertion format The JWT your IdP signs must have: **Header:** ``` { "alg": "RS256|ES256|PS256", "typ": "oauth-id-jag+jwt", "kid": "..." } ``` **Payload:** ``` { "iss": "", // must match a registered trusted IdP "aud": "", // must match the IdP's registered audience "sub": "", // per your IdP's convention "exp": , // must be within max_assertion_age "iat": , "jti": "" // replay-guarded — single use } ``` Extra claims (`groups`, `department`, custom claims) are ignored by AuthPlane at v0.1.x — they can influence policy decisions in a future release. Your IdP can include whatever's convenient. ## Testing with `xaa.dev` If you don't have an enterprise IdP handy, use [xaa.dev](https://xaa.dev) — the public XAA playground built on Okta. Full walkthrough with ngrok in [`authserver/docs/how-to/test-xaa-with-xaa-dev.md`](https://github.com/authplane/authserver/blob/main/docs/how-to/test-xaa-with-xaa-dev.md). Two-minute setup: register `idp.xaa.dev` as a trusted IdP, run AuthPlane behind ngrok, request an ID-JAG from xaa.dev's playground UI, present it to your `/oauth/token`. ## Metrics Once XAA is in prod: - `authplane_xaa_policy_evaluation_total{decision="allow|deny"}` — every policy check - `authplane_xaa_idp_operations_total` — IdP registry CRUD + JWKS refresh - `authplane_xaa_subject_resolutions_total` — subject mapping resolutions `rate(authplane_xaa_policy_evaluation_total{decision="deny"}[5m])` spiking = policy misconfig or unauthorized attempts. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `400 invalid_grant "assertion validation failed"` | Signature invalid (wrong JWKS key), or `iss`/`aud`/`exp` mismatch | Verify the IdP is registered; check assertion claims against expected values | | `400 invalid_grant "assertion too old"` | `iat` > `xaa.max_assertion_age` ago | Bump `max_assertion_age` (default 5m) or fix clock skew between IdP and AS | | `400 access_denied` | No policy allows this (IdP × client × scope × resource) | Create/update policy; check `authplane_xaa_policy_evaluation_total{decision="deny"}` metric with labels | | `400 access_denied` in `strict` subject mode | Subject not in the mapping table | Add explicit `subject-mapping` row | | `400 invalid_grant "jti already used"` | Assertion replayed | Client bug: each assertion is single-use per JTI | | `400 unauthorized_client` | Client's `grant_types` doesn't include `jwt-bearer` | Update the client's registration | ## Related - [Concepts: Cross-App Access (XAA)](/concepts/xaa) — mental model - [Concepts: Grants & flows → JWT Bearer](/concepts/grants-and-flows#jwt-bearer--cross-app-access-xaa) - [Reference: Configuration → xaa](/reference/configuration#xaa-and-jwt-bearer--yaml-only-at-v01x) — every YAML knob - [Topologies: Enterprise XAA](/topologies/enterprise-xaa) — end-to-end topology diagram - [Test XAA with xaa.dev (authserver repo)](https://github.com/authplane/authserver/blob/main/docs/how-to/test-xaa-with-xaa-dev.md) — reproducible playground walkthrough --- ## operate/backup-upgrade-purge.mdx --- title: Backup, upgrade, purge description: "Data lifecycle for AuthPlane — backup by storage driver, upgrade path with semver, and scheduling authserver purge across systemd, Docker, and Kubernetes." section: Operate sectionOrder: 6 order: 6 --- # Backup, upgrade, purge > **TL;DR** — Three operational chores. **Backup** the storage driver (SQLite file / `pg_dump`) plus the signing keys directory. **Upgrade** by pulling the new binary or image and running `authserver migrate`; migrations are forward-only and idempotent. **Purge** is not automatic in `serve` — schedule `authserver purge` externally (systemd timer, Docker sidecar, or k8s CronJob) or expired-data tables grow unbounded. ## Backup ### SQLite The database file and signing keys must be backed up together. **Cold backup (safe, requires stopping the server):** ```bash # Standalone systemctl stop authserver cp /var/lib/authserver/authserver.db /backup/authserver-$(date +%Y%m%d).db cp -r /var/lib/authserver/keys /backup/keys-$(date +%Y%m%d) systemctl start authserver # Docker Compose docker compose stop authserver docker run --rm -v authserver-data:/data -v $(pwd)/backup:/backup \ alpine tar czf /backup/authserver-$(date +%Y%m%d).tar.gz -C /data . docker compose start authserver ``` **Live backup (no downtime, WAL mode required):** ```bash sqlite3 /var/lib/authserver/authserver.db \ ".backup /backup/authserver-$(date +%Y%m%d).db" ``` SQLite's `.backup` command holds a shared lock briefly and produces a consistent snapshot while writes continue. Enable WAL mode (default) for concurrent-read semantics: `storage.sqlite.wal: true`. ### PostgreSQL ```bash # One-shot docker compose exec postgres pg_dump -U authserver authserver > backup.sql # Or from a standalone Postgres host pg_dump -h db.internal -U authserver authserver > backup.sql ``` For production, use continuous archiving (WAL-E, pgBackRest, `barman`) for point-in-time recovery. AuthPlane's schema is standard PostgreSQL — any Postgres-aware backup tool works. ### Signing keys **Always back up the signing keys directory.** If you lose them, every outstanding JWT becomes unverifiable — clients will hit `401 invalid_token` until they re-authenticate. - **Keyfile store** — back up `/var/lib/authserver/keys/` (standalone) or the `authserver-data` volume (Docker) or the persistent volume (Kubernetes). - **`postgres_key` store** — keys are in the Postgres backup already; nothing extra to back up. - **`vault_transit` store** — Vault is the source of truth. Follow your Vault backup procedure; AuthPlane never has the private key on disk. ## Upgrade AuthPlane follows **semantic versioning**. Same-major-line upgrades are drop-in; a major bump signals a breaking change and comes with a migration note in the release. **Standalone binary:** ```bash systemctl stop authserver cp /usr/local/bin/authserver /usr/local/bin/authserver.bak curl -L https://github.com/authplane/authserver/releases/latest/download/authserver-linux-amd64 \ -o /usr/local/bin/authserver chmod +x /usr/local/bin/authserver sudo -u authserver authserver migrate --config /etc/authserver/config.yaml systemctl start authserver ``` **Docker Compose:** ```bash docker compose pull authserver docker compose run --rm authserver migrate # apply any new migrations docker compose up -d ``` **Kubernetes (Helm):** ```bash helm upgrade authplane oci://ghcr.io/authplane/charts/authplane \ --version \ -f values-production.yaml ``` The Helm chart runs migrations automatically on pod boot — no separate migration Job needed. The init container waits for Postgres connectivity; the main process embeds all migration SQL via `go:embed` and applies pending migrations before serving traffic. ### Migrations are forward-only and idempotent Running `authserver migrate` on an already-migrated database is a no-op. There is **no rollback** — restore from backup if you need to revert. Read the release notes before major-version bumps. Breaking changes are called out in `CHANGELOG.md` with concrete migration steps. ## Scheduled purge **`serve` does not run purge goroutines.** With `dpop.enabled`, `client_credentials.enabled`, or `xaa.enabled` set to `true`, you MUST schedule `authserver purge` externally or the expired-data tables grow unbounded. ### What `authserver purge` deletes One pass deletes expired rows from every purgeable table: | Target (`--only` name) | What it deletes | |---|---| | `assertion-jti` | Expired XAA / JWT-bearer assertion JTIs (replay prevention) | | `connect-pending-states` | Expired upstream-connect pending states (broker Connect flow) | | `dpop-nonces` | Expired DPoP proof JTIs and server nonces | | `jti` | Expired revoked token JTIs (RFC 7009 revocation list) | | `machine-tokens` | Expired client-credentials machine tokens | | `refresh-tokens` | Expired refresh tokens and aged-out token families | | `sessions` | Expired user sessions | Default is all targets; `--only=target1,target2` runs a subset. `--timeout` defaults to `10m`; pass `--timeout=0` to disable the internal deadline. Aborts on SIGINT/SIGTERM. Consent grants and other never-expire rows are not touched — they age out via revocation, not expiration. ### Recommended schedule A single **daily** run is enough for most deployments. Operators who issue many short-lived tokens (DPoP proofs, machine tokens) may prefer hourly to keep tables small. The purge is a set of lightweight `DELETE WHERE expires_at < now()` queries — it does not block OAuth traffic. ### Scheduled purge — systemd timer For standalone binary deployments. `/etc/systemd/system/authserver-purge.service`: ```ini [Unit] Description=AuthPlane expired-data purge After=network-online.target [Service] Type=oneshot User=authserver Group=authserver ExecStart=/usr/local/bin/authserver purge --config /etc/authserver/config.yaml ``` `/etc/systemd/system/authserver-purge.timer`: ```ini [Unit] Description=Run authserver purge hourly [Timer] OnCalendar=hourly RandomizedDelaySec=5m Persistent=true [Install] WantedBy=timers.target ``` Enable: ```bash systemctl daemon-reload systemctl enable --now authserver-purge.timer systemctl list-timers authserver-purge.timer ``` Inspect recent runs: ```bash journalctl -u authserver-purge.service --since '1 day ago' ``` ### Scheduled purge — Docker Compose sidecar Add a second service alongside `authserver` that reuses the same image and env — kept out of `docker compose up` by default via `profiles: ["purge"]`: ```yaml services: authserver: image: authplane/authserver:latest # ... your normal config ... authserver-purge: image: authplane/authserver:latest command: ["purge"] environment: # Must match authserver's storage config — purge hits the same DB. AUTHPLANE_STORAGE_DRIVER: postgres AUTHPLANE_STORAGE_POSTGRES_DSN: postgres://authserver:${POSTGRES_PASSWORD}@postgres:5432/authserver?sslmode=disable depends_on: postgres: condition: service_healthy restart: "no" profiles: ["purge"] ``` Trigger from the host — cron or on demand: ```bash # On-demand docker compose run --rm authserver-purge # Hourly via host crontab (crontab -e) 0 * * * * cd /opt/authserver && docker compose run --rm authserver-purge >> /var/log/authserver-purge.log 2>&1 ``` Prefer host-level scheduling over stuffing cron inside the container — keeps the image minimal and failures visible to your standard monitoring. ### Scheduled purge — Kubernetes CronJob ```yaml apiVersion: batch/v1 kind: CronJob metadata: name: authserver-purge namespace: authserver spec: schedule: "0 * * * *" # hourly concurrencyPolicy: Forbid # don't overlap runs successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 1 template: spec: restartPolicy: OnFailure containers: - name: purge image: authplane/authserver:latest args: ["purge"] envFrom: - configMapRef: name: authserver-config - secretRef: name: authserver-secrets resources: requests: { cpu: 50m, memory: 64Mi } limits: { cpu: 500m, memory: 256Mi } ``` The CronJob must share the same storage config (`AUTHPLANE_STORAGE_*` env) as the `serve` Deployment — it runs purge against the same database. For "rows purged over time" charts, enable metrics on the CronJob pod the same way as `serve` (e.g. `AUTHPLANE_METRICS_PROVIDER=otel` with the OTLP endpoint). ### Selective purge Run just the high-churn tables hourly and everything else daily: ```bash # Hourly authserver purge --only=dpop-nonces,assertion-jti,jti --config /etc/authserver/config.yaml # Daily authserver purge --config /etc/authserver/config.yaml ``` `--only` accepts comma-separated target names from the table above. ### Exit codes and alerting `authserver purge` exits non-zero if any target fails or the context is canceled. Individual failures log at `ERROR` with a `table=` attribute; the command continues with remaining targets and fails at the end. Wire the job's exit status into your alerting: - **systemd** — `OnFailure=` in the service unit - **Kubernetes** — the CronJob's `Job` failure events; alert on `kube_job_status_failed > 0` - **Docker cron wrapper** — grep the log for `ERROR` and page on non-zero exit ### Verifying a scheduled purge Run manually once after setup: ```bash authserver purge --timeout=5m ``` You should see `INFO purge completed` for each target. ## What's NOT part of these chores - **Rate-limiter cache** — cleaned in-process every 5 minutes; not persisted, no scheduling needed. - **JWKS cache** — invalidated by `SIGHUP` or `POST /admin/keys/rotate`; TTL-less otherwise. - **In-memory registry caches** — none. The unified resource model reads from the DB on every request. ## Related - [Operate overview](/operate/overview) — mode picker - [Standalone binary](/operate/standalone) — systemd timer walkthrough - [Docker Compose](/operate/docker-compose) — backup examples end to end - [Kubernetes (Helm)](/operate/kubernetes) — Helm chart handles migrations automatically - [Security: Key management](/security/key-management) — signing key rotation policy - [Configuration reference](/reference/configuration) — storage and signing knobs --- ## operate/docker-compose.mdx --- title: Docker Compose description: "Production-grade Docker Compose files for AuthPlane — SQLite single-instance and PostgreSQL multi-instance-ready, plus Caddy for automatic TLS." section: Operate sectionOrder: 6 order: 2 --- # Docker Compose > **TL;DR** — Two canonical compose files: SQLite (single instance, `<1000` clients) and PostgreSQL (HA-ready, needs `authserver migrate` on first start). Both use the same `authplane/authserver:latest` image and expose OAuth on `:9000` while keeping the admin port on `127.0.0.1:9001` only. Add Caddy for automatic Let's Encrypt TLS. ## SQLite (single instance) Best for small deployments: single server, `<1000` clients, no HA requirement. `docker-compose.yml`: ```yaml services: authserver: image: authplane/authserver:latest ports: - "9000:9000" - "127.0.0.1:9001:9001" # Admin API on loopback only environment: AUTHPLANE_SERVER_ISSUER: https://auth.example.com AUTHPLANE_STORAGE_SQLITE_PATH: /data/authserver.db AUTHPLANE_SIGNING_KEY_PATH: /data/keys AUTHPLANE_SESSION_SECRET: ${SESSION_SECRET} AUTHPLANE_SESSION_SECURE: "true" AUTHPLANE_ADMIN_API_KEY: ${ADMIN_API_KEY} AUTHPLANE_RESOURCE_URI: http://mcp-server:3000/mcp AUTHPLANE_RESOURCE_SCOPES: tools/echo volumes: - authserver-data:/data restart: unless-stopped healthcheck: test: ["CMD", "/authserver", "version"] interval: 10s timeout: 5s retries: 3 volumes: authserver-data: ``` `.env`: ```bash SESSION_SECRET=generate-a-32-byte-random-string ADMIN_API_KEY=generate-a-secure-api-key ``` Generate secrets: ```bash openssl rand -base64 32 # session secret openssl rand -hex 24 # admin API key ``` Start: ```bash docker compose up -d ``` ## PostgreSQL (multi-instance ready) For HA or when you want a shared database. Adds a Postgres 18-alpine service, wires DSN via env var, and includes a healthcheck-gated `depends_on` so `authserver` waits for Postgres to be ready. `docker-compose.yml`: ```yaml services: postgres: image: postgres:18-alpine environment: POSTGRES_DB: authserver POSTGRES_USER: authserver POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pg-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U authserver"] interval: 5s timeout: 3s retries: 5 authserver: image: authplane/authserver:latest ports: - "9000:9000" - "127.0.0.1:9001:9001" environment: AUTHPLANE_SERVER_ISSUER: https://auth.example.com AUTHPLANE_STORAGE_DRIVER: postgres AUTHPLANE_STORAGE_POSTGRES_DSN: postgres://authserver:${POSTGRES_PASSWORD}@postgres:5432/authserver?sslmode=disable AUTHPLANE_SIGNING_KEY_PATH: /data/keys AUTHPLANE_SESSION_SECRET: ${SESSION_SECRET} AUTHPLANE_SESSION_SECURE: "true" AUTHPLANE_ADMIN_API_KEY: ${ADMIN_API_KEY} AUTHPLANE_RESOURCE_URI: http://mcp-server:3000/mcp AUTHPLANE_RESOURCE_SCOPES: tools/echo volumes: - authserver-keys:/data/keys depends_on: postgres: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "/authserver", "version"] interval: 10s timeout: 5s retries: 3 volumes: pg-data: authserver-keys: ``` Run migrations before first start: ```bash docker compose run --rm authserver migrate ``` Then start: ```bash docker compose up -d ``` The `sslmode=disable` in the DSN is fine when `authserver` and `postgres` share a Docker network. If you point at an external Postgres, set `sslmode=require` (or `verify-full` with a `sslrootcert=` path). ## With a reverse proxy (Caddy) — automatic TLS Caddy handles Let's Encrypt certificates automatically. Add it in front of `authserver`: `docker-compose.yml`: ```yaml services: caddy: image: caddy:2-alpine ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy-data:/data depends_on: - authserver authserver: image: authplane/authserver:latest environment: AUTHPLANE_SERVER_ISSUER: https://auth.example.com AUTHPLANE_STORAGE_SQLITE_PATH: /data/authserver.db AUTHPLANE_SIGNING_KEY_PATH: /data/keys AUTHPLANE_SESSION_SECRET: ${SESSION_SECRET} AUTHPLANE_SESSION_SECURE: "true" AUTHPLANE_ADMIN_API_KEY: ${ADMIN_API_KEY} volumes: - authserver-data:/data volumes: caddy-data: authserver-data: ``` `Caddyfile`: ```caddyfile auth.example.com { reverse_proxy authserver:9000 } ``` The admin port (`:9001`) is intentionally NOT exposed through the reverse proxy. Access the Admin UI at `http://127.0.0.1:9001/admin/ui/` from the Docker host — SSH tunnel, VPN, or bastion for remote access. ## Backup ### SQLite The database file and signing keys live in the `authserver-data` volume: ```bash # Stop authserver for consistent backup docker compose stop authserver # Copy the volume data docker run --rm -v authserver-data:/data -v $(pwd)/backup:/backup \ alpine tar czf /backup/authserver-$(date +%Y%m%d).tar.gz -C /data . # Restart docker compose start authserver ``` For live backups, run SQLite's `.backup` from the **host** against the mounted volume — the AuthPlane image is distroless and has no shell or `sqlite3` binary, so `docker compose exec … sqlite3 …` will fail. WAL mode makes a host-side `.backup` safe without stopping the container. Details in [Backup, upgrade, purge](/operate/backup-upgrade-purge). ### PostgreSQL ```bash docker compose exec postgres pg_dump -U authserver authserver > backup.sql ``` Or use continuous archiving (WAL-E, pgbackrest) for point-in-time recovery. ## Upgrading ```bash docker compose pull authserver docker compose run --rm authserver migrate # Run any new migrations docker compose up -d ``` Migrations are forward-only and idempotent — running them on an already-migrated database is a no-op. Read the release notes before major version bumps. ## Operational features Runtime features that apply to Docker Compose as much as standalone: - **SIGHUP key reload** — hot-reload signing keys without a restart: ```bash docker kill -s HUP $(docker compose ps -q authserver) ``` Or use `POST /admin/keys/rotate` via the Admin API. - **PostgreSQL LISTEN/NOTIFY** — when `storage.driver: postgres`, config changes (new resources, allowlist updates) propagate to all instances in milliseconds. SQLite deployments poll caches every 30 seconds instead. - **Zero-downtime key rotation** — new keys are added; old keys stay in JWKS for verification until they expire. See [Security: Key management](/security/key-management). - **`authserver purge`** — scheduled cleanup for expired tokens, DPoP nonces, and assertion JTIs. Not automatic; schedule via a compose sidecar or host crontab. See [Backup, upgrade, purge](/operate/backup-upgrade-purge#docker-compose). ### SQLite cache propagation window With `AUTHPLANE_STORAGE_DRIVER=sqlite`, resource-server / allowlist / broker-provider changes made via the Admin API take up to **30 seconds** to become visible to `/.well-known/oauth-authorization-server`, scope validation, and the token endpoint — the in-memory caches refresh on a 30 s tick and SQLite has no `LISTEN/NOTIFY`. PostgreSQL deployments propagate in milliseconds via PG NOTIFY. Rarely matters outside initial bring-up. ## Related - [Operate overview](/operate/overview) — the mode picker - [Standalone binary](/operate/standalone) — the same on Linux without Docker - [Kubernetes (Helm)](/operate/kubernetes) — the same at HA scale - [Vault Transit](/operate/vault-transit) — HSM-grade signing in front of any mode - [Backup, upgrade, purge](/operate/backup-upgrade-purge) — data lifecycle - [Configuration reference](/reference/configuration) — every env var --- ## operate/kubernetes.mdx --- title: Kubernetes (Helm) description: "Production-grade Helm chart for AuthPlane — PostgreSQL/SQLite storage, OIDC federation, Vault Transit signing, split ingress for OAuth and admin, and OTEL observability." section: Operate sectionOrder: 6 order: 3 --- # Kubernetes (Helm) > **TL;DR** — Official Helm chart at `oci://ghcr.io/authplane/charts/authplane`. Ships with an optional Bitnami PostgreSQL subchart, split ingresses (public OAuth on `:9000` + internal admin on `:9001`), Vault Transit signing support, ServiceMonitor for Prometheus, OTEL wiring. Migrations run automatically on pod boot. HA-ready with `autoscaling.enabled` — Vault Transit recommended when running multi-replica to avoid the shared-PVC problem. ## Prerequisites - Kubernetes 1.26+ - Helm 3.8+ - A PostgreSQL instance — either the Bitnami subchart the chart ships (dev/testing) or your own external DB (production) ## Quick Start ### From the OCI registry (recommended) ```bash helm install authplane oci://ghcr.io/authplane/charts/authplane \ --version 0.1.0 \ -f values-production.yaml ``` ### From source (development) ```bash git clone https://github.com/authplane/authserver.git cd authserver # Update chart dependencies (downloads Bitnami PostgreSQL subchart) helm dependency update charts/authplane # Install with built-in PostgreSQL helm install authplane charts/authplane \ --set config.server.issuer=https://auth.example.com \ --set postgresql.enabled=true \ --set postgresql.auth.password=changeme \ --set secrets.sessionSecret=$(openssl rand -hex 32) \ --set secrets.adminApiKey=$(openssl rand -hex 32) ``` ## Storage modes ### PostgreSQL (production) Recommended for production. Either the Bitnami subchart or your own external DB works; either way the chart auto-adds an init container that waits for PostgreSQL to be ready before starting AuthPlane. **Option A — Bitnami subchart** (dev/testing convenience): ```yaml # values-dev.yaml config: server: issuer: https://auth.example.com storage: driver: postgres postgresql: enabled: true auth: username: authplane password: changeme database: authplane secrets: sessionSecret: "generate-with-openssl-rand-hex-32" adminApiKey: "generate-with-openssl-rand-hex-32" ``` ```bash helm install authplane charts/authplane -f values-dev.yaml ``` **Option B — External PostgreSQL** (production): ```yaml # values-production.yaml config: server: issuer: https://auth.example.com storage: driver: postgres externalDatabase: host: postgres.database.svc port: 5432 user: authplane password: secret database: authplane sslmode: require # Or use a pre-existing Secret with the full DSN: # existingSecret: my-db-secret # existingSecretKey: dsn secrets: existingSecret: authplane-secrets # pre-created with session-secret + admin-api-key ``` ### SQLite (dev / single-node) SQLite mode requires persistent storage and is limited to a single replica. ```yaml # values-sqlite.yaml replicaCount: 1 config: server: issuer: http://localhost:9000 storage: driver: sqlite persistence: enabled: true size: 1Gi secrets: sessionSecret: "generate-with-openssl-rand-hex-32" adminApiKey: "generate-with-openssl-rand-hex-32" ``` > **Warning** — SQLite doesn't support multiple replicas. The chart's `NOTES.txt` warns if `replicaCount > 1` while `driver: sqlite`. > **Cache propagation window** — with `driver: sqlite`, changes made via the Admin API (new resource servers, scope updates, broker-provider config) take up to **30 seconds** to become visible to `/.well-known/oauth-authorization-server` and the token endpoint. SQLite has no `LISTEN/NOTIFY`. PostgreSQL propagates in milliseconds via PG NOTIFY. Usually only matters during initial bring-up. ## Migrations Migrations run automatically on pod startup. The AuthPlane binary embeds all migration SQL via `go:embed` and applies pending migrations before serving traffic. **No separate `Job` needed.** The chart's init container waits for PostgreSQL connectivity; the main process handles the migrations themselves. If you want to run migrations manually (e.g., during a large upgrade): ```bash kubectl exec -it deploy/authplane -- /authserver migrate ``` ## OIDC Federation (Google, Okta, Entra) Users see a "Continue with [Provider]" button on the login page. Full setup in [Guides: Federate to your IdP](/guides/federate-idp); the chart-specific bits: ### Google Workspace ```yaml config: oidc: enabled: true issuer: https://accounts.google.com client_id: "YOUR_GOOGLE_CLIENT_ID" client_secret: "YOUR_GOOGLE_CLIENT_SECRET" display_name: "Google Workspace" scopes: [openid, email, profile] include_groups_scope: false # Google doesn't support groups scope ``` ### Okta ```yaml config: oidc: enabled: true issuer: https://your-org.okta.com client_id: "YOUR_OKTA_CLIENT_ID" client_secret: "YOUR_OKTA_CLIENT_SECRET" display_name: "Okta" scopes: [openid, email, profile] include_groups_scope: true # Okta supports groups ``` ### Secrets management for OIDC client secrets Don't put client secrets in `values.yaml`. Two options: **Env-var injection** — reference a pre-created Kubernetes Secret: ```yaml config: oidc: enabled: true client_secret_ref: AUTHPLANE_OIDC_CLIENT_SECRET extraEnv: - name: AUTHPLANE_OIDC_CLIENT_SECRET valueFrom: secretKeyRef: name: oidc-credentials key: client-secret ``` **Full config as sealed Secret** — for GitOps workflows: ```yaml existingConfigSecret: my-sealed-config ``` ## Vault Transit signing (HSM-grade) AuthPlane supports HashiCorp Vault Transit for JWT signing — private keys never leave Vault. Recommended for multi-replica deployments (avoids the shared-PVC problem) and any compliance environment. ```yaml vault: signing: enabled: true address: https://vault.vault.svc:8200 mount: transit keyName: authserver-signing auth: method: approle approle: roleId: "..." secretId: "..." # Or a pre-existing Secret with keys: # vault-token (token auth) # vault-approle-role-id + vault-approle-secret-id (approle) # existingSecret: vault-signing-creds ``` Setting `vault.signing.enabled=true` auto-sets `AUTHPLANE_SIGNING_KEY_STORE=vault_transit` and injects the connection env vars. ### Vault auth patterns in Kubernetes - **AppRole** (recommended) — inject `roleId` / `secretId` via `existingSecret` or External Secrets Operator. - **Vault Agent Sidecar** — Vault Agent annotations via `podAnnotations`; mount injected secrets via `extraVolumes` / `extraVolumeMounts`. - **Vault CSI Provider** — mount secrets as volumes via `extraVolumes`. The chart does NOT include a Vault subchart — Vault is infrastructure your organization already runs. Full walkthrough in [Operate: Vault Transit](/operate/vault-transit). ## Ingress — split public + admin The chart provides separate ingress resources for OAuth (`:9000`) and Admin (`:9001`). These are different security boundaries — the Admin API should have restricted access. ```yaml ingress: enabled: true className: nginx annotations: cert-manager.io/cluster-issuer: letsencrypt-prod hosts: - host: auth.example.com paths: - path: / pathType: Prefix tls: - secretName: auth-tls hosts: - auth.example.com adminIngress: enabled: true className: nginx annotations: nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8 hosts: - host: auth-admin.internal.example.com paths: - path: / pathType: Prefix tls: - secretName: auth-admin-tls hosts: - auth-admin.internal.example.com ``` Without an admin ingress, port-forward for the UI: ```bash kubectl port-forward svc/authplane 9001:9001 # Open http://localhost:9001/admin/ui/ ``` ## Secrets management The chart handles three categories of secrets. ### 1. Session secret + Admin API key ```yaml # Option A — Inline (dev only; NOT stable across helm upgrade) secrets: sessionSecret: "generate-with-openssl-rand-hex-32" adminApiKey: "generate-with-openssl-rand-hex-32" # Option B — Pre-existing Secret (production) secrets: existingSecret: authplane-secrets # Must contain keys: session-secret, admin-api-key ``` > **Warning** — auto-generated secrets change on every `helm upgrade`. Always use explicit values or `existingSecret` for production; regenerated secrets invalidate every active session on upgrade. ### 2. Full config as Secret The chart renders the full AuthPlane config as a Kubernetes Secret (not ConfigMap) because it can contain sensitive values (DSN, Vault tokens). For GitOps: ```yaml existingConfigSecret: my-sealed-config ``` ### 3. Database password ```yaml # Option A — Inline externalDatabase: password: secret # Option B — Pre-existing Secret with the full DSN externalDatabase: existingSecret: my-db-secret existingSecretKey: dsn ``` ## Observability ### Prometheus (ServiceMonitor) ```yaml config: observability: metrics: provider: prometheus path: /metrics serviceMonitor: enabled: true interval: 15s ``` ### OpenTelemetry (logs + traces + metrics) ```yaml config: observability: logging: outputs: otel: true otel_endpoint: otel-collector.monitoring:4317 insecure: true tracing: enabled: true endpoint: otel-collector.monitoring:4317 insecure: true sample_rate: 1.0 metrics: provider: both otel_endpoint: otel-collector.monitoring:4317 insecure: true ``` Full metric catalog and Grafana dashboards in [Guides: Monitoring](/guides/monitoring). ## Production checklist | Setting | Recommendation | |---|---| | `config.server.issuer` | Set to your external HTTPS URL — no trailing slash | | `config.session.secure` | `true` (requires HTTPS) | | `config.storage.driver` | `postgres` | | `secrets.existingSecret` | Pre-created Secret (not inline) | | `externalDatabase.sslmode` | `require` or `verify-full` | | `ingress.tls` | Configured with cert-manager | | `adminIngress` | Restricted via IP allowlist or separate internal ingress | | `autoscaling.enabled` | `true` with `minReplicas: 2` | | `podDisruptionBudget.enabled` | `true` | | `networkPolicy.enabled` | `true` | | `resources` | Set both `requests` and `limits` | | `vault.signing.enabled` | `true` when multi-replica | ## Scaling - **PostgreSQL mode** — AuthPlane is stateless. Scale horizontally with HPA. - **Signing keys** — use Vault Transit (`vault.signing.enabled: true`) for multi-replica. With keyfile, all replicas share the PVC (`ReadWriteMany` required, or co-locate pods on the same node). - **Session affinity** — not required. Sessions are stored in signed cookies, not server-side. - **Database connections** — total = `config.storage.postgres.max_conns × replicas`. Size your Postgres accordingly. ## Local testing with Kind For iterating on the chart itself, [`kind`](https://kind.sigs.k8s.io/) is the fast local loop. Key steps: ```bash kind create cluster --name authplane-test docker build -t authplane:local . kind load docker-image authplane:local --name authplane-test helm dependency update charts/authplane helm install authplane charts/authplane -f values-sqlite.yaml \ --set image.repository=authplane --set image.tag=local kubectl port-forward svc/authplane 9000:9000 9001:9001 ``` ## Uninstallation ```bash helm uninstall authplane ``` > **Note** — PVCs are not deleted automatically. Remove them manually if no longer needed: > ```bash > kubectl delete pvc -l app.kubernetes.io/instance=authplane > ``` ## Chart reference Every configurable parameter lives in the chart's `values.yaml` — pull it from the OCI registry (`helm show values oci://ghcr.io/authplane/charts/authplane`) to see the shipped defaults. For raw manifests (no Helm), template the chart with `helm template …` and commit the output. ## Related - [Operate overview](/operate/overview) — mode picker - [Docker Compose](/operate/docker-compose) — same setup at single-host scale - [Standalone binary](/operate/standalone) — same setup on a Linux host without containers - [Vault Transit](/operate/vault-transit) — HSM-grade signing detail - [Guides: Federate to your IdP](/guides/federate-idp) — OIDC provider setup end to end - [Guides: Monitoring](/guides/monitoring) — Prometheus + OTEL full setup - [Configuration reference](/reference/configuration) — every env var --- ## operate/overview.mdx --- title: Operate overview description: "Pick the deployment mode that fits your environment — standalone binary, Docker Compose, or Kubernetes — with the trade-offs laid out." section: Operate sectionOrder: 6 order: 1 --- # Operate overview > **TL;DR** — One Go binary, three deployment shapes. Standalone binary for a single Linux host. Docker Compose for single-host with Postgres and a reverse proxy. Kubernetes (Helm) for HA, multi-replica, production. Same binary in every mode; the only real choice is your infrastructure. This page is the picker. ## The deployment matrix | Mode | Best for | Storage | Signing keys | HA-ready | Effort | |---|---|---|---|---|---| | **Single binary (`docker run`)** | Local dev, demos, evaluating AuthPlane | SQLite (on-disk) | Auto-generated keyfile | No | One command | | **Standalone binary + systemd** | Small VPS, on-prem single host, air-gapped | SQLite or Postgres | Keyfile or Vault Transit | If Postgres | ~15 min | | **Docker Compose** | Single host with Postgres, dev/staging, on-prem | SQLite or Postgres | Keyfile | If Postgres | ~10 min | | **Kubernetes (Helm)** | Production, multi-replica HA, regulated environments | Postgres (subchart or external) | Vault Transit recommended | ✓ | Chart + values | Every mode uses the same `authplane/authserver:latest` container image (or the equivalent binary — the Docker image is a distroless wrapper around the Go binary). Config precedence is identical: [defaults → YAML file → env vars](/reference/configuration#precedence). ## The 30-second picker - **Just trying it out** → `docker run authplane/authserver:latest serve` — SQLite + auto keys, zero config. Not this page's territory; go to [Quickstart](/quickstart). - **One Linux server, no Kubernetes** → [Standalone binary](/operate/standalone) with systemd + reverse proxy. - **One Linux server, prefer containers** → [Docker Compose](/operate/docker-compose) — SQLite or Postgres, optional Caddy for TLS. - **Kubernetes cluster, need HA** → [Kubernetes (Helm)](/operate/kubernetes) with the OCI-published chart. - **Any of the above but with HSM-grade signing** → add [HashiCorp Vault Transit](/operate/vault-transit) — works across all three. ## Common requirements across all modes Regardless of shape, these apply: - **Session secret and admin API key** — generate with `openssl rand -hex 32`. Boot fails when `server.issuer` is not localhost and these are missing. - **Public issuer URL over HTTPS** — reverse proxy or ingress terminating TLS in front of `:9000`. - **Admin port `:9001` not exposed publicly** — internal network only, or behind IP allowlist. - **PostgreSQL for multi-instance** — SQLite doesn't support concurrent writers across replicas. - **Vault Transit for multi-replica signing** — keyfile requires a shared PVC (`ReadWriteMany`); Vault avoids the problem entirely by keeping keys server-side. - **Scheduled `authserver purge`** — expired tokens, DPoP nonces, and assertion JTIs aren't cleaned by `serve`. Schedule externally (systemd timer, k8s CronJob, or docker sidecar). See [Backup, upgrade, purge](/operate/backup-upgrade-purge). ## Storage: SQLite vs Postgres | | SQLite | PostgreSQL | |---|---|---| | Setup | Zero — auto-created on first boot | Requires external DB + `authserver migrate` on first start | | Concurrent readers | ✓ (WAL mode) | ✓ | | Concurrent writers across instances | ✗ | ✓ | | Cross-instance config propagation | 30 s polling (in-memory cache tick) | Milliseconds via `LISTEN/NOTIFY` | | Backup | File copy while stopped, or `.backup` while running | `pg_dump` | | Recommended for | Single instance, dev, evaluating | Production, HA, multi-instance | The 30-second SQLite propagation window matters mostly during initial bring-up (creating resources, rotating keys) — steady-state it's invisible. ## Signing keys: keyfile vs Vault Transit | | Keyfile | Postgres key store | Vault Transit | |---|---|---|---| | Setup | Auto-generated on boot; PEM files in `signing.key_path` | Keys stored in the Postgres DB with encryption at rest | External Vault + Transit engine + auth (token or AppRole) | | Multi-instance safe | Requires shared PVC (`ReadWriteMany`) | ✓ (propagates via `LISTEN/NOTIFY`) | ✓ (Vault is the shared store) | | Private keys on disk | ✓ (PEM under `key_path`) | Encrypted in DB (not plaintext on disk) | ✗ — signing happens in Vault | | Rotation | `authserver admin key rotate` | Same | Same, via Vault | | Compliance | Fine for most | Good for regulated storage | Best for HSM-grade / FIPS | Details in [Security: Key management](/security/key-management) and [Operate: Vault Transit](/operate/vault-transit). ## What every mode ships Regardless of how you deploy: - Public OAuth endpoints on `:9000` — `/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/revoke`, `/oauth/introspect` - Discovery on `:9000` — `/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration`, `/.well-known/jwks.json` - Health on `:9000/health` and `:9000/ready` - Admin API + Admin UI on `:9001` — clients, users, resources, providers, grants, issuances, signing keys, audit - Structured logs (slog), Prometheus metrics on `:9001/metrics` (admin port), optional OpenTelemetry traces + metrics OTLP - Signing-key rotation via `SIGHUP` (`kill -HUP ` / `docker kill -s HUP` / `POST /admin/keys/rotate`) ## Choose your mode - [Standalone binary](/operate/standalone) — systemd unit, dedicated user, PEM keyfile, reverse proxy notes - [Docker Compose](/operate/docker-compose) — SQLite and Postgres compose files, Caddy TLS example, backup + upgrade - [Kubernetes (Helm)](/operate/kubernetes) — chart install, values files, ingress split (public + admin), OIDC in values, Vault Transit, observability wiring, Kind for local testing - [HashiCorp Vault Transit](/operate/vault-transit) — HSM-grade signing across any of the above - [Backup, upgrade, purge](/operate/backup-upgrade-purge) — data lifecycle across all modes ## Related - [Configuration reference](/reference/configuration) — every knob for every section - [Security: Threat model](/security/threat-model) — trust boundaries and 16 named threats - [Guides: Monitoring](/guides/monitoring) — Prometheus + OTEL wiring end to end --- ## operate/standalone.mdx --- title: Standalone binary description: "Run AuthPlane as a Linux binary under systemd — dedicated user, keyfile signing, reverse proxy, hardened service unit." section: Operate sectionOrder: 6 order: 4 --- # Standalone binary > **TL;DR** — Download the release binary, create a dedicated user, drop a YAML config, wire up systemd with the hardening flags below, front with Caddy or nginx for TLS, and you have a production-grade AuthPlane on a single Linux host. No Docker required. SQLite by default; drop in Postgres when you're ready. Keyfile signing works out of the box; `postgres_key` or `vault_transit` for multi-node. ## Choose your backends | Decision | Development | Production (single node) | Production (multi-node) | |---|---|---|---| | Storage | SQLite (default) | PostgreSQL | PostgreSQL (required) | | Signing keys | `keyfile` (default) | `keyfile` or `postgres_key` | `postgres_key` or `vault_transit` | | Data encryption | `aes_master` | `aes_master` | `aes_master` or `vault_transit_encrypt` | PostgreSQL is required for multi-node because the `postgres_key` signing driver uses `LISTEN/NOTIFY` on the `signing_key_change` channel for zero-downtime signing-key rotation across instances. ## Download ```bash curl -L https://github.com/authplane/authserver/releases/latest/download/authserver-linux-amd64 \ -o /usr/local/bin/authserver chmod +x /usr/local/bin/authserver authserver version ``` ## Create user and directories ```bash # Dedicated system user with no home + no shell useradd --system --no-create-home --shell /usr/sbin/nologin authserver # Data + config dirs mkdir -p /var/lib/authserver/keys mkdir -p /etc/authserver # Ownership chown -R authserver:authserver /var/lib/authserver ``` ## Configuration Create `/etc/authserver/config.yaml`: ```yaml server: issuer: https://auth.example.com address: ":9000" storage: driver: sqlite sqlite: path: /var/lib/authserver/authserver.db wal: true signing: algorithm: ES256 key_store: keyfile key_path: /var/lib/authserver/keys session: secret: "generate-a-32-byte-random-string" secure: true same_site: lax admin: enabled: true address: "127.0.0.1:9001" # loopback only api_key: "generate-a-secure-api-key" resources: - slug: my-mcp-server backend_kind: mint name: My MCP Server uri: http://localhost:3000/mcp scopes: - name: tools/echo description: Echo a message back to the caller ``` > **Runtime management** — the YAML `resources:` block is optional seed data; entries are inserted on startup when no row with the same slug exists. Manage resources at runtime via the Admin API (`/admin/resources`) or CLI (`authserver admin resource create`). Generate secrets and lock down the config file (contains sensitive values): ```bash openssl rand -base64 32 # session secret openssl rand -hex 24 # admin API key chmod 600 /etc/authserver/config.yaml chown authserver:authserver /etc/authserver/config.yaml ``` ## Run migrations ```bash sudo -u authserver authserver migrate --config /etc/authserver/config.yaml ``` Idempotent — safe to re-run on an already-migrated DB. Note the `--config` flag: `authserver migrate` accepts no positional path argument — passing one silently uses the default DB in the working directory instead of your configured one. ## Create the first admin user ```bash sudo -u authserver authserver admin user create \ --config /etc/authserver/config.yaml \ --email admin@example.com \ --password changeme \ --name Admin \ --role admin ``` Change the password immediately after first login. ## systemd unit Create `/etc/systemd/system/authserver.service`: ```ini [Unit] Description=AuthPlane MCP Authorization Server After=network-online.target Wants=network-online.target [Service] Type=simple User=authserver Group=authserver ExecStart=/usr/local/bin/authserver serve --config /etc/authserver/config.yaml Restart=on-failure RestartSec=5 LimitNOFILE=65536 # Security hardening NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/authserver PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true [Install] WantedBy=multi-user.target ``` Enable and start: ```bash systemctl daemon-reload systemctl enable authserver systemctl start authserver ``` Watch logs: ```bash systemctl status authserver journalctl -u authserver -f ``` ## Reverse proxy The public OAuth server listens on `:9000`; front it with TLS. ### Caddy ```caddyfile auth.example.com { reverse_proxy localhost:9000 } ``` Caddy handles Let's Encrypt automatically. Zero config beyond this. ### nginx ```nginx server { listen 443 ssl; server_name auth.example.com; ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:9000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` The admin API stays bound to `127.0.0.1:9001` — accessible only from the local machine. Reach the Admin UI via SSH tunnel: ```bash ssh -L 9001:127.0.0.1:9001 user@host # then open http://localhost:9001/admin/ui/ ``` ## PostgreSQL (optional) For deployments that want Postgres instead of SQLite: ```bash apt install postgresql sudo -u postgres createuser authserver sudo -u postgres createdb -O authserver authserver ``` Update `/etc/authserver/config.yaml`: ```yaml storage: driver: postgres postgres: dsn: "postgres://authserver@localhost:5432/authserver?sslmode=disable" ``` Re-run migrations, restart: ```bash sudo -u authserver authserver migrate --config /etc/authserver/config.yaml systemctl restart authserver ``` ## Operational features ### SIGHUP — reload signing keys without restart Hot-reload after rotating keys on disk or via Vault Transit: ```bash kill -HUP $(pidof authserver) # Or with systemd systemctl kill -s HUP authserver ``` Logs `signing keys reloaded` on success. No connections dropped. ### PostgreSQL `LISTEN/NOTIFY` (multi-instance) The unified resource model reads resources, broker providers, and per-resource policy directly from the database on every request — no in-memory registry that needs cross-instance invalidation. The only channel that requires `LISTEN/NOTIFY` today is signing-key rotation: | Channel | Driver | Triggered by | Effect | |---|---|---|---| | `signing_key_change` | `signing.key_store: postgres_key` | `signing_keys` row insert/update (`POST /admin/keys/rotate`, SIGHUP-triggered reloads) | Each instance reloads the JWKS cache so the new `kid` is published immediately | Wired automatically when `signing.key_store: postgres_key`. SQLite deployments and `keyfile`-driver deployments rely on SIGHUP instead. ### In-memory caches | Cache | TTL | Invalidated by | |---|---|---| | JWKS document (`/.well-known/jwks.json`) | TTL-less; cleared on demand | `signing_key_change` NOTIFY (multi-instance), SIGHUP, or `POST /admin/keys/rotate` | | IdP JWKS keys (XAA) | `xaa.jwks_cache_ttl` (default 1h) | `POST /admin/idps/{id}/refresh-keys` or TTL expiry | Resource registry, broker-provider, and per-resource policy reads are **not** cached — every request hits the DB via an indexed lookup. If you're debugging stale-config symptoms, the DB is the single source of truth. **Tuning `xaa.jwks_cache_ttl`** — the default 1h balances freshness against IdP load. Lower it (e.g. 5m) when an upstream IdP rotates JWKS aggressively or when you want a shorter blast radius after key compromise; raise it when the IdP rate-limits JWKS fetches. YAML-only field (no env-var override). Next assertion verification refreshes the cache after lowering — no restart needed. ### Signing key rotation Zero-downtime hot operation. Previous key stays in the JWKS document so outstanding tokens remain verifiable; new tokens are signed with the new key from the first `/oauth/token` call after rotation. ```bash # Via Admin API curl -X POST http://localhost:9001/admin/keys/rotate \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Or via CLI authserver admin key rotate ``` **When to rotate:** - Calendar cadence (e.g. every 90 days) as part of key-hygiene policy. - After a suspected key-material exposure — backup loss, stolen disk, operator departure with past `/var/lib/authserver/keys/` access. - During a kid-format migration (ES256 ↔ RS256) — rotate to switch algorithms gradually; old tokens keep verifying until they expire. **What happens:** 1. New key pair generated + persisted (keyfile writes to `/var/lib/authserver/keys/`; Vault Transit and `postgres_key` write to their backends). 2. The new key becomes `current`; previously current is demoted to `previous`. Keys older than `previous` are removed from the JWKS. 3. `/.well-known/jwks.json` includes both `current` and `previous` — verifiers that cache the JWKS pick up the new key on their next refresh (SDKs default to 1h). 4. All tokens signed after rotation carry the new `kid`. In-flight tokens signed by the previous key continue to verify until they expire. On multi-instance deployments the new key propagates via PostgreSQL `LISTEN/NOTIFY` (ms) or the keyfile's filesystem watcher on the next scheduled refresh — no manual SIGHUP required with `postgres_key` or `vault_transit`. Single-instance `keyfile` can use SIGHUP for immediate reload. Full rotation policy: [Security: Key management](/security/key-management). ### CORS warning at boot AuthPlane logs a startup `WARN` when `server.allowed_origins` is empty (or `AUTHPLANE_SERVER_ALLOWED_ORIGINS` is unset): ``` WARN CORS is disabled (AUTHPLANE_SERVER_ALLOWED_ORIGINS is empty); browser-based MCP clients (MCP Inspector, Claude Desktop, etc.) will silently fail on /oauth/token, /oauth/introspect, and /oauth/revoke due to CORS preflight rejections. For local dev set AUTHPLANE_SERVER_ALLOWED_ORIGINS=*; for production set an explicit origin allowlist. ``` Intentionally a `WARN`, not fatal — server-to-server-only deployments may legitimately leave this empty. If you see it in a deployment that needs browser clients, set `server.allowed_origins` (or `AUTHPLANE_SERVER_ALLOWED_ORIGINS`) to your origin allowlist (or `*` for local dev). ### Scheduled `authserver purge` `serve` does not run purge goroutines — schedule externally. Recipes in [Backup, upgrade, purge](/operate/backup-upgrade-purge#scheduled-purge-systemd-timer). The rate limiter cache is cleaned in-process every 5 minutes; it's not persisted and doesn't need scheduled purging. ## Firewall ```bash # Public — via reverse proxy ufw allow 443/tcp # Block direct access to authserver ports from outside ufw deny 9000/tcp ufw deny 9001/tcp ``` ## Related - [Operate overview](/operate/overview) — mode picker - [Docker Compose](/operate/docker-compose) — same setup with containers - [Kubernetes (Helm)](/operate/kubernetes) — same setup at HA scale - [Vault Transit](/operate/vault-transit) — HSM-grade signing - [Backup, upgrade, purge](/operate/backup-upgrade-purge) — data lifecycle - [Configuration reference](/reference/configuration) — every env var and YAML key --- ## operate/vault-transit.mdx --- title: Vault Transit signing description: "Delegate JWT signing to HashiCorp Vault Transit — private keys never leave Vault, audit trail per signing operation, FIPS-ready with HSM-backed Vault." section: Operate sectionOrder: 6 order: 5 --- # Vault Transit signing > **TL;DR** — Configure `signing.key_store: vault_transit` and AuthPlane sends every JWT payload to Vault for signing. Private key material never lives on the AuthPlane host. Adds ~2 ms latency per signing op, adds Vault as a hard dependency. Best fit for compliance environments, HSM-backed Vault deployments, zero-trust setups, and multi-replica Kubernetes. ## When to choose Vault Transit AuthPlane supports three signing key stores. Pick this one only when you need it. | Key store | Best for | Trade-off | |---|---|---| | `keyfile` (default) | Single-node deployments, development | Simple; keys on disk. `ReadWriteMany` PVC or shared filesystem for multi-node. | | `postgres_key` | Multi-node Postgres deployments | Keys stored in DB with AES encryption at rest. No shared filesystem needed. | | **`vault_transit`** | Compliance-sensitive, HSM-backed, zero-trust | Keys never leave Vault. Adds Vault dependency + ~2 ms per sign. | ## Why Vault Transit - **Keys never leave Vault** — signing material lives inside Vault's HSM-backed or software-backed Transit engine - **Audit trail** — Vault logs every signing operation - **Managed rotation** — Vault Transit handles key rotation on its side - **FIPS compliance** — when Vault is backed by an HSM ## Prerequisites - HashiCorp Vault 1.12+ - Transit secrets engine enabled - A Transit key configured for signing — ECDSA-P256 for ES256, RSA-2048 for RS256 ## Step 1 — Enable the Transit engine ```bash vault secrets enable transit ``` ## Step 2 — Create a signing key For **ES256** (ECDSA P-256), AuthPlane's default: ```bash vault write transit/keys/authserver-signing type=ecdsa-p256 ``` For **RS256**: ```bash vault write transit/keys/authserver-signing type=rsa-2048 ``` ## Step 3 — Create a Vault policy `authserver-signing-policy.hcl`: ```hcl path "transit/sign/authserver-signing" { capabilities = ["update"] } path "transit/verify/authserver-signing" { capabilities = ["update"] } path "transit/keys/authserver-signing" { capabilities = ["read"] } ``` Apply: ```bash vault policy write authserver-signing authserver-signing-policy.hcl ``` ## Step 4 — Choose an authentication method ### Option A — Static token Fine for dev; requires token rotation for production. ```bash vault token create -policy=authserver-signing -period=768h ``` ```yaml signing: algorithm: ES256 key_store: vault_transit vault_transit: address: https://vault:8200 token: "hvs.your-vault-token" mount: transit key_name: authserver-signing ``` ### Option B — AppRole (recommended for production) Enable AppRole, create a role tied to the signing policy: ```bash vault auth enable approle vault write auth/approle/role/authserver \ token_policies="authserver-signing" \ token_ttl=1h \ token_max_ttl=4h ``` Get the role ID and secret ID: ```bash vault read auth/approle/role/authserver/role-id vault write -f auth/approle/role/authserver/secret-id ``` Config: ```yaml signing: algorithm: ES256 key_store: vault_transit vault_transit: address: https://vault:8200 mount: transit key_name: authserver-signing approle: role_id: "your-role-id" secret_id: "your-secret-id" mount: approle ``` AuthPlane requests a fresh Vault token via AppRole on boot and re-authenticates before the token expires. ## Environment variables ```bash AUTHPLANE_SIGNING_KEY_STORE=vault_transit AUTHPLANE_SIGNING_ALGORITHM=ES256 AUTHPLANE_VAULT_ADDR=https://vault:8200 # Option A: static token AUTHPLANE_VAULT_TOKEN=hvs.your-token # Option B: AppRole AUTHPLANE_VAULT_APPROLE_ROLE_ID=your-role-id AUTHPLANE_VAULT_APPROLE_SECRET_ID=your-secret-id AUTHPLANE_VAULT_APPROLE_MOUNT=approle AUTHPLANE_VAULT_TRANSIT_MOUNT=transit AUTHPLANE_VAULT_TRANSIT_KEY_NAME=authserver-signing AUTHPLANE_VAULT_TIMEOUT=10s ``` ## Kubernetes (Helm) setup Same knobs via values.yaml — see [Kubernetes (Helm) → Vault Transit](/operate/kubernetes#vault-transit-signing-hsm-grade). Recommended Vault-auth patterns in Kubernetes: - **AppRole** — inject `roleId` / `secretId` via `existingSecret` or External Secrets Operator. - **Vault Agent Sidecar** — Vault Agent annotations via `podAnnotations`; mount injected secrets via `extraVolumes`. - **Vault CSI Provider** — mount secrets as volumes. The Helm chart doesn't ship a Vault subchart — Vault is infrastructure your organization already runs. ## Docker Compose (dev/testing) Dev-mode Vault for local iteration only. Never for production. ```yaml services: vault: image: hashicorp/vault:1.15 ports: - "8200:8200" environment: VAULT_DEV_ROOT_TOKEN_ID: dev-root-token cap_add: - IPC_LOCK authserver: image: authplane/authserver:latest environment: AUTHPLANE_SIGNING_KEY_STORE: vault_transit AUTHPLANE_VAULT_ADDR: http://vault:8200 AUTHPLANE_VAULT_TOKEN: dev-root-token AUTHPLANE_VAULT_TRANSIT_KEY_NAME: authserver-signing depends_on: - vault ``` Then inside the vault container: ```bash vault secrets enable transit vault write transit/keys/authserver-signing type=ecdsa-p256 ``` > **Note** — dev mode Vault (`VAULT_DEV_ROOT_TOKEN_ID`) is for testing only. In production, use a properly initialized and sealed Vault. ## Validation rules Boot fails if: - `signing.vault_transit.address` is missing when `key_store: vault_transit` - Both `token` and `approle.role_id` are set (they're mutually exclusive) - `approle.role_id` is set but `approle.secret_id` is missing ## Key rotation with Vault Transit Two levels: 1. **Vault-side rotation** — `vault write -f transit/keys/authserver-signing/rotate` bumps the key version inside Vault. AuthPlane's next signing operation picks up the new version transparently. 2. **AuthPlane-side rotation** — `authserver admin key rotate` still works and triggers a new key version request against Vault. Prefer this route if you want AuthPlane's audit trail alongside Vault's. The JWKS keeps both old and new keys visible until the old one expires so in-flight tokens continue to verify. ## Troubleshooting ### `vault transit: permission denied` The token or AppRole doesn't have the required policy. Verify: ```bash vault token lookup vault policy read authserver-signing ``` Common cause: policy typo, or policy applied to a different role than the one whose credentials AuthPlane has. ### `vault transit: key not found` The Transit key doesn't exist under the configured mount. Create it: ```bash vault write transit/keys/authserver-signing type=ecdsa-p256 ``` Double-check the mount matches `vault_transit.mount` in your config. ### `vault transit: connection refused` AuthPlane can't reach Vault. Check: - `vault_transit.address` is correct - In Docker Compose, use the service name (`http://vault:8200`) not `localhost` - In Kubernetes, use the ClusterIP or the FQDN (`vault.vault.svc:8200`) - Vault is unsealed and initialized (`vault status`) ### AppRole authentication loops or reauthenticates constantly Token TTL is too short and secret_id_ttl expired. Bump `token_ttl` and `token_max_ttl` on the role, or regenerate the secret_id. ## Related - [Operate overview](/operate/overview) — mode picker (includes signing store choice matrix) - [Operate: Standalone](/operate/standalone) — Vault Transit config in a systemd context - [Operate: Kubernetes](/operate/kubernetes#vault-transit-signing-hsm-grade) — Helm values for Vault Transit - [Security: Key management](/security/key-management) — rotation policy, algorithm choice, keyfile vs postgres vs Vault - [Configuration reference](/reference/configuration#signing) — every signing knob --- ## reference/admin-api.mdx --- title: Admin API description: "OpenAPI reference for AuthPlane's admin surface on :9001 — clients, users, resources, providers, grants, issuances, signing keys, audit, XAA." section: Reference sectionOrder: 7 order: 3 --- # Admin API > **TL;DR** — Everything AuthPlane exposes on `:9001` — the operator surface. CRUD for OAuth clients, users, resources, broker providers, grants, issuances, signing keys, XAA IdPs and policies, plus the audit log query API. Same endpoints power the built-in Admin UI at `/admin/ui/`. Authenticate with `Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY`. **Never expose `:9001` to the public internet.** ## Authentication Every admin request needs the API key you set at boot (`admin.api_key` in YAML or `AUTHPLANE_ADMIN_API_KEY` env var). Present it as a Bearer token: ```bash curl -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ https://auth-admin.internal.example.com/admin/clients ``` The Admin UI at `/admin/ui/` uses the same key — the user pastes it once and it's stored in `sessionStorage`. **Never expose port 9001 publicly.** Front with: - Internal network only, or - IP allowlist via ingress annotations (`nginx.ingress.kubernetes.io/whitelist-source-range`), or - Loopback bind (`admin.address: "127.0.0.1:9001"`) + SSH tunnel / bastion access Boot validation enforces that `admin.api_key` is set when `server.issuer` is not localhost. ## Endpoints at a glance | Path | Purpose | |---|---| | `/admin/clients` | OAuth clients — list, get, create (DCR + manual), update, suspend, delete | | `/admin/users` | Local users — list, get, create, disable, delete; password reset | | `/admin/resources` | Mint and Broker resources — CRUD, per-resource `policy.*` config | | `/admin/broker-providers` | Upstream OAuth providers — CRUD | | `/admin/users/{id}/grants` | List consent + broker grants for a specific user | | `/admin/grants/consent/{id}` | Revoke a consent grant (DELETE) | | `/admin/grants/broker/{id}` | Revoke a broker grant (DELETE) — does not cascade to upstream tokens | | `/admin/issuances` | Per-token forensic audit — list, filter by user/client/resource | | `/admin/keys` | Signing keys — list, rotate | | `/admin/audit` | Structured audit log — query with filters | | `/admin/idps` | XAA trusted IdPs — CRUD, refresh JWKS | | `/admin/xaa/policies` | XAA authorization policies | | `/admin/xaa/subject-mappings` | XAA subject mapping rules | | `/admin/system/config` | Effective configuration snapshot (read-only) | | `/admin/system/status` | Runtime status, feature-enabled flags, version | | `/admin/settings/dcr` | DCR mode config (runtime toggle between `open`/`approved_redirects`/`admin_only`) | The `/admin/ui/*` route serves the React SPA — not part of the API. ## The CLI mirrors the API Every admin API endpoint has a CLI equivalent under `authserver admin `. Example equivalents: | REST | CLI | |---|---| | `POST /admin/clients` | `authserver admin client create --grant-types … --scopes 'name\|upstream\|desc'` | | `GET /admin/resources` | `authserver admin resource list` | | `POST /admin/broker-providers` | `authserver admin provider create --slug … --protocol oauth` | | `POST /admin/keys/rotate` | `authserver admin key rotate` | | `GET /admin/users/{id}/grants` | `authserver admin grant list-user-grants --user …` | | `DELETE /admin/grants/consent/{id}` | `authserver admin grant revoke-consent --id …` | | `DELETE /admin/grants/broker/{id}` | `authserver admin grant revoke-broker --id …` | Full CLI reference in [Reference: Metrics & CLI](/reference/metrics-and-cli#cli-reference). ## Live spec (Rapidoc) The spec below is the exact YAML that ships with the AuthPlane binary. Search operations, inspect request/response schemas, try requests inline against a running instance. ## Related - [Reference: Public API](/reference/public-api) — the OAuth surface on `:9000` - [Guides: Admin API](/guides/admin-api) — task-focused walkthroughs (create clients, revoke tokens, inspect issuances) - [Reference: Configuration](/reference/configuration#admin) — the `admin.*` config block - [Reference: Metrics & CLI](/reference/metrics-and-cli#cli-reference) — CLI equivalents for every endpoint here - [Security: Threat model](/security/threat-model) — admin API is threat T8; hardening notes --- ## reference/configuration.mdx --- title: Configuration description: "Every configuration key AuthPlane exposes — YAML shape, environment variable mapping, defaults, and semantics." section: Reference sectionOrder: 7 order: 1 --- # Configuration > **TL;DR** — AuthPlane loads config in three layers, highest priority last: **built-in defaults → YAML file → `AUTHPLANE_*` env vars**. Everything you need to boot is optional (SQLite + auto-generated keys + no config file); everything you need for production is explicit and validated at boot. This page lists every section, every key, and every env var. ## Precedence 1. **Defaults** — sensible values for local development, built into the binary. 2. **YAML file** — `authserver serve --config config.yaml`. 3. **Environment variables** — `AUTHPLANE_*` prefix; override YAML. Env vars win because you want the same YAML file across environments with per-deployment overrides for secrets and issuer URLs. Comma-separated list variables (like `AUTHPLANE_OIDC_SCOPES=openid,email,profile`) split on commas. Duration variables accept Go duration strings (`10s`, `5m`, `1h`, `24h`). Bool variables accept `true`, `false`, `1`, `0`. ## Minimal dev config Zero config works — the binary starts on `:9000` with SQLite, auto-generated signing keys, and no resource. The moment you need to accept a real MCP client, add a resource: ```yaml resources: - slug: my-mcp-server uri: http://localhost:3000/ backend_kind: mint display_name: My MCP Server scopes: - name: tools/echo description: Echo tool ``` Or a single Mint resource via env vars: ```bash export AUTHPLANE_RESOURCE_URI=http://localhost:3000/ export AUTHPLANE_RESOURCE_SCOPES=tools/echo authserver serve ``` ## Minimal production config Boot validation enforces these when `server.issuer` is not `localhost`: ```yaml server: issuer: https://auth.example.com session: secret: "generate-a-32-byte-random-string" # openssl rand -hex 32 secure: true admin: api_key: "generate-a-secure-api-key" # openssl rand -hex 32 ``` Anything missing here fails boot with a structured error — you cannot accidentally deploy a non-local AuthPlane with a random ephemeral session secret. --- ## `server` Public HTTP server settings. ```yaml server: issuer: http://localhost:9000 # Public URL — appears in AS metadata and JWT `iss` address: ":9000" # Listen address read_timeout: 30s write_timeout: 30s idle_timeout: 120s shutdown_wait: 10s # Graceful shutdown drain allowed_origins: [] # CORS allowlist for browser MCP clients ``` | Env var | Field | |---|---| | `AUTHPLANE_SERVER_ISSUER` | `server.issuer` | | `AUTHPLANE_SERVER_ADDRESS` | `server.address` | | `AUTHPLANE_SERVER_READ_TIMEOUT` | `server.read_timeout` | | `AUTHPLANE_SERVER_WRITE_TIMEOUT` | `server.write_timeout` | | `AUTHPLANE_SERVER_IDLE_TIMEOUT` | `server.idle_timeout` | | `AUTHPLANE_SERVER_SHUTDOWN_WAIT` | `server.shutdown_wait` | | `AUTHPLANE_SERVER_ALLOWED_ORIGINS` | `server.allowed_origins` (comma-separated; `*` for all) | **`issuer`** appears in the `/.well-known/oauth-authorization-server` metadata and as the `iss` claim in every JWT. MCP clients use it for discovery. Must match the URL clients use to reach AuthPlane exactly — no trailing slash. **`allowed_origins`** — empty by default. Browser-based MCP clients (MCP Inspector, Claude Desktop web) fail CORS preflight silently without it. For local dev set `AUTHPLANE_SERVER_ALLOWED_ORIGINS=*`; for production set an explicit allowlist. Server-to-server-only deployments may leave this empty. --- ## `storage` Persistence layer. Both backends implement the same interfaces — choose by `driver`. ```yaml storage: driver: sqlite # "sqlite" or "postgres" sqlite: path: data/authserver.db wal: true # WAL mode for concurrent reads postgres: dsn: "postgres://user:pass@localhost:5432/authserver?sslmode=require" max_conns: 25 min_conns: 5 max_conn_lifetime: 1h max_conn_idle_time: 30m ``` | Env var | Field | |---|---| | `AUTHPLANE_STORAGE_DRIVER` | `storage.driver` | | `AUTHPLANE_STORAGE_SQLITE_PATH` | `storage.sqlite.path` | | `AUTHPLANE_STORAGE_SQLITE_WAL` | `storage.sqlite.wal` | | `AUTHPLANE_STORAGE_POSTGRES_DSN` | `storage.postgres.dsn` | | `AUTHPLANE_STORAGE_POSTGRES_MAX_CONNS` | `storage.postgres.max_conns` | | `AUTHPLANE_STORAGE_POSTGRES_MIN_CONNS` | `storage.postgres.min_conns` | | `AUTHPLANE_STORAGE_POSTGRES_MAX_CONN_LIFETIME` | `storage.postgres.max_conn_lifetime` | | `AUTHPLANE_STORAGE_POSTGRES_MAX_CONN_IDLE_TIME` | `storage.postgres.max_conn_idle_time` | **SQLite** is default and recommended for single-instance deployments. Pure Go via `modernc.org/sqlite` (no CGO). WAL mode enables concurrent reads while writes serialize. DB file is created automatically on first boot. **PostgreSQL** is required for multi-instance (HA) deployments. Uses `LISTEN/NOTIFY` for cross-instance signing-key rotation. Run `authserver migrate` before first start. --- ## `signing` JWT signing keys and algorithms. ```yaml signing: algorithm: ES256 # "ES256" (default) or "RS256" key_store: keyfile # "keyfile", "postgres_key", or "vault_transit" key_path: data/keys # Keyfile store: directory for PEM files vault_transit: # Only when key_store: vault_transit address: https://vault:8200 token: "" # Static token (mutually exclusive with approle) mount: transit key_name: authserver-signing timeout: 10s approle: role_id: "" secret_id: "" mount: approle ``` | Env var | Field | |---|---| | `AUTHPLANE_SIGNING_ALGORITHM` | `signing.algorithm` | | `AUTHPLANE_SIGNING_KEY_STORE` | `signing.key_store` | | `AUTHPLANE_SIGNING_KEY_PATH` | `signing.key_path` | | `AUTHPLANE_SIGNING_PG_ENCRYPTION_KEY_ENV` | `signing.postgres_key.encryption_key_env` (Postgres store only) | | `AUTHPLANE_VAULT_ADDR` | `signing.vault_transit.address` | | `AUTHPLANE_VAULT_TOKEN` | `signing.vault_transit.token` | | `AUTHPLANE_VAULT_TRANSIT_MOUNT` | `signing.vault_transit.mount` | | `AUTHPLANE_VAULT_TRANSIT_KEY_NAME` | `signing.vault_transit.key_name` | | `AUTHPLANE_VAULT_TIMEOUT` | `signing.vault_transit.timeout` | | `AUTHPLANE_VAULT_APPROLE_ROLE_ID` | `signing.vault_transit.approle.role_id` | | `AUTHPLANE_VAULT_APPROLE_SECRET_ID` | `signing.vault_transit.approle.secret_id` | | `AUTHPLANE_VAULT_APPROLE_MOUNT` | `signing.vault_transit.approle.mount` | **Algorithms** — `ES256` (ECDSA P-256) is default: compact tokens, fast verification. `RS256` (RSA 2048) for environments that require it (legacy IDPs, HSM constraints). **Key stores** — `keyfile` writes PEM keys to `key_path`. `postgres_key` (HA-safe) stores keys in the storage DB with encryption at rest — required when running multiple instances so key rotation propagates via `LISTEN/NOTIFY`. Note: `postgres` (without `_key`) fails boot; the suffix disambiguates from the storage driver of the same name. `vault_transit` delegates signing to HashiCorp Vault Transit — private keys never leave Vault. See [Security: Key management](/security/key-management) and [Operate: Vault Transit](/operate/vault-transit). **Key rotation** — `authserver admin key rotate` generates a new signing key. The old key stays in the JWKS until it expires so in-flight tokens keep verifying. --- ## `dcr` — Dynamic Client Registration (RFC 7591) ```yaml dcr: mode: open # "open", "approved_redirects", or "admin_only" approved_redirects: # Used when mode: approved_redirects - http://localhost:* - https://inspector.mcp.garden/* rate_limit: 10 # Registrations per second rate_limit_burst: 20 default_token_expiry: 15m default_refresh_expiry: 168h # 7 days ``` | Env var | Field | |---|---| | `AUTHPLANE_DCR_MODE` | `dcr.mode` | | `AUTHPLANE_DCR_APPROVED_REDIRECTS` | `dcr.approved_redirects` (comma-separated) | | `AUTHPLANE_DCR_RATE_LIMIT` | `dcr.rate_limit` | | `AUTHPLANE_DCR_RATE_LIMIT_BURST` | `dcr.rate_limit_burst` | | `AUTHPLANE_DCR_DEFAULT_TOKEN_EXPIRY` | `dcr.default_token_expiry` | | `AUTHPLANE_DCR_DEFAULT_REFRESH_EXPIRY` | `dcr.default_refresh_expiry` | **Modes:** - `open` — any client can register with any redirect URI. Development only. - `approved_redirects` — anyone can register, but the redirect URI must match a pattern in `approved_redirects`. Balanced for production with well-known MCP clients. - `admin_only` — clients must be pre-registered via the Admin API or CLI. Hardened default for internal-only deployments. **Rate limits** apply per-IP to the `/oauth/register` endpoint. Defaults are conservative — bump for automated CI environments. --- ## `cimd` — Client ID Metadata Document Auto-registration via URL `client_id` (draft-ietf-oauth-client-id-metadata-document). ```yaml cimd: enabled: true require_https: true # Require HTTPS for CIMD document URLs cache_ttl: 1h # Cache fetched documents for this long fetch_timeout: 10s # HTTP timeout for fetching CIMD docs ``` | Env var | Field | |---|---| | `AUTHPLANE_CIMD_ENABLED` | `cimd.enabled` | | `AUTHPLANE_CIMD_REQUIRE_HTTPS` | `cimd.require_https` | | `AUTHPLANE_CIMD_CACHE_TTL` | `cimd.cache_ttl` | | `AUTHPLANE_CIMD_FETCH_TIMEOUT` | `cimd.fetch_timeout` | When a client presents a URL as its `client_id`, AuthPlane fetches and validates the metadata document at that URL. Set `require_https: false` only for local dev with `http://` CIMD URLs. --- ## `session` Cookie-based session for the login and consent pages. ```yaml session: cookie_name: authserver_session max_age: 24h secure: false # Must be true in production same_site: lax # "lax", "strict", or "none" secret: "" # Required in production (32+ bytes) ``` | Env var | Field | |---|---| | `AUTHPLANE_SESSION_COOKIE_NAME` | `session.cookie_name` | | `AUTHPLANE_SESSION_MAX_AGE` | `session.max_age` | | `AUTHPLANE_SESSION_SECURE` | `session.secure` | | `AUTHPLANE_SESSION_SAME_SITE` | `session.same_site` | | `AUTHPLANE_SESSION_SECRET` | `session.secret` | The **session secret** signs and encrypts session cookies. For localhost dev, a random ephemeral secret is generated if none is set — sessions don't survive restart. For production, set a stable 32+ byte value (`openssl rand -hex 32`). --- ## `rate_limit` Global rate limiting + brute-force protection on auth endpoints. ```yaml rate_limit: enabled: true requests_per_second: 100 # Global RPS limit burst: 200 # Burst allowance auth_fail_max: 10 # Max failed auth attempts per IP auth_fail_window: 10m # Window for counting failures auth_lockout: 15m # Lockout duration after exceeding max ``` | Env var | Field | |---|---| | `AUTHPLANE_RATE_LIMIT_ENABLED` | `rate_limit.enabled` | | `AUTHPLANE_RATE_LIMIT_RPS` | `rate_limit.requests_per_second` | | `AUTHPLANE_RATE_LIMIT_BURST` | `rate_limit.burst` | | `AUTHPLANE_RATE_LIMIT_AUTH_FAIL_MAX` | `rate_limit.auth_fail_max` | | `AUTHPLANE_RATE_LIMIT_AUTH_FAIL_WINDOW` | `rate_limit.auth_fail_window` | | `AUTHPLANE_RATE_LIMIT_AUTH_LOCKOUT` | `rate_limit.auth_lockout` | Token-bucket global limit plus per-IP tracking of failed logins. Lockout applies to the `/login` endpoint only — OAuth endpoints stay open. --- ## `oauth` ```yaml oauth: require_scope: true # Require scope parameter in authorize requests ``` | Env var | Field | |---|---| | `AUTHPLANE_OAUTH_REQUIRE_SCOPE` | `oauth.require_scope` | When `true` (default), `/oauth/authorize` requests without a `scope` parameter are rejected with `invalid_scope` per RFC 6749 §3.3. When `false`, missing scope defaults to all registered scopes for the target resource — useful for MCP clients (like Claude Code at v0.1.x) that don't send scope on `/authorize`. See [ADR-012](https://github.com/authplane/authserver/blob/main/wiki/DECISIONS.md). ## Optional grants — disabled by default Three grants are off by default. Turn them on explicitly: ```yaml client_credentials: enabled: false # RFC 6749 §4.4 token_expiry: 1h dpop: enabled: false # RFC 9449 nonce_ttl: 60s proof_lifetime: 60s require_nonce: false token_exchange: enabled: false # RFC 8693 allow_self_exchange: false max_chain_depth: 5 token_expiry: 1h ``` | Env var | Field | Default | |---|---|---| | `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED` | `client_credentials.enabled` | `false` | | `AUTHPLANE_CLIENT_CREDENTIALS_TOKEN_EXPIRY` | `client_credentials.token_expiry` | `1h` | | `AUTHPLANE_DPOP_ENABLED` | `dpop.enabled` | `false` | | `AUTHPLANE_DPOP_NONCE_TTL` | `dpop.nonce_ttl` | `60s` | | `AUTHPLANE_DPOP_PROOF_LIFETIME` | `dpop.proof_lifetime` | `60s` | | `AUTHPLANE_DPOP_REQUIRE_NONCE` | `dpop.require_nonce` | `false` | | `AUTHPLANE_TOKEN_EXCHANGE_ENABLED` | `token_exchange.enabled` | `false` | | `AUTHPLANE_TOKEN_EXCHANGE_ALLOW_SELF_EXCHANGE` | `token_exchange.allow_self_exchange` | `false` | | `AUTHPLANE_TOKEN_EXCHANGE_MAX_CHAIN_DEPTH` | `token_exchange.max_chain_depth` | `5` | | `AUTHPLANE_TOKEN_EXCHANGE_TOKEN_EXPIRY` | `token_exchange.token_expiry` | `1h` | - **`client_credentials`** — required for machine-to-machine flows and any SDK that performs server-to-server introspection. See [Concepts: Grants & flows](/concepts/grants-and-flows) and [Topologies: Backend service + MCP](/topologies/m2m-client-credentials). - **`dpop`** — sender-constrained tokens. Additive: clients that don't send a DPoP header still get bearer tokens (unless `require_nonce: true` locks it down). See [Concepts: DPoP](/concepts/dpop). - **`token_exchange`** — powers delegation, Broker resources, and Cross-App Access. Cross-client authorization is enforced per-resource via `policy.exchange.allowed_client_ids` on each registered resource. See [Concepts: Delegation & act-chain](/concepts/delegation-act-chain). ## XAA and JWT Bearer — YAML-only at v0.1.x Enterprise-Managed Auth (Cross-App Access) is configured via YAML at v0.1.x. Env-var overrides are not exposed for this section. ```yaml xaa: enabled: false jwks_cache_ttl: 1h token_expiry: 1h max_assertion_age: 5m subject_mode: auto_map # "auto_map" or "strict" require_resource: true # reject assertions that don't specify `resource=` ``` Trusted IdPs, policies, and subject mappings are managed via the Admin REST API only — `POST /admin/idps`, `POST /admin/xaa/policies`, `POST /admin/xaa/subject-mappings`. There is no `authserver admin xaa …` CLI subcommand. See [Concepts: Cross-App Access](/concepts/xaa) and [Guides: Enterprise-Managed Auth](/guides/xaa). ## `agents` ```yaml agents: enable_jwks_listing: false # Publish agent JWK sets on /.well-known/agents.json ``` | Env var | Field | |---|---| | `AUTHPLANE_AGENTS_ENABLE_JWKS_LISTING` | `agents.enable_jwks_listing` | Agent identity claims (`agent_id`, `agent_chain`) are emitted on every token by default. This flag controls the optional publishing of agent JWK sets for third-party verification. --- ## `admin` Admin API + Admin UI, on a separate port from public OAuth. ```yaml admin: enabled: true address: ":9001" # Separate port api_key: "" # Required in production ``` | Env var | Field | |---|---| | `AUTHPLANE_ADMIN_ENABLED` | `admin.enabled` | | `AUTHPLANE_ADMIN_ADDRESS` | `admin.address` | | `AUTHPLANE_ADMIN_API_KEY` | `admin.api_key` | Hosts both the REST API under `/admin/*` and the built-in React Admin UI at `/admin/ui/`. Authenticate API requests with `Authorization: Bearer `. The UI uses the same key entered in-browser and stored in `sessionStorage`. **Never expose `:9001` to the public internet** — keep it on an internal network or behind a firewall/NetworkPolicy. --- ## `oidc` — Upstream OIDC Federation ```yaml oidc: enabled: false issuer: https://accounts.google.com client_id: "" client_secret: "" display_name: Google # Button text on login page scopes: [openid, email, profile] redirect_uri: https://auth.example.com/oidc/callback show_local_login: true # Show password form alongside OIDC button include_groups_scope: true # Include "groups" scope if upstream supports it connector_id: "" # Dex connector_id parameter (optional) ``` | Env var | Field | |---|---| | `AUTHPLANE_OIDC_ENABLED` | `oidc.enabled` | | `AUTHPLANE_OIDC_ISSUER` | `oidc.issuer` | | `AUTHPLANE_OIDC_CLIENT_ID` | `oidc.client_id` | | `AUTHPLANE_OIDC_CLIENT_SECRET` | `oidc.client_secret` | | `AUTHPLANE_OIDC_DISPLAY_NAME` | `oidc.display_name` | | `AUTHPLANE_OIDC_SCOPES` | `oidc.scopes` (comma-separated) | | `AUTHPLANE_OIDC_REDIRECT_URI` | `oidc.redirect_uri` | | `AUTHPLANE_OIDC_SHOW_LOCAL_LOGIN` | `oidc.show_local_login` | | `AUTHPLANE_OIDC_INCLUDE_GROUPS_SCOPE` | `oidc.include_groups_scope` | | `AUTHPLANE_OIDC_CONNECTOR_ID` | `oidc.connector_id` | Validation: `oidc.issuer` and `oidc.redirect_uri` must use HTTPS in production; `client_id`, `client_secret`, and `redirect_uri` are required when enabled. See [Guides: Federate to your IdP](/guides/federate-idp). --- ## `resources` Every MCP server (Mint) and every upstream provider target (Broker) is a `resources` entry. ```yaml resources: - slug: my-mcp-server uri: http://mcp-server:3000/mcp # MCP canonical form — no trailing slash backend_kind: mint display_name: My MCP Server scopes: - name: tools/echo description: Echo a message - name: tools/query_database description: Query the database policy: exchange: allowed_client_ids: [] # Empty = any consented client runtime: client_ids: [] # Empty = no runtime binding connect: allowed_return_urls: [] # Overrides connect.allowed_return_urls ``` | Env var | Field | |---|---| | `AUTHPLANE_RESOURCE_URI` | Single Mint resource URI; slug auto-derived from host | | `AUTHPLANE_RESOURCE_SCOPES` | Comma-separated scope names | Env vars support a single Mint resource. For multiple resources or any Broker, use YAML or the Admin API. **`uri` must match byte-for-byte** what your MCP server publishes in its Protected Resource Metadata (RFC 9728). Compliant MCP clients echo the PRM `resource` field verbatim on `/oauth/authorize` and `/oauth/token`; AuthPlane uses exact string matching for audience validation. A one-character mismatch silently rejects every token. Use the MCP spec canonical form (no trailing slash, lowercase host, no default port). Watch for scheme/host case, default ports, percent-encoding — see [Guides: Connect an MCP client](/guides/connect-mcp-client). **`backend_kind`** — `mint` (AS issues its own JWT) or `broker` (AS vends an upstream provider token via RFC 8693). See [Concepts: Architecture](/concepts/architecture#unified-resource-model). **`policy.exchange.allowed_client_ids`** — per-resource replacement for the legacy global cross-client allowlist. Empty means any consented client can exchange into this resource. **`policy.runtime.client_ids`** — per-resource runtime binding: whitelist of clients whose tokens are accepted at THIS resource. See [Guides: Runtime client binding](/guides/runtime-client-binding). --- ## `observability` Logs, traces, metrics. ```yaml observability: logging: level: info # debug | info | warn | error format: json # json | text add_source: false # Include file:line in log records outputs: stdout: true otel: false otel_endpoint: "" # OTLP gRPC endpoint insecure: false # Allow plaintext gRPC tracing: enabled: false endpoint: "" # OTLP gRPC endpoint (e.g. localhost:4317) insecure: false sample_rate: 1.0 # 0.0 to 1.0 metrics: provider: prometheus # prometheus | otel | both | none path: /metrics # Prometheus scrape endpoint otel_endpoint: "" # OTLP endpoint when provider=otel or both insecure: false ``` | Env var | Field | |---|---| | `AUTHPLANE_LOG_LEVEL` | `observability.logging.level` | | `AUTHPLANE_LOG_FORMAT` | `observability.logging.format` | | `AUTHPLANE_LOG_ADD_SOURCE` | `observability.logging.add_source` | | `AUTHPLANE_LOG_STDOUT` | `observability.logging.outputs.stdout` | | `AUTHPLANE_LOG_OTEL` | `observability.logging.outputs.otel` | | `AUTHPLANE_LOG_OTEL_ENDPOINT` | `observability.logging.outputs.otel_endpoint` | | `AUTHPLANE_LOG_OTEL_INSECURE` | `observability.logging.outputs.insecure` | | `AUTHPLANE_TRACING_ENABLED` | `observability.tracing.enabled` | | `AUTHPLANE_TRACING_ENDPOINT` | `observability.tracing.endpoint` | | `AUTHPLANE_TRACING_INSECURE` | `observability.tracing.insecure` | | `AUTHPLANE_TRACING_SAMPLE_RATE` | `observability.tracing.sample_rate` | | `AUTHPLANE_METRICS_PROVIDER` | `observability.metrics.provider` | | `AUTHPLANE_METRICS_PATH` | `observability.metrics.path` | | `AUTHPLANE_METRICS_OTEL_ENDPOINT` | `observability.metrics.otel_endpoint` | | `AUTHPLANE_METRICS_INSECURE` | `observability.metrics.insecure` | Full metric catalog and Prometheus setup in [Guides: Monitoring](/guides/monitoring) and [Reference: Metrics & CLI](/reference/metrics-and-cli). --- ## `data_encryption` Encrypts sensitive data at rest — upstream refresh grants in `broker_grants`, and (when Postgres key store is enabled) signing keys. ```yaml data_encryption: driver: aes_master # "aes_master" or "vault_transit_encrypt" aes_master: key_env: AUTHPLANE_VAULT_ENCRYPTION_KEY # Env var containing hex-encoded 256-bit key old_key_env: AUTHPLANE_VAULT_OLD_KEY # Previous key for rotation (optional) vault_transit_encrypt: address: https://vault:8200 auth_method: token # "token" or "approle" token_env: VAULT_TOKEN mount_path: transit key_name: authserver-data approle: role_id_env: VAULT_APPROLE_ROLE_ID secret_id_env: VAULT_APPROLE_SECRET_ID ``` | Env var | Field | |---|---| | `AUTHPLANE_DATA_ENCRYPTION_DRIVER` | `data_encryption.driver` | | `AUTHPLANE_DATA_ENCRYPTION_KEY_ENV` | `data_encryption.aes_master.key_env` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_ADDRESS` | `data_encryption.vault_transit_encrypt.address` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_AUTH_METHOD` | `data_encryption.vault_transit_encrypt.auth_method` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_TOKEN_ENV` | `data_encryption.vault_transit_encrypt.token_env` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_MOUNT_PATH` | `data_encryption.vault_transit_encrypt.mount_path` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_KEY_NAME` | `data_encryption.vault_transit_encrypt.key_name` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_ROLE_ID_ENV` | `data_encryption.vault_transit_encrypt.approle.role_id_env` | | `AUTHPLANE_DATA_ENCRYPTION_VAULT_SECRET_ID_ENV` | `data_encryption.vault_transit_encrypt.approle.secret_id_env` | | `AUTHPLANE_VAULT_ENCRYPTION_KEY` | Actual AES-256-GCM key (64-char hex string, referenced by `key_env`) | | `AUTHPLANE_VAULT_OLD_KEY` | Previous AES key during rotation (referenced by `old_key_env`) | **AES master key** — local AES-256-GCM with HKDF-derived per-purpose subkeys. Generate: `openssl rand -hex 32`. **Vault Transit encrypt** — delegates encryption to HashiCorp Vault Transit; plaintext never touches disk. Required for compliance environments. --- ## `broker_providers` Upstream OAuth / API-key / service-account providers that Broker resources reference. See [Concepts: Token Vault](/concepts/token-vault). ```yaml broker_providers: - slug: github display_name: GitHub protocol: oauth config_data: client_id: "your-github-app-client-id" client_secret_ref: "CONNECTOR_GITHUB_SECRET" authorize_url: "https://github.com/login/oauth/authorize" token_url: "https://github.com/login/oauth/access_token" response_format: standard - slug: slack display_name: Slack protocol: oauth config_data: client_id: "your-slack-app-client-id" client_secret_ref: "CONNECTOR_SLACK_SECRET" authorize_url: "https://slack.com/oauth/v2/authorize" token_url: "https://slack.com/api/oauth.v2.access" response_format: form ``` No env-var shortcut — broker providers are managed via YAML or the Admin API/CLI (`authserver admin provider create`). The `slug` is the public ID; Broker resources reference it via `broker_provider_slug`. `protocol` — one of `oauth`, `apikey`, `service_account`. Each dispatches to a different adapter under `internal/brokerproto/`. Default scopes live on the Broker `Resource` row, not the provider — one provider can back multiple resources with different scope sets. --- ## `connect` The user-facing OAuth flow to grant your MCP server upstream access. ```yaml connect: state_secret: "generate-a-32-byte-random-string" # HMAC key for state tokens allowed_return_urls: # Global default (per-resource override available) - http://localhost:* - https://myapp.example.com/* redirect_base_url: http://localhost:9000 # Base URL for OAuth callbacks ``` | Env var | Field | |---|---| | `AUTHPLANE_CONNECT_STATE_SECRET` | `connect.state_secret` | | `AUTHPLANE_CONNECT_ALLOWED_RETURN_URLS` | `connect.allowed_return_urls` (comma-separated) | | `AUTHPLANE_CONNECT_REDIRECT_BASE_URL` | `connect.redirect_base_url` | **`state_secret`** — HMAC key for signing state tokens during the connect flow. Must be at least 32 characters (`openssl rand -hex 32`). Different key from `session.secret`. **`allowed_return_urls`** — global default whitelist for connect-flow return URLs. Each resource can override via `resources[].policy.connect.allowed_return_urls` for finer control. --- ## Validation rules enforced at boot Boot fails with a structured error if any of these are violated: - `session.secret` required when `server.issuer` is not localhost - `session.secure: true` required when `server.issuer` is not localhost - `admin.api_key` required when `admin.enabled: true` and `server.issuer` is not localhost - `oidc.client_id` + `client_secret` + `redirect_uri` all required when `oidc.enabled: true` - `oidc.issuer` + `oidc.redirect_uri` must use HTTPS in production - Every enabled feature's required-config combination is validated up-front (partial config → boot fails at startup, not at first request) - `resources[].uri` must be a well-formed URL ## Config precedence in practice Debug your effective config by comparing all three sources: ```bash # See defaults + YAML $ authserver serve --config config.yaml --dry-run # See what env vars are set $ env | grep AUTHPLANE_ # See the final self-check report in the boot log $ authserver serve --config config.yaml 2>&1 | grep -A20 "feature self-check" ``` Every enabled feature logs a one-liner at boot indicating what config it picked up. Misconfigurations are fatal — the process exits with a structured error naming the missing keys. ## Related - [Concepts: Architecture](/concepts/architecture) — the layers each config section maps to - [Guides: Federate to your IdP](/guides/federate-idp) — full OIDC setup by provider - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — tuning the DPoP knobs above - [Guides: Wire up the Token Vault](/guides/token-vault) — putting `broker_providers` + `connect` together - [Operate: Vault Transit](/operate/vault-transit) — key store + data encryption - [Reference: Metrics & CLI](/reference/metrics-and-cli) — what `observability` produces - [Security: Key management](/security/key-management) — rotation, algorithm choice, HSM --- ## reference/errors.mdx --- title: Errors description: "Complete error catalog — OAuth error codes, WWW-Authenticate patterns (Bearer + DPoP), RFC 9457 problem+json envelope, MCP JSON-RPC -32042, and top-20 symptom→fix table." section: Reference sectionOrder: 7 order: 5 --- # Errors > **TL;DR** — Every error AuthPlane can return, in one place. OAuth errors follow RFC 6749 §5.2 with an RFC 9457 problem+json envelope on top. Auth challenges use RFC 6750 (`Bearer`) or RFC 9449 (`DPoP`) `WWW-Authenticate` headers. Consent errors from the SDKs surface as MCP JSON-RPC `-32042` `UrlElicitationRequiredError`. Symptom→fix table at the bottom for the ones you'll actually hit. ## Error envelope Every error response includes **both** OAuth error fields **and** RFC 9457 Problem Details fields, served with `Content-Type: application/problem+json`: ```json HTTP/1.1 400 Bad Request Content-Type: application/problem+json { "error": "invalid_grant", "error_description": "authorization code has already been used", "type": "https://docs.authplane.ai/errors/invalid_grant", "title": "Bad Request", "status": 400, "detail": "authorization code has already been used" } ``` - `error` + `error_description` — OAuth 2.1 wire format (RFC 6749 §5.2). Every OAuth client library reads these. - `type` + `title` + `status` + `detail` — RFC 9457 Problem Details. Standard HTTP APIs read these. Both are always present. Pick whichever your stack understands. ## OAuth error codes Domain errors are sentinels in `internal/domain/errors.go`. Each carries an OAuth code + a default HTTP status. ### OAuth error codes (mixed sources) The codes below all use the RFC 6749 §5.2 envelope shape but come from different specs. Sourcing matters if you're building on top of these: §5.2 defines only the six token-endpoint codes; the rest are borrowed from the authorization endpoint, other RFCs, or OIDC. | OAuth code | HTTP | Source | Typical cause | |---|---|---|---| | `invalid_request` | 400 | RFC 6749 §5.2 | Missing/malformed parameter, bad `redirect_uri` | | `invalid_client` | 401 | RFC 6749 §5.2 | Unknown client_id, wrong secret, suspended client | | `invalid_grant` | 400 | RFC 6749 §5.2 | Expired code, wrong PKCE verifier, replayed code, revoked refresh family | | `unauthorized_client` | 400 | RFC 6749 §5.2 | Client lacks the requested `grant_type` in its registration | | `unsupported_grant_type` | 400 | RFC 6749 §5.2 | Grant type disabled at the AS (e.g. `client_credentials.enabled: false`) | | `invalid_scope` | 400 | RFC 6749 §5.2 | Scope not declared on the target resource | | `access_denied` | 403 | RFC 6749 §4.1.2.1 (authorize endpoint) | User denied consent; DCR blocked; state-token owner mismatch | | `server_error` | 500 | RFC 6749 §4.1.2.1 (authorize endpoint) | Encryption/decryption failed, key not found, rotation conflict | | `consent_required` | 400 | OIDC Core §3.1.2.6 | Token exchange target needs upstream user consent (Broker resource) | | `slow_down` | 429 | RFC 8628 (device flow) | Rate limit hit | ### DPoP-specific codes (RFC 9449) RFC 9449 defines two different delivery mechanisms depending on which server rejects the proof: - **Authorization server** (`/oauth/token`, §8) — HTTP **400** with the RFC 6749 §5.2 JSON envelope `{"error":"…"}`. **No `WWW-Authenticate` header.** If the AS needs a nonce it *also* sets `DPoP-Nonce: `. - **Resource server** (§9) — HTTP **401** with `WWW-Authenticate: DPoP error="…"`. This is the challenge form clients see on `tools/*` calls. | Code | AS (§8) | RS (§9) | Meaning | |---|---|---|---| | `invalid_dpop_proof` | 400 | 401 | Proof malformed, wrong `htm`/`htu`/`ath`, replayed `jti`, signature invalid, JWK thumbprint doesn't match `cnf.jkt`, or two `DPoP` headers on the same request (§4.3) | | `use_dpop_nonce` | 400 | 401 | Server requires nonce; response includes `DPoP-Nonce: ` | Fix for either: regenerate the proof against the exact request URL + method, and (if the server sent `DPoP-Nonce`) include that value as the `nonce` claim. ### AuthPlane-specific codes | Code | HTTP | Meaning | |---|---|---| | `scope_not_granted` | 400 | Broker resource: requested scope not in the user's stored `broker_grants.scopes_granted` — need re-consent via `/connect/{provider}` | | `token_expired` | 400 | Vault: upstream access token expired and cannot be refreshed (no refresh token stored) | | `not_found` | 404 | Admin API: resource / user / client / grant doesn't exist | | `conflict` | 409 | Admin API: unique constraint (duplicate slug on create, concurrent update version mismatch) | ## `WWW-Authenticate` challenge patterns The `WWW-Authenticate` response header tells clients how to retry. ### Bearer (RFC 6750 §3) ``` WWW-Authenticate: Bearer realm="https://mcp.example.com/mcp", error="invalid_token", error_description="The access token expired", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" ``` The `resource_metadata` field (RFC 9728 §5.1) tells the client where to fetch the PRM document — which in turn tells it where to fetch a new token. All AuthPlane SDKs emit this automatically on 401 responses. ### DPoP (RFC 9449 §7.1) ``` WWW-Authenticate: DPoP realm="https://mcp.example.com/mcp", error="invalid_dpop_proof", error_description="DPoP proof jti has already been used", algs="ES256 RS256 PS256", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" ``` For `use_dpop_nonce` the response ALSO includes: ``` DPoP-Nonce: ``` The client must include this nonce in the next DPoP proof's `nonce` claim. ### Insufficient scope Whether Bearer or DPoP, scope failures return `error="insufficient_scope"` with a `scope=` field listing what's required: ``` HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", error_description="token missing required scope", scope="tools/write" ``` ## MCP JSON-RPC errors Two MCP-specific errors that the SDKs surface on top of the OAuth layer: ### `-32042` — URL elicitation required Emitted when a tool handler triggers a `client.exchange()` call that hits `consent_required` on a Broker resource. The SDK auto-translates the OAuth error into an MCP JSON-RPC error `-32042` with a `consentUrl` in the data payload: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32042, "message": "URL elicitation required", "data": { "elicitation_id": "elic_...", "url": "https://auth.example.com/connect/github?return_to=..." } } } ``` The MCP client shows the user the URL, they complete the Connect flow, then the client retries the original `tools/call`. The SDKs handle this automatically — you don't `try/except` for it in tool code. See [Concepts: Token Vault](/concepts/token-vault). ## Complete domain error catalog Every error sentinel in `internal/domain/errors.go`. Grouped by concern. ### OAuth core | Sentinel | Code | Typical trigger | |---|---|---| | `ErrInvalidGrant` | `invalid_grant` | Expired/wrong code, wrong verifier, wrong `redirect_uri` | | `ErrInvalidClient` | `invalid_client` | Unknown client_id, wrong secret | | `ErrClientNotFound` | `not_found` | Admin lookup miss (distinct from OAuth `invalid_client`) | | `ErrInvalidScope` | `invalid_scope` | Scope not in client's allowed set | | `ErrCodeConsumed` | `invalid_grant` | Auth code replay (already used) | | `ErrFamilyRevoked` | `invalid_grant` | Refresh token family revoked (theft detected) | | `ErrConsentRequired` | `consent_required` | User consent needed but not present | | `ErrSessionExpired` | `invalid_grant` | Auth session timed out | | `ErrInvalidRedirectURI` | `invalid_request` | `redirect_uri` doesn't match registration | | `ErrInvalidPKCE` | `invalid_grant` | PKCE verification failed | | `ErrClientSuspended` | `invalid_client` | Client suspended or revoked | | `ErrRateLimited` | `slow_down` | Too many requests | | `ErrUserNotFound` | `not_found` | Unknown user | | `ErrInvalidCredentials` | `invalid_grant` | Wrong password | | `ErrUnsupportedGrantType` | `unsupported_grant_type` | Grant disabled at AS | | `ErrRefreshTokenReused` | `invalid_grant` | Refresh token already consumed | | `ErrRegistrationDisabled` | `access_denied` | DCR mode is `admin_only` | | `ErrUnauthorizedClient` | `unauthorized_client` | Client not permitted for grant type | ### CIMD | Sentinel | Code | Trigger | |---|---|---| | `ErrCIMDFetchFailed` | `invalid_client` | HTTP fetch of CIMD doc failed | | `ErrCIMDInvalid` | `invalid_client` | CIMD doc validation failed | ### OIDC federation | Sentinel | Code | Trigger | |---|---|---| | `ErrOIDCAuthFailed` | `access_denied` | Upstream OIDC authentication failed | ### Encryption + keys | Sentinel | Code | Trigger | |---|---|---| | `ErrEncryptionFailed` | `server_error` | Encryption failed (bad input) | | `ErrDecryptionFailed` | `server_error` | Decryption failed (wrong key/context/tampered) | | `ErrEncryptorUnavailable` | `server_error` | Encryption backend down | | `ErrRotationConflict` | `server_error` | Concurrent key rotation detected | | `ErrKeyNotFound` | `server_error` | Signing key not found in store | ### Broker / Vault | Sentinel | Code | Trigger | |---|---|---| | `ErrConnectionNotFound` | `not_found` | Vault connection not found | | `ErrConnectionConflict` | `conflict` | Concurrent update detected | | `ErrConnectionExists` | `conflict` | Connection already exists for (owner, service) | | `ErrStateNotFound` | `invalid_request` | State token expired or consumed | | `ErrServiceNotFound` | `invalid_request` | Connector service not registered | | `ErrInvalidState` | `invalid_request` | State token tampered | | `ErrStateForeignUser` | `access_denied` | State token belongs to a different user | | `ErrInvalidReturnURL` | `invalid_request` | Return URL not in `allowed_return_urls` | | `ErrScopeNotGranted` | `scope_not_granted` | Requested scope not in `broker_grants.scopes_granted` | | `ErrVaultTokenExpired` | `token_expired` | Upstream token expired and no refresh available | ### DPoP | Sentinel | Code | Trigger | |---|---|---| | `ErrDPoPReplay` | `invalid_dpop_proof` | Proof `jti` already used | | `ErrDPoPInvalidProof` | `invalid_dpop_proof` | Bad `alg`/`htm`/`htu`/`iat`/`ath`/signature | | `ErrDPoPNonceRequired` | `use_dpop_nonce` | Server nonce required but missing | ## Symptom → cause → fix (top 20) The ones you'll actually hit while wiring things up. | Symptom | Cause | Fix | |---|---|---| | `401 invalid_token` on every request, no other detail | Wrong `aud` — token issued for a different resource URI than yours | Ensure the client sends `resource=` on `/authorize` and `/token`. The URI must match the resource registration byte-for-byte | | `401 invalid_dpop_proof` when client IS sending a DPoP header | Python `authplane-mcp`: forgot `install_request_context(mcp)`; any language: `htu` mismatch because a reverse proxy rewrote scheme/host | Call `install_request_context(mcp)` in Python; audit `X-Forwarded-Proto`/`Host` on the proxy | | `401 invalid_dpop_proof` on second request from same client | JTI replay — proof `jti` reused (bug in client) | Client must generate a fresh `jti` per request | | `401 use_dpop_nonce` on first DPoP request | Server requires nonce (`dpop.require_nonce: true`) | Client picks up `DPoP-Nonce` response header, includes it in next proof's `nonce` claim | | `403 insufficient_scope` on a specific tool | Token's `scope` claim doesn't contain what `require_scope` demands | Check granted scopes in the Admin UI Issuances panel; may need to expand `scopes` on the resource registration or the client's `scope` field | | `400 invalid_grant "authorization code has already been used"` | Client tried to exchange the same code twice, or the code expired (`AuthCodeTTL = 10 minutes`) | Restart the flow; codes are single-use | | `400 invalid_grant "PKCE verification failed"` | Client sent wrong `code_verifier` or generated the wrong `code_challenge` | Ensure `code_challenge = BASE64URL(SHA256(code_verifier))` | | `400 invalid_grant "refresh token has already been used"` OR `"token family revoked due to reuse detection"` | Refresh token was used twice — either concurrent refresh or theft | Restart auth from scratch. All tokens in the family are now revoked | | `400 unsupported_grant_type` on `client_credentials` | `client_credentials.enabled: false` (default) | Set env `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` and restart | | `400 unsupported_grant_type` on token-exchange or jwt-bearer | Same — disabled by default | Set `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true` or `xaa.enabled: true` | | `400 unauthorized_client` | Client's `grant_types` doesn't include the requested grant | Update the client via `authserver admin client update` | | `400 invalid_scope` | Requested scope not registered on the target resource | Add the scope to the resource, or drop it from the request | | `400 consent_required` + `consent_url` in the token-exchange response | User hasn't completed the Connect flow for a Broker resource | Redirect user to the `consent_url` (SDK does this automatically via MCP `-32042`) | | MCP client shows `-32042 URL elicitation required` | Same as above — token-exchange consent needed | Follow the URL in `error.data.url` | | PRM 404 at `/.well-known/oauth-protected-resource` | SDK didn't mount the PRM handler (Go only; Python/TS auto-mount) | `http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())` | | Discovery 404 at `/.well-known/oauth-authorization-server` | Wrong issuer URL — MCP client is hitting the resource server instead of the AS | Check that PRM's `authorization_servers` field points at AuthPlane, not your MCP server | | Boot fails with "session.secret is required" | `server.issuer` is not localhost and `session.secret` is unset | `openssl rand -hex 32` and set `AUTHPLANE_SESSION_SECRET` | | Boot fails with "admin.api_key is required" | Same — production issuer without an API key | Generate one and set `AUTHPLANE_ADMIN_API_KEY` | | Admin UI shows "Failed to fetch" everywhere | CORS not configured; browser blocks calls to `:9001` from a different origin | Set `AUTHPLANE_SERVER_ALLOWED_ORIGINS` with the origin of your admin UI host | | MCP Inspector says "no tools" | Inspector URL points at the host without `/mcp` | Use `npx @modelcontextprotocol/inspector http://localhost:8080/mcp` | ## Reading structured logs Every error path logs a structured slog event at `WARN` or `ERROR` with: - `error` — the sentinel name - `code` — the OAuth error code - `client_id`, `resource`, `grant_type` — request context when available - `trace_id`, `span_id`, `request_id` — for correlation Example (auth code replay): ``` 2026-07-01T00:14:20Z ERROR msg="token exchange failed" error=ErrCodeConsumed code=invalid_grant client_id=my-client resource=https://mcp.example.com/mcp request_id=r_abc123 trace_id=t_def456 ``` Grep or ship to a log aggregator; every error field is queryable. ## Related - [Reference: RFC compliance](/reference/rfc-compliance) — which RFC defines each error code - [Reference: Public API](/reference/public-api) — endpoint-level error responses in the OpenAPI spec - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — the `invalid_dpop_proof` scenarios in depth - [Troubleshooting: Debugging](/troubleshooting/debugging) — end-to-end debug checklist - [Troubleshooting: Common errors](/troubleshooting/common-errors) — richer walkthroughs for the top-20 above --- ## reference/metrics-and-cli.mdx --- title: Metrics & CLI description: "Complete catalog of AuthPlane's Prometheus/OTel metrics + every authserver CLI command with its flags." section: Reference sectionOrder: 7 order: 6 --- # Metrics & CLI > **TL;DR** — Two references in one page. **Metrics** — every counter/histogram/gauge AuthPlane emits, with the prefix (`authserver_*` for legacy, `authplane_*` for Phase-3 features), the labels, and a one-line description. **CLI** — every command the `authserver` binary ships (`serve`, `migrate`, `purge`, `admin *`), with their flags and typical use. Same commands run under systemd, Docker, and Kubernetes. --- # Metrics catalog Metrics are emitted via [OpenTelemetry](https://opentelemetry.io/) and can be scraped by Prometheus (`observability.metrics.provider: prometheus`, default `/metrics` path) or pushed to an OTLP endpoint (`provider: otel` or `both`). Full config in [Configuration: observability](/reference/configuration#observability), Prometheus + Grafana setup in [Guides: Monitoring](/guides/monitoring). ## Prefix note Metric names use **two prefixes** — a historical artifact: - **`authserver_*`** — original metrics (tokens, auth flow, OIDC, introspection, key management, upstream broker). - **`authplane_*`** — Phase-3 additions (client credentials, DPoP, token exchange, agent identity, XAA). Both are stable — dashboard once, don't change. When grepping `/metrics`, remember to search both prefixes. ## Counters (`_total`) ### OAuth core | Metric | What it counts | |---|---| | `authserver_tokens_issued_total` | Access tokens issued (any grant) | | `authserver_tokens_refreshed_total` | Refresh-token grants | | `authserver_tokens_revoked_total` | `/oauth/revoke` calls that revoked at least one token | | `authserver_auth_denied_total` | Auth requests denied (any reason) | | `authserver_clients_registered_total` | Client registrations via DCR + admin | | `authserver_consent_decisions_total` | User consent screen decisions (approve/deny) | | `authserver_login_attempts_total` | `/login` submissions (success + failure) | | `authserver_refresh_token_reuse_total` | Refresh-token reuse detections → family revocations | | `authserver_introspection_total` | `/oauth/introspect` calls | ### OIDC federation | Metric | What it counts | |---|---| | `authserver_oidc_jwks_cache_hits_total` | Upstream IdP JWKS cache hits | | `authserver_oidc_jwks_cache_misses_total` | Upstream IdP JWKS cache misses (triggers fetch) | ### Signing key management | Metric | What it counts | |---|---| | `authserver_key_rotation_total` | Signing-key rotations | ### Broker / upstream token vending | Metric | What it counts | |---|---| | `authserver_upstream_token_issued_total` | Upstream-format access tokens vended to MCP clients (Broker resources) | | `authserver_upstream_token_refresh_total` | Auto-refresh operations against persisted upstream credentials | | `authserver_connection_connect_total` | Upstream Connect flow completions | | `authserver_connection_disconnect_total` | Upstream Disconnect operations | ### Client Credentials | Metric | What it counts | |---|---| | `authplane_client_credentials_issued_total` | Machine-token issuances | | `authplane_client_credentials_denied_total` | Machine-token denials | ### DPoP (RFC 9449) | Metric | What it counts | |---|---| | `authplane_dpop_proofs_validated_total` | DPoP proofs that passed validation | | `authplane_dpop_proofs_rejected_total` | DPoP proofs rejected (see labels for reason) | ### Token exchange (RFC 8693) | Metric | What it counts | |---|---| | `authplane_token_exchange_total` | Token exchange operations (label: `kind=impersonation|delegation`) | | `authplane_token_exchange_denied_total` | Token exchange operations denied | ### Agent identity + XAA | Metric | What it counts | |---|---| | `authplane_agent_tokens_issued_total` | Tokens issued with `agent_id` / `agent_chain` claims | | `authplane_xaa_policy_evaluation_total` | XAA policy evaluations (label: `outcome=allow|deny`) | | `authplane_xaa_idp_operations_total` | XAA IdP-registry operations | | `authplane_xaa_subject_resolutions_total` | XAA subject-mapping resolutions | ### Admin API | Metric | What it counts | |---|---| | `authplane_resource_server_ops_total` | Admin resource-server CRUD | | `authplane_allowlist_ops_total` | Cross-client allowlist admin ops (legacy path) | ### HTTP | Metric | What it counts | |---|---| | `authserver_http_requests_total` | Requests per (method, path, status) | ## Histograms (`_duration_seconds`) Recorded in seconds. Buckets: `0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`. | Metric | What it measures | |---|---| | `authserver_token_issuance_duration_seconds` | End-to-end token issuance | | `authserver_auth_flow_duration_seconds` | Authorize flow duration | | `authserver_cimd_fetch_duration_seconds` | CIMD document fetch | | `authserver_db_operation_duration_seconds` | Storage backend operations | | `authserver_oidc_exchange_duration_seconds` | OIDC code exchange + ID token verification | | `authserver_introspection_duration_seconds` | Introspection call latency | | `authserver_key_reload_duration_seconds` | JWKS cache reload | | `authserver_upstream_token_issuance_duration_seconds` | Upstream-format token issuance | | `authserver_http_request_duration_seconds` | HTTP request latency (label: method, path, status) | ## Gauges | Metric | Meaning | |---|---| | `authserver_active_clients` | Currently registered clients | | `authserver_active_token_families` | Live refresh-token families | ## Recommended alerts Start with these; tune thresholds to your traffic. - `rate(authserver_auth_denied_total[5m]) > 10` — spike in auth denials (possible attack or misconfig) - `rate(authserver_refresh_token_reuse_total[5m]) > 0` — refresh-token theft detected (never expected in normal ops) - `rate(authplane_dpop_proofs_rejected_total[5m]) > 5` — DPoP failures spiking (SDK misconfig or client bug) - `histogram_quantile(0.99, rate(authserver_token_issuance_duration_seconds_bucket[5m])) > 0.5` — p99 token issuance > 500 ms (DB or Vault latency) - `authserver_key_rotation_total unchanged for 90d` — rotation cadence missed - `authserver_upstream_token_refresh_total{outcome="failed"} > 0` — upstream provider auth broken Full alert examples in [Guides: Monitoring](/guides/monitoring#six-alerts-worth-having). --- # CLI reference Every AuthPlane operational task can run from the CLI. Same binary as the server (`authplane/authserver:latest` or the standalone Linux binary). Config is passed as `--config ` or via `AUTHPLANE_*` env vars. ## `authserver serve` Start the server. Runs both public (`:9000`) and admin (`:9001`) HTTP servers. ```bash authserver serve --config /etc/authserver/config.yaml ``` **Flags:** - `--config ` — YAML config file. Optional; without it, defaults + env vars are used. **Signal handling:** - `SIGTERM` / `SIGINT` — graceful shutdown (drains for `server.shutdown_wait`, default 10 s). - `SIGHUP` — reload signing keys hot (`current`/`previous` JWKS refresh). No connection drop. See [Operate: Standalone](/operate/standalone) for the systemd unit and [Operate: Docker Compose](/operate/docker-compose) for the container form. ## `authserver migrate` Apply pending database migrations. Idempotent — safe to re-run. ```bash authserver migrate --config /etc/authserver/config.yaml ``` Migrations are embedded in the binary via `go:embed` and applied by `serve` on boot too — `migrate` is the standalone form for CI/CD pipelines that want to run migrations separately from the server process. Forward-only; there is no rollback. The Helm chart's init container waits for Postgres connectivity but does NOT run this — the main process handles migrations on pod boot. ## `authserver purge` Delete expired rows from the DB. Not run by `serve`; schedule externally. ```bash authserver purge --config /etc/authserver/config.yaml authserver purge --only=dpop-nonces,jti --timeout=5m ``` **Flags:** - `--config ` — YAML config (needed to reach the DB). - `--only ` — comma-separated list from: `assertion-jti`, `connect-pending-states`, `dpop-nonces`, `jti`, `machine-tokens`, `refresh-tokens`, `sessions`. Default: all targets. - `--timeout ` — abort after this duration. Default `10m`. Pass `0` to disable. **Exit codes** — non-zero if any target fails or context canceled. Individual failures log at `ERROR` with `table=` attribute; the command continues with remaining targets and fails at the end. Wire into your alerting. Scheduling recipes for systemd, Docker Compose, and Kubernetes CronJob in [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge#scheduled-purge). ## `authserver version` Print the binary version and exit. ```bash authserver version # authserver v0.1.x commit=abcdef1 built=2026-01-01T00:00:00Z ``` ## `authserver admin user create` Bootstrap the first admin user (before the Admin UI is reachable). ```bash authserver admin user create \ --config /etc/authserver/config.yaml \ --email admin@example.com \ --password changeme \ --name Admin \ --role admin ``` **Flags:** - `--email` — user's email (required) - `--password` — initial password (required; change after first login) - `--name` — display name (required) - `--role` — `admin` or `user` (default `user`) After the first admin exists, manage users via the Admin UI or `authserver admin user *`. --- ## Admin CLI — `authserver admin *` Every entity in the Admin API has a matching CLI verb. Common pattern: ```bash authserver admin [--flags] ``` Auth uses the same `AUTHPLANE_ADMIN_API_KEY` as the REST API. Config path (for DB access) via `--config` or env. ### `admin client` OAuth client management. ```bash authserver admin client list # list all authserver admin client create --grant-types authorization_code,refresh_token \ --redirect-uris https://app.example.com/cb \ --scopes 'tools/read||Read tools' \ --scopes 'tools/write||Write tools' \ --auth-method client_secret_post \ --name "My App" ``` The CLI subcommands are `list`, `create`, `update`, `rotate-secret`, and `delete`. For `get`, `suspend`, and `reactivate`, use the admin REST API — for example: ```bash # Inspect a client curl -s http://localhost:9001/admin/clients/ \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Suspend a client (block new tokens) curl -s -X PATCH http://localhost:9001/admin/clients//suspend \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Reactivate a suspended client curl -s -X PATCH http://localhost:9001/admin/clients//reactivate \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" ``` Output format: `--json` for JSON, default is `key=value` per line. ### `admin user` Local user management. ```bash authserver admin user list authserver admin user create --email … --password … --role admin authserver admin user update --id --email new@example.com authserver admin user force-logout --id authserver admin user delete --id ``` The CLI subcommands are `list`, `create`, `update`, `delete`, and `force-logout`. For `get`, `disable`, `enable`, and password resets, use the admin REST API — for example: ```bash # Look up a user curl -s "http://localhost:9001/admin/users?email=" \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Disable a user (block logins) curl -s -X PATCH http://localhost:9001/admin/users//disable \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Enable a user curl -s -X PATCH http://localhost:9001/admin/users//enable \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Reset a user's password (field on the generic update endpoint) curl -s -X PATCH http://localhost:9001/admin/users/ \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"password":""}' ``` ### `admin resource` Mint / Broker resources. ```bash authserver admin resource list authserver admin resource get --slug authserver admin resource create --slug my-mcp \ --uri http://localhost:3000/mcp \ --backend-kind mint \ --scope-map "tools/read:tools/read,tools/write:tools/write" authserver admin resource update --slug my-mcp --scopes 'tools/read||Read tools' --scopes 'tools/write||Write tools' --scopes 'tools/delete||Delete tools' authserver admin resource delete --slug my-mcp ``` Broker resources include `--broker-provider ` (identifies the upstream provider). Per-resource policy (`policy.exchange.allowed_client_ids`, `policy.runtime.client_ids`, `policy.connect.allowed_return_urls`) has dedicated subcommands under `admin resource policy`. ### `admin provider` Broker providers (upstream OAuth / API key / service account). ```bash authserver admin provider list authserver admin provider get --id authserver admin provider create --slug github --protocol oauth \ --display-name "GitHub" \ --config-data ./github-provider.json authserver admin provider update --id --config-data ./github-provider.json authserver admin provider delete --id ``` ### `admin grant` Consent grants + broker grants (per-user). ```bash authserver admin grant list-user-grants --user authserver admin grant revoke-consent --id # cascades onto live Mint issuances authserver admin grant revoke-broker --id # no issuance cascade — upstream tokens are not AS-revocable ``` ### `admin issuance` Per-token forensic audit rows. ```bash authserver admin issuance list --user # last N issuances for a user authserver admin issuance list --client --limit 50 authserver admin issuance list --resource --since 7d # Go-duration form (e.g. 24h, 7d; max 30d) authserver admin issuance get --id ``` ### `admin key` Signing key management. ```bash authserver admin key list # current + previous kids authserver admin key rotate # zero-downtime rotation ``` Same as `POST /admin/keys/rotate` via the REST API. On multi-instance deployments with `postgres_key` or `vault_transit`, propagation is automatic via `LISTEN/NOTIFY` (ms). Single-instance `keyfile` may want SIGHUP after rotation. ### `admin dcr` Runtime DCR mode toggle. Persisted; survives restart. ```bash authserver admin dcr get # show current mode authserver admin dcr set --mode admin_only # switch modes at runtime ``` Modes: `open`, `approved_redirects`, `admin_only`. See [Configuration: dcr](/reference/configuration#dcr--dynamic-client-registration-rfc-7591). ### XAA (Enterprise-Managed Auth) — no CLI subcommand; use the admin REST API Trusted IdPs, XAA policies, and subject mappings have no `authserver admin xaa …` CLI equivalent. Manage them through the admin REST API: ```bash # Trusted IdPs curl -s http://localhost:9001/admin/idps \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" curl -s -X POST http://localhost:9001/admin/idps \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"issuer":"https://idp.example.com","audience":"https://auth.example.com","jwks_uri":"https://idp.example.com/.well-known/jwks.json"}' curl -s -X POST http://localhost:9001/admin/idps//refresh-keys \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" curl -s -X DELETE http://localhost:9001/admin/idps/ \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Policies curl -s http://localhost:9001/admin/xaa/policies \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" curl -s -X POST http://localhost:9001/admin/xaa/policies \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"idp_id":"","client_ids":[""],"scopes":["tools/read"],"resources":["https://mcp.example.com/mcp"]}' curl -s -X DELETE http://localhost:9001/admin/xaa/policies/ \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Subject mappings curl -s http://localhost:9001/admin/xaa/subject-mappings \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" curl -s -X POST http://localhost:9001/admin/xaa/subject-mappings \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"idp_id":"","mode":"explicit","federated_subject":"foo","local_subject":"bar"}' ``` Only available when `xaa.enabled: true`. ### `admin fronting` (advanced) Fronting-link management for gateway topologies. See [Topologies: MCP gateway → hidden Mint](/topologies/mcp-gateway-mint). ```bash authserver admin fronting list authserver admin fronting create --source --target \ --scope-map "src/read:dst/read" authserver admin fronting delete --source --target ``` ## Global CLI flags Every subcommand accepts: - `--config ` — YAML config path (or set `AUTHPLANE_*` env vars) - `--json` — machine-readable output (default: `key=value` lines) - `--help` — subcommand help ## Related - [Reference: Admin API](/reference/admin-api) — the REST equivalent of every `admin *` command - [Reference: Configuration](/reference/configuration) — env vars + YAML keys that these commands operate on - [Guides: Monitoring](/guides/monitoring) — Prometheus scrape config, Grafana dashboards, alerting rules - [Guides: Admin API](/guides/admin-api) — task-focused walkthroughs - [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge) — `purge` scheduling recipes --- ## reference/public-api.mdx --- title: Public API description: "OpenAPI reference for AuthPlane's public OAuth 2.1 endpoints — /oauth/*, /.well-known/*, /oidc/*, /connect/*, /health." section: Reference sectionOrder: 7 order: 2 --- # Public API > **TL;DR** — Everything AuthPlane exposes on `:9000` — the port your MCP clients hit. OAuth 2.1 endpoints (`/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/introspect`, `/oauth/revoke`), discovery (`/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration`, `/.well-known/jwks.json`), federation callback (`/oidc/callback`, `/oidc/start`), Broker connect flow (`/connect/{provider}`), and health checks. Live spec below with try-it-out. ## Endpoints at a glance | Path | Purpose | |---|---| | `GET /.well-known/oauth-authorization-server` | RFC 8414 AS metadata | | `GET /.well-known/openid-configuration` | Alias to AS metadata (for OIDC-discovery clients) | | `GET /.well-known/jwks.json` | Public signing keys (RFC 7517) | | `GET /oauth/authorize` | User-facing authorization endpoint (RFC 6749 §4.1 + PKCE) | | `POST /oauth/token` | Token endpoint — all grants: `authorization_code`, `refresh_token`, `client_credentials`, `token-exchange`, `jwt-bearer` | | `POST /oauth/register` | Dynamic Client Registration (RFC 7591) | | `POST /oauth/introspect` | Token introspection (RFC 7662) | | `POST /oauth/revoke` | Token revocation (RFC 7009) | | `GET /oidc/start` | Kick off upstream OIDC login (federation) | | `GET /oidc/callback` | Upstream OIDC return | | `GET /connect/{provider}` | Start Broker Connect flow for an upstream provider | | `GET /connect/{provider}/callback` | Broker Connect return | | `GET /health`, `GET /ready` | Health probes | Discovery and health are unauthenticated. Everything else follows OAuth semantics — see [Concepts: Grants & flows](/concepts/grants-and-flows) for what each grant expects at `/oauth/token`. ## Authentication models Five models across the public endpoints: - **Unauthenticated** — `/.well-known/*`, `/health`, `/ready`, `/oauth/register` (with `dcr.mode: open`), `/login` and `/oidc/*` (these *establish* a session) - **Session cookie** — `/oauth/authorize`, `/consent`, `/connect/*` (user-facing endpoints that require a logged-in user) - **Bearer JWT / DPoP** — resource-server calls that AuthPlane itself doesn't serve, but that AS-issued tokens carry - **API key** — none on the public API (admin only) - **Client credentials** — `/oauth/token` for confidential clients (`client_secret_basic` or `client_secret_post`) Full mapping per endpoint is in the spec below (`security` field on each operation). ## Live spec (Rapidoc) Search endpoints, expand schemas, try requests inline. The spec below is the exact YAML that ships with the AuthPlane binary — the endpoints your MCP clients hit at runtime. ## Related - [Reference: Admin API](/reference/admin-api) — the operator surface on `:9001` - [Reference: Configuration](/reference/configuration) — every knob that affects what the public API accepts/emits - [Reference: RFC compliance](/reference/rfc-compliance) — which RFCs each endpoint implements + intentional deviations - [Reference: Errors](/reference/errors) — OAuth error codes and `WWW-Authenticate` patterns - [Concepts: Architecture](/concepts/architecture#request-flow) — end-to-end auth-code flow through the codebase --- ## reference/rfc-compliance.mdx --- title: RFC compliance description: "Every RFC AuthPlane implements — coverage, intentional deviations, and where each one shows up on the wire." section: Reference sectionOrder: 7 order: 4 --- # RFC compliance > **TL;DR** — AuthPlane implements the subset of each RFC needed for MCP authorization: not less, not more. Every deviation is opt-in, documented, and tied to an ADR. Legacy grants (implicit, ROPC) are intentionally omitted per OAuth 2.1 security guidance. This page is the compliance statement — one row per RFC with what's covered, what's not, and why. ## Design philosophy Every RFC below is implemented **to the letter** unless the "Deviation" column says otherwise. Where an RFC offers multiple approaches (token formats, client auth methods), AuthPlane picks the secure-by-default option. The full test matrix — one test per RFC section — is in [`authserver/wiki/COMPLIANCE_TEST_MATRIX.md`](https://github.com/authplane/authserver/blob/main/wiki/COMPLIANCE_TEST_MATRIX.md). ## Core OAuth ### RFC 6749 — OAuth 2.0 Authorization Framework **Implemented sections:** §4.1 (Authorization Code Grant), §4.4 (Client Credentials), §3.3 (Scope), §5.2 (Error responses). **Coverage:** - Authorization Code grant with mandatory PKCE (RFC 7636) - Client Credentials grant (RFC 6749 §4.4) — see below - Scope validation against registered scopes - Client authentication: `none`, `client_secret_basic`, `client_secret_post` - Error responses per §5.2 **Configurable default-scope handling** (ADR-012): when `oauth.require_scope: false`, missing scope in authorize requests defaults to the resource's registered scopes. RFC 6749 §3.3 explicitly allows this: *"If the client omits the scope parameter … the authorization server MUST either process the request using a pre-defined default value or fail the request indicating an invalid scope."* We ship the *fail* branch by default (spec-strict); the toggle switches on the *pre-defined default* branch that the RFC also permits. **Not implemented:** Implicit grant (§4.2), Resource Owner Password Credentials (§4.3) — OAuth 2.1 removes both from the spec. ### RFC 6749 §4.4 — Client Credentials Machine-to-machine token issuance. Requires `client_secret_post` or `client_secret_basic`. Supports scope validation and resource audience binding. **Configuration:** `client_credentials.enabled: true` (disabled by default). Only confidential clients with `client_credentials` in their `grant_types`. **Token format:** RFC 9068 JWT with `typ: at+jwt`, `sub` = `client_id` (no user), `aud` bound to `resource` when set. Introspection + revocation supported. ### RFC 7636 — PKCE **S256 only.** `plain` method rejected. Missing `code_challenge` rejected. ### RFC 9700 — OAuth 2.0 Security BCP **Coverage:** PKCE required, exact redirect-URI matching, refresh-token rotation with reuse detection, no implicit grant, DPoP available. ## Token format ### RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens Access tokens are JWTs with `typ: at+jwt`, standard claims (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `client_id`, `scope`). ### RFC 7517 — JSON Web Key (JWK) JWKS endpoint at `/.well-known/jwks.json`. Supports **ES256** (EC P-256) and **RS256** key types. ## Discovery ### RFC 8414 — OAuth 2.0 Authorization Server Metadata Full AS metadata at `/.well-known/oauth-authorization-server`. Fields returned: - `issuer`, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, `revocation_endpoint` - `introspection_endpoint` (when `introspection` enabled) - `jwks_uri` - `response_types_supported`, `grant_types_supported`, `token_endpoint_auth_methods_supported` - `code_challenge_methods_supported: ["S256"]` - `scopes_supported` (aggregated from all resources' scopes) - `resource_indicators_supported: true` - `dpop_signing_alg_values_supported` (when DPoP enabled) - `authplane_agent_identity_supported: true` (AuthPlane extension) ### RFC 9728 — OAuth 2.0 Protected Resource Metadata Served by the resource server (i.e., your MCP server via the SDK) at `/.well-known/oauth-protected-resource`. MCP clients use this to discover the authorization server. AuthPlane's SDKs generate and serve this automatically. ## Client registration ### RFC 7591 — Dynamic Client Registration Three modes: - `open` — anyone can register (dev only) - `approved_redirects` — anyone can register, redirect URI must match `dcr.approved_redirects` patterns - `admin_only` — pre-registration required via Admin API/CLI Fields supported: `redirect_uris`, `client_name`, `token_endpoint_auth_method`, `grant_types`, `scope`, and AuthPlane extensions for agent identity. ### `draft-ietf-oauth-client-id-metadata-document` — CIMD When `client_id` is a URL, AuthPlane fetches the metadata document at that URL, validates fields, and uses it for registration. Configurable via `cimd.require_https` (default `true`), `cimd.cache_ttl`, `cimd.fetch_timeout`. ## Resource indicators ### RFC 8707 — Resource Indicators for OAuth 2.0 The `resource` parameter in `/oauth/authorize` and `/oauth/token` binds tokens to a specific resource server via the `aud` claim. **Strict exact-string matching** — trailing slashes matter. See [Configuration: Resources](/reference/configuration#resources) for the URI-mismatch trap. ## Token lifecycle ### RFC 7009 — Token Revocation Revocation endpoint at `/oauth/revoke`. Accepts both access tokens and refresh tokens. Returns `200` on success and for invalid/unknown tokens per §2.2; error responses (`unsupported_token_type`, client-auth failure, `503`) follow §2.2.1. ### RFC 7662 — Token Introspection Introspection endpoint at `/oauth/introspect`. Accepts access tokens and machine tokens. Requires client authentication (`client_secret_post` or `client_secret_basic`) for confidential clients. ### Refresh token rotation Refresh tokens rotate on every use — a new token is issued, the old one consumed. **Reuse detection** — a second use of a consumed refresh token revokes the entire token family (RFC 9700 §4.14). ## Error format ### RFC 9457 — Problem Details for HTTP APIs Error responses include both OAuth error fields (`error`, `error_description`) and Problem Details fields (`type`, `title`, `detail`, `status`). Content-Type: `application/problem+json`. Full error catalog in [Reference: Errors](/reference/errors). ## Proof of possession ### RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP) **Full support** including proof validation, token binding, and server nonces. **Coverage:** - DPoP proof JWT validation (§4.3): `typ`, `alg`, `jwk`, `htm`, `htu`, `iat`, `jti`, `nonce` - Supported algorithms: `ES256`, `RS256`, `PS256` - Algorithm restriction: `alg:none` and all symmetric algorithms rejected - Private key in `jwk` header rejected - `htu` comparison strips query string (scheme + authority + path; authority includes non-default port; compared after RFC 3986 normalization) - JKT computation per [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) (JWK Thumbprint) — base64url-encoded SHA-256 - Token binding via `cnf.jkt` claim in access tokens - DPoP-bound token type: `token_type: DPoP` - Server-issued nonces (`DPoP-Nonce` response header) with configurable TTL - JTI replay prevention with database-backed store + background purge - `ath` (access token hash) validation on resource requests - Backward compatible: no DPoP proof → standard Bearer token - Introspection returns `cnf.jkt` for DPoP-bound tokens - AS metadata advertises `dpop_signing_alg_values_supported` **Configuration:** `dpop.enabled: true` (disabled by default). ## Token exchange ### RFC 8693 — OAuth 2.0 Token Exchange **Full support** for impersonation and delegation flows. **Coverage:** - `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` - Subject token validation: signature, issuer, expiry, revocation - Subject token types: `access_token`, `jwt` URNs - Impersonation: no actor token, no `act` claim, `sub` preserved - Delegation: actor token present, nested `act` claim per §4.1 - Multi-hop delegation: correct chain nesting - Scope narrowing: requested scope must be subset of subject-token scope - Configurable chain depth limit (1-10, default 5) - Per-resource policy enforcement (`policy.exchange.allowed_client_ids`) - Self-exchange guarded by `token_exchange.allow_self_exchange` - DPoP binding propagation on exchanged tokens - AS metadata: `grant_types_supported` includes the URN **Configuration:** `token_exchange.enabled: true` (disabled by default). ## AuthPlane extensions ### JWT Bearer + XAA (RFC 7523 + ID-JAG assertion profile) Enterprise-Managed Authorization via JWT Bearer grant with the ID-JAG assertion format — an emerging IETF/OIDF draft referenced by the MCP Authorization spec (2025-11-25). **Coverage:** - `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` - ID-JAG assertion validation: signature, issuer, audience, expiry, type header (`oauth-id-jag+jwt`) - Trusted IdP registry with JWKS discovery + caching (SSRF-protected) - Policy engine: IdP + client_id + scope + resource constraint evaluation - Subject mapping: `auto_map` (federated subject) and `strict` (explicit mapping required) modes - Replay prevention: assertion JTI single-use enforcement + auto-purge - Scope intersection: policy scopes narrow the issued token's scope - Resource binding: `resource` parameter flows to token `aud` claim - DPoP binding: XAA tokens support `cnf.jkt` - Machine token storage for revocation/introspection - AS metadata: `grant_types_supported` includes `jwt-bearer` URN when enabled **Configuration:** `xaa.enabled: true` (disabled by default). YAML-only at v0.1.x — no env-var overrides. **No deviations from RFC 7523 §2.1** (JWT assertion profile). ### Agent identity claims AuthPlane-specific JWT extensions: - `agent_id` — set to `client_id` when issuing client has `is_agent=true` - `agent_chain` — ordered list built from delegation `act` chain, capped at 8 - Agent registration via DCR (`agent: true`, `agent_description` max 255 chars) - Optional JWKS agent listing (`agents.enable_jwks_listing: true`) - AS metadata advertises `authplane_agent_identity_supported: true` Not standardized. See [Concepts: Agent identity](/concepts/agent-identity). ## MCP-specific ### MCP Authorization Specification (2025-11-25) **Implemented:** Full discovery flow (PRM → AS Metadata → DCR → Authorize → Token), CIMD support, resource indicators, DPoP-bound tokens. **Tested against:** Claude Code, Claude Desktop, MCP Inspector. Cursor and VS Code interop is documented for guidance but not yet in the regression set — status *Pending* in the [compatibility matrix](https://github.com/authplane/authserver/blob/main/docs/compatibility.md). OAuth 2.1 itself remains an active IETF draft (`draft-ietf-oauth-v2-1`) rather than a published RFC — AuthPlane tracks the latest revision. ## Summary matrix | RFC | Coverage | Deviation | Configuration | |---|---|---|---| | RFC 6749 | §4.1, §4.4, §3.3, §5.2 | ADR-012 (opt-in) | Always on | | RFC 7636 (PKCE) | S256 only | `plain` rejected (per BCP) | Always on | | RFC 7009 (Revocation) | Full | None | Always on | | RFC 7517 (JWK) | ES256, RS256 | None | Always on | | RFC 7523 (JWT Bearer) | Full assertion profile | None | `xaa.enabled` | | RFC 7591 (DCR) | Full | None | `dcr.mode` | | RFC 7662 (Introspection) | Full | None | Always on | | RFC 8414 (AS Metadata) | Full | None | Always on | | RFC 8693 (Token Exchange) | Full | None | `token_exchange.enabled` | | RFC 8707 (Resource Indicators) | Full (exact match) | None | Always on | | RFC 9068 (JWT AT) | Full | None | Always on | | RFC 9449 (DPoP) | Full | None | `dpop.enabled` | | RFC 9457 (Problem Details) | Full | None | Always on | | RFC 9700 (OAuth BCP) | Full | None | Always on | | RFC 9728 (PRM) | Full (via SDK) | None | Always on | | CIMD draft | Full | None | `cimd.enabled` | ## Related - [Reference: Configuration](/reference/configuration) — every `*.enabled` flag from the table above - [Reference: Errors](/reference/errors) — RFC 9457 problem+json shape + `WWW-Authenticate` catalog - [Reference: Public API](/reference/public-api) — the endpoints each RFC lives on - [Concepts: Grants & flows](/concepts/grants-and-flows) — RFC-by-RFC mapping to actual flows - **Test matrix** — [`authserver/wiki/COMPLIANCE_TEST_MATRIX.md`](https://github.com/authplane/authserver/blob/main/wiki/COMPLIANCE_TEST_MATRIX.md) — one test row per RFC section - **Design decisions** — [`authserver/wiki/DECISIONS.md`](https://github.com/authplane/authserver/blob/main/wiki/DECISIONS.md) — ADRs referenced above --- ## sdks/go.mdx --- title: Go description: "Four Go modules — authplanemcp (official MCP SDK), mark3labs, http (any net/http server), and core primitives." section: SDKs sectionOrder: 3 order: 4 --- # Go > **TL;DR** — Four modules. `authplanemcp` targets the **official MCP Go SDK**. `mark3labs` targets the **mark3labs/mcp-go** community library. `http` is a generic `net/http` adapter for any Go HTTP server. `core` is the framework-agnostic primitive package all others build on. Every adapter shares the same `Options{Issuer, Resource, Scopes, DevMode, ClientOptions, VerifierOptions}` shape and delivers Bearer + DPoP verification, RFC 9728 PRM, RFC 6750 `WWW-Authenticate` challenges, and RFC 8693 token exchange. ## Install ```bash # Official MCP Go SDK go get github.com/authplane/go-sdk/mcp # mark3labs/mcp-go community SDK go get github.com/authplane/go-sdk/mark3labs # Generic net/http (any Go server) go get github.com/authplane/go-sdk/http # Core primitives only go get github.com/authplane/go-sdk/core ``` Compatibility: | Package | Min Go | Notes | |---|---|---| | `go-sdk/mcp` | 1.25+ | Pulls in `modelcontextprotocol/go-sdk`. | | `go-sdk/mark3labs` | 1.25.5+ | Minimum required by `mark3labs/mcp-go v0.54.0`. Adapter embeds `authplanehttp.Adapter`. | | `go-sdk/http` | 1.24+ | Standalone `net/http` middleware. | | `go-sdk/core` | 1.24+ | Shared primitive package. | All adapters delegate to `go-sdk/core` for the JWKS cache, metadata discovery, JWT validation, DPoP proof verification, token-exchange client, and introspection. --- ## `authplanemcp` — Official MCP Go SDK adapter ### Quickstart ```go package main import ( "context" "net/http" "github.com/authplane/go-sdk/core/resource/verifier" "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { ctx := context.Background() adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{ Issuer: "http://localhost:9000", Resource: "http://localhost:8080/mcp", Scopes: []string{"tools/read"}, DevMode: true, VerifierOptions: []verifier.Option{ verifier.WithInboundDPoP(verifier.InboundDPoPOptions{Required: true}), }, }) if err != nil { panic(err) } defer adapter.Close() server := mcp.NewServer( &mcp.Implementation{Name: "my-server", Version: "1.0.0"}, nil, ) handler := mcp.NewStreamableHTTPHandler( func(_ *http.Request) *mcp.Server { return server }, nil, ) http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) http.Handle("/mcp", adapter.AuthMiddleware(handler)) if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } } ``` ### `Options` reference | Field | Type | Purpose | |---|---|---| | `Issuer` | `string` (required) | AS URL for RFC 8414 discovery. | | `Resource` | `string` (required) | JWT `aud` and PRM `resource`. | | `Scopes` | `[]string` | Advertised in PRM. | | `DevMode` | `bool` | Prepends `authplane.WithFetchSettings(authplane.DevModeFetchSettings())` to `ClientOptions`. Also honors `AUTHPLANE_DEV_MODE=1`. | | `ClientOptions` | `[]authplane.Option` | Client-level: `WithClientCredentials`, `WithClientAuthentication`, `WithJWKSCacheTTL`, `WithCircuitBreaker`, `WithDPoP`. Introspection auto-wires as revocation checker when credentials are present. | | `VerifierOptions` | `[]verifier.Option` | Verifier-level: `WithAlgorithms`, `WithClockSkew`, `WithInboundDPoP`, `WithRevocationChecker` (overrides auto-wired introspection). | `WithVerifierOptions` **replaces** (not appends to) the auto-wired option list. If you set `VerifierOptions` while `ClientOptions` also wires introspection, you must include the introspection checker yourself or accept that revocation is off. ### Main API | Symbol | Purpose | |---|---| | `NewAdapter(ctx, Options) (*Adapter, error)` | Constructor. Performs metadata discovery + warms JWKS. | | `adapter.AuthMiddleware(next http.Handler) http.Handler` | Verifies token, enforces scopes, injects claims into request context. Writes RFC 6750 401 on failure with `WWW-Authenticate` that advertises the PRM URL via `resource_metadata=` (RFC 9728 §5.1). | | `adapter.WellKnownPRMPath() string` | Path for the PRM handler (`/.well-known/oauth-protected-resource/mcp`). | | `adapter.ProtectedResourceMetadataHandler() http.Handler` | Serves the PRM JSON. | | `adapter.Close() error` | Releases JWKS + metadata refresh goroutines. `defer adapter.Close()` after `NewAdapter`. | | `authplanemcp.ClaimsFromContext(ctx) *verifier.VerifiedClaims` | Read claims injected by `AuthMiddleware`. Returns `nil` when absent. | | `authplanemcp.TokenFromContext(ctx) string` | Read the raw bearer token (useful for `adapter.TokenExchange`). Returns `""` when absent. | ### Per-tool scope enforcement The official MCP Go SDK doesn't ship a per-tool scope guard — check inside your handler: ```go func writeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { claims := authplanemcp.ClaimsFromContext(ctx) if claims == nil { return nil, fmt.Errorf("no claims in context") } if !claims.HasScope("tools/write") { return nil, fmt.Errorf("insufficient scope: tools/write required") } // ... your logic return &mcp.CallToolResult{}, nil } ``` ### Token exchange Call `TokenExchange` on the adapter — this is the path that auto-maps `ConsentRequiredError` to the MCP JSON-RPC `-32042` elicitation error. Calling `TokenExchange` on the raw client returned by `adapter.Client()` does not auto-map. ```go resp, err := adapter.TokenExchange(ctx, authplane.TokenExchangeInput{ SubjectToken: inboundToken, Resources: []string{"https://api.example.com/downstream"}, Scopes: []string{"tools/write"}, }) // resp.AccessToken, resp.TokenType ("Bearer" or "DPoP"), resp.ExpiresIn ``` If the exchange fails with `ConsentRequiredError` (contains a `ConsentURL` field), the adapter maps it to the MCP JSON-RPC `-32042` elicitation error for the client. Your handler doesn't need `try/except`-style handling. ### Sharing a pre-built client For multi-resource deployments where one client backs several adapters, build `authplane.NewClient` yourself and pass it via `ClientOptions` — the adapter will not own its lifecycle in that case. See the full [user-guide §10](https://github.com/AuthPlane/go-sdk/blob/main/mcp/docs/user-guide.md). --- ## `authplanemark3labs` — mark3labs/mcp-go adapter ### Quickstart ```go package main import ( "context" "net/http" "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" "github.com/mark3labs/mcp-go/server" ) func main() { ctx := context.Background() adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ Issuer: "https://auth.example.com", Resource: "https://mcp.example.com/mcp", Scopes: []string{"tools/query", "tools/write"}, }) if err != nil { panic(err) } defer adapter.Close() mcpServer := server.NewMCPServer("my-server", "1.0.0", server.WithToolCapabilities(false), server.WithRecovery(), ) streamable := server.NewStreamableHTTPServer(mcpServer, server.WithHTTPContextFunc(adapter.HTTPContextFunc()), ) http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) http.Handle("/mcp", adapter.AuthMiddleware(streamable)) http.ListenAndServe(":8080", nil) } ``` ### Two coordinated hooks `mark3labs/mcp-go` has an HTTP request context and a per-tool-call MCP context — they don't share by default. The adapter bridges them via two required hooks: | Hook | Purpose | |---|---| | `adapter.AuthMiddleware(next)` | Standard `http.Handler` middleware. Parses `Authorization: Bearer …` or `DPoP …`, verifies, injects claims + raw token into the **HTTP request** context. On failure writes RFC 6750 401 with the PRM URL in `WWW-Authenticate`. Auto-excludes the PRM well-known path. | | `server.WithHTTPContextFunc(adapter.HTTPContextFunc())` | Forwards claims + token from the HTTP request context into the **per-tool-call** MCP context. Without it, tool handlers receive a fresh context with no claims. | ### Per-tool scope enforcement Scope enforcement is **per-tool**, not per-request, matching the MCP protocol requirement that `initialize` succeed with any authenticated client: ```go import "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" func writeHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { claims := authplanemark3labs.ClaimsFromContext(ctx) if claims == nil { return mcp.NewToolResultError("no claims"), nil } if !claims.HasScope("tools/write") { return mcp.NewToolResultError("insufficient scope: tools/write required"), nil } // ... your logic return mcp.NewToolResultText("done"), nil } ``` ### Under the hood `*Adapter` embeds `*authplanehttp.Adapter` — the same middleware, scope guard, DPoP/bearer parsing, and `WWW-Authenticate` shape as the [HTTP adapter](#authplanehttp--net-http-adapter) below. mark3labs-only additions are the context bridge and URL-elicitation mapping. --- ## `authplanehttp` — `net/http` adapter For any Go HTTP server — not tied to an MCP framework. Use when your resource server is a regular `net/http` service and you want AuthPlane token validation without pulling in an MCP SDK. ### Quickstart ```go package main import ( "context" "net/http" "github.com/authplane/go-sdk/core/authplane" "github.com/authplane/go-sdk/core/resource" "github.com/authplane/go-sdk/http/pkg/authplanehttp" ) func main() { ctx := context.Background() client, err := authplane.NewClient(ctx, "https://auth.example.com", authplane.WithClientCredentials("my-client", "s3cret"), ) if err != nil { panic(err) } defer client.Close() res, err := client.Resource("https://api.example.com", resource.WithScopes("read", "write"), ) if err != nil { panic(err) } adapter := authplanehttp.New(res) mux := http.NewServeMux() mux.Handle(adapter.WellKnownPRMPath(), adapter.PRMHandler()) mux.Handle("/api/admin", adapter.RequireScopes("admin")(http.HandlerFunc(adminHandler))) mux.Handle("/api/", http.HandlerFunc(readHandler)) http.ListenAndServe(":8080", adapter.Middleware()(mux)) } ``` ### Three things happen per request 1. `Middleware()` extracts the `Authorization` header, verifies the token (Bearer **or** DPoP), and injects `*verifier.VerifiedClaims` + raw token into the request context. 2. `RequireScopes(...)` middleware (optional, per-route) checks for required scopes. 3. The well-known PRM path is auto-excluded from token checks so unauthenticated clients can discover the AS. On failure, the adapter writes an RFC 6750 error response: correct HTTP status, `WWW-Authenticate` with the right scheme (`Bearer` or `DPoP`), error code, and JSON body. ### Adapter does not own client lifecycle Unlike the MCP adapters, `authplanehttp` is a **thin wrapper around `*resource.Resource`** — it holds no cleanup state. The caller owns the `*authplane.Client` and calls `client.Close()`. One client typically backs many resources and adapters in a single process. --- ## `authplane` — Core primitives Framework-agnostic package. Use it when: - You want direct control over the JWKS cache, DPoP outbound, or token-exchange flow. - You're building a custom framework adapter. - You need to verify tokens outside a request context. ### Build a client + resource ```go import ( "context" "github.com/authplane/go-sdk/core/authplane" "github.com/authplane/go-sdk/core/resource" ) client, err := authplane.NewClient(ctx, "https://auth.example.com", authplane.WithClientCredentials("my-client", "s3cret"), ) if err != nil { panic(err) } defer client.Close() res, err := client.Resource("https://api.example.com/mcp", resource.WithScopes("tools/query"), ) if err != nil { panic(err) } ``` ### Verify a token ```go claims, err := res.VerifyToken(ctx, accessToken) // add resource.WithDPoP(dpopCtx) for DPoP if err != nil { // sentinel errors: verifier.ErrTokenExpired, verifier.ErrInsufficientScope, // verifier.ErrDPoPBindingMismatch, etc. — check with errors.Is. } // claims.Sub(), claims.Scopes(), claims.Audience(), claims.Cnf(), claims.Act(), claims.AgentID(), ... ``` ### Token exchange ```go resp, err := client.TokenExchange(ctx, authplane.TokenExchangeInput{ SubjectToken: userToken, Resources: []string{"https://api.example.com/downstream"}, Scopes: []string{"tools/write"}, }) // resp.AccessToken, resp.TokenType ("Bearer" | "DPoP"), resp.ExpiresIn ``` If the exchange requires user consent that hasn't happened yet, `client.TokenExchange` returns a `*authplane.ConsentRequiredError` — its `ConsentURL` field is what an MCP adapter would map to the `-32042` elicitation error. ### Inbound DPoP Build a `verifier.DPoPContext` from your framework's request (method, URL, `DPoP` header values) and pass it as an option: `res.VerifyToken(ctx, token, resource.WithDPoP(dpopCtx))`. See [core user-guide §9 Inbound DPoP Summary](https://github.com/AuthPlane/go-sdk/blob/main/core/docs/user-guide.md#9-inbound-dpop-summary). ### Error handling Sentinel errors from `github.com/authplane/go-sdk/core/resource/verifier`, checked with `errors.Is`: - `ErrTokenMissing`, `ErrTokenExpired`, `ErrInvalidSignature`, `ErrInvalidClaims`, `ErrInsufficientScope`, `ErrTokenRevoked` - `ErrDPoPBindingMismatch`, `ErrDPoPReplayDetected`, `ErrDPoPInvalid` - `ErrMetadataUnavailable`, `ErrJWKSUnavailable` Plus `authplane.ConsentRequiredError` for token-exchange consent failures. Each sentinel maps to an OAuth error code suitable for a `WWW-Authenticate` challenge — the HTTP adapter uses these to build the response automatically; direct users of `core` construct their own. ### Cleanup `client.Close()`. Idempotent. Releases JWKS and metadata refresh goroutines. --- ## Related - [SDKs overview](/sdks/overview) — pick between adapters - [Quickstart](/quickstart) — `authplanemcp` end-to-end in 10 minutes - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) - [Guides: Wire up the Token Vault](/guides/token-vault) - **Full user-guides in the go-sdk repo:** - [`mcp` (official MCP SDK)](https://github.com/AuthPlane/go-sdk/blob/main/mcp/docs/user-guide.md) - [`mark3labs`](https://github.com/AuthPlane/go-sdk/blob/main/mark3labs/docs/user-guide.md) - [`http`](https://github.com/AuthPlane/go-sdk/blob/main/http/docs/user-guide.md) - [`core`](https://github.com/AuthPlane/go-sdk/blob/main/core/docs/user-guide.md) --- ## sdks/overview.mdx --- title: SDKs overview description: "Which AuthPlane SDK adapter to use for your MCP framework — Python, TypeScript, Go — plus the feature matrix and install commands." section: SDKs sectionOrder: 3 order: 1 --- # SDKs overview > **TL;DR** — Three languages (Python, TypeScript, Go), one shared core primitive package per language, plus one adapter per popular MCP framework. All adapters do the same job: fetch AS metadata, cache the JWKS, publish RFC 9728 PRM, validate incoming JWTs (with or without DPoP), and expose token-exchange for delegation and upstream vending. This page tells you which adapter to install for your stack. ## Which adapter for which stack | Language | Adapter package | MCP framework it targets | Framework repo | |---|---|---|---| | **Python** | `authplane-mcp` | Official MCP Python SDK — `mcp.server.fastmcp.FastMCP` | [modelcontextprotocol/python-sdk](https://github.com/modelcontextprotocol/python-sdk) | | **Python** | `authplane-fastmcp` | FastMCP (PrefectHQ) | [PrefectHQ/fastmcp](https://github.com/PrefectHQ/fastmcp) | | **Python** | `authplane` (core) | Framework-agnostic primitives | — | | **TypeScript** | `@authplane/mcp` | Official MCP TypeScript SDK | [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) | | **TypeScript** | `@authplane/fastmcp` | FastMCP (punkpeye) | [punkpeye/fastmcp](https://github.com/punkpeye/fastmcp) | | **TypeScript** | `@authplane/hono` | Hono web framework | [honojs/hono](https://github.com/honojs/hono) | | **TypeScript** | `@authplane/nestjs` | NestJS | [nestjs/nest](https://github.com/nestjs/nest) | | **TypeScript** | `@authplane/sdk` (core) | Framework-agnostic primitives | — | | **Go** | `authplanemcp` | Official MCP Go SDK | [modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) | | **Go** | `authplane mark3labs` | mark3labs/mcp-go community SDK | [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) | | **Go** | `authplane http` | Generic `net/http` — any Go server | — | | **Go** | `authplane` (core) | Framework-agnostic primitives | — | > **Naming trap.** "FastMCP" is the class name of the official MCP Python SDK's transport (`mcp.server.fastmcp.FastMCP`) **and** the name of two independent framework projects (PrefectHQ's Python FastMCP, punkpeye's TypeScript FastMCP). They are three different pieces of software. Pick the AuthPlane adapter that matches the framework you actually use. ## Feature support Every adapter delivers the same core functionality; the differences are how per-tool scope enforcement and inbound DPoP are wired. | Feature | Python (mcp) | Python (fastmcp) | TS (all) | Go (all) | Core packages | |---|---|---|---|---|---| | JWT signature validation (RS256/ES256/PS256) | ✓ | ✓ | ✓ | ✓ | ✓ | | RFC 8414 metadata discovery + cache | ✓ | ✓ | ✓ | ✓ | ✓ | | JWKS fetch + background rotation | ✓ | ✓ | ✓ | ✓ | ✓ | | RFC 9728 PRM publishing | ✓ | ✓ | ✓ | ✓ | manual | | Audience binding (`aud` = resource URI) | ✓ | ✓ | ✓ | ✓ | ✓ | | Inbound DPoP enforcement (RFC 9449) | ✓ *[needs `install_request_context`]* | ✓ *[no extra wiring](/sdks/python#authplane-fastmcp-dpop-caveat)* | ✓ | ✓ | ✓ | | Outbound DPoP for calls to AS | ✓ | ✓ | ✓ | ✓ | ✓ | | Token exchange (RFC 8693) via `client.exchange()` (Go: `client.TokenExchange()`) | ✓ | ✓ | ✓ | ✓ | ✓ | | Introspection revocation (RFC 7662) | ✓ | ✓ | ✓ | ✓ | ✓ | | URL elicitation → MCP JSON-RPC `-32042` | ✓ | ✓ | ✓ | ✓ | manual | | Per-tool scope guard | `require_scope()` | `@mcp.tool(auth=require_scopes(...))` | `requireScopes()` | `ClaimsFromContext()` + manual check | manual | ## Choose your adapter in three questions 1. **Which MCP framework are you using?** - Official MCP SDK (Python, TS, or Go) → `authplane-mcp` / `@authplane/mcp` / `authplanemcp`. - PrefectHQ FastMCP (Python) → `authplane-fastmcp`. - punkpeye FastMCP (TS) → `@authplane/fastmcp`. - Hono → `@authplane/hono`. - NestJS → `@authplane/nestjs`. - mark3labs/mcp-go → `authplane mark3labs`. - Anything else in Go with plain `net/http` → `authplane http`. - No framework, want raw primitives → the core `authplane` / `@authplane/sdk` / `authplane` (Go) package. 2. **Do you need inbound DPoP enforcement?** All adapters — including Python's `authplane-fastmcp` — verify DPoP proofs when configured. `authplane-mcp` callers additionally call `install_request_context(mcp)` so the verifier can see the raw request; `authplane-fastmcp` needs no extra wiring. See [Python: Inbound DPoP](/sdks/python#authplane-fastmcp-dpop-caveat). 3. **Do you need to vend upstream tokens** (GitHub, Slack, Google) from your tools? All adapters expose `client.exchange()` (Python), `auth.client.exchange()` (TS), or `client.TokenExchange()` (Go). See [Guides: Wire up the Token Vault](/guides/token-vault). ## Install commands ### Python ```bash # For the official MCP Python SDK pip install authplane-mcp # For PrefectHQ FastMCP pip install authplane-fastmcp # Core primitives only pip install authplane ``` Requires Python 3.11+. `authplane-mcp` supports `mcp >=1.23.0, <1.28.0` (MCP 1.28 renamed an elicitation field — update pending). ### TypeScript ```bash # For the official MCP TypeScript SDK npm install @authplane/sdk @authplane/mcp # For punkpeye FastMCP npm install @authplane/sdk @authplane/fastmcp fastmcp zod # For Hono npm install @authplane/sdk @authplane/hono hono # For NestJS npm install @authplane/sdk @authplane/nestjs @nestjs/common @nestjs/core # Core primitives only npm install @authplane/sdk ``` Requires Node.js 20 LTS or newer. ### Go ```bash # For the official MCP Go SDK go get github.com/authplane/go-sdk/mcp # For mark3labs/mcp-go go get github.com/authplane/go-sdk/mark3labs # For plain net/http (any framework) go get github.com/authplane/go-sdk/http # Core primitives only go get github.com/authplane/go-sdk/core ``` Requires Go **1.24+** for `core` and `http`, **1.25+** for `mcp`, **1.25.5+** for `mark3labs`. ## Versioning & compatibility All AuthPlane SDKs follow **semantic versioning**. Same-major-line upgrades are drop-in; a major bump signals a breaking change and comes with a migration note in that repo's `CHANGELOG.md`. - **Python** — pinning `authplane-mcp==0.x` is safe; the underlying MCP SDK version is called out in each release's compatibility line. - **TypeScript** — `@authplane/*` packages share a version; upgrade the set together. Compatible with `fastmcp@^3.35`, `hono@^4`, `@nestjs/*@^10 or ^11`. - **Go** — module versions independent per adapter; the `core` module is the shared dependency and follows the same version cadence. Every SDK ships a conformance test suite that runs against a live AuthPlane instance. Green suite = the adapter meets the spec claims on this page. ## What each SDK adapter does under the hood Regardless of language and framework, the setup call performs the same seven steps described in [Your first MCP server](/first-mcp-server#what-the-sdk-call-does): 1. Discover the AS via RFC 8414 metadata 2. Fetch the JWKS + start background rotation 3. Build an in-memory Resource object 4. Wrap it in a Token Verifier the framework's `authenticate` callback calls 5. Build the RFC 9728 PRM document 6. Wrap the underlying OAuth client to auto-translate `ConsentRequiredError` to MCP `-32042` 7. Register an on-shutdown hook (`aclose()` / `.close()` / `defer Close()`) The differences between adapters are the surface — how the framework hands the request in, how you enforce scopes, and how the PRM handler gets mounted. ## Related - [SDKs: Python](/sdks/python) — the three Python packages in detail - [SDKs: TypeScript](/sdks/typescript) — the five `@authplane/*` packages in detail - [SDKs: Go](/sdks/go) — the four Go modules in detail - [Quickstart](/quickstart) — the shortest path from install to a running server - [Your first MCP server](/first-mcp-server) — line-by-line walkthrough of the snippet - [Guides: Wire up the Token Vault](/guides/token-vault) — using `client.exchange()` to vend upstream tokens --- ## sdks/python.mdx --- title: Python description: "Three Python packages — authplane-mcp (official MCP SDK), authplane-fastmcp (PrefectHQ), and authplane core primitives — with the caveats you need to know." section: SDKs sectionOrder: 3 order: 2 --- # Python > **TL;DR** — Three packages. `authplane-mcp` targets the **official MCP Python SDK** (`from mcp.server.fastmcp import FastMCP`). `authplane-fastmcp` targets **PrefectHQ FastMCP** (`from fastmcp import FastMCP`) — different framework, same class name, different adapter. `authplane` is the framework-agnostic core. All three require Python 3.11+ and share the same underlying JWT validation, DPoP, token-exchange, and elicitation semantics. ## Install ```bash # Official MCP Python SDK pip install authplane-mcp # PrefectHQ FastMCP pip install authplane-fastmcp # Core primitives only (custom integrations) pip install authplane ``` Compatibility: | Package | Requires | Framework | |---|---|---| | `authplane-mcp` | Python 3.11+, `mcp >=1.23.0, <1.28.0` | Official MCP Python SDK | | `authplane-fastmcp` | Python 3.11+, `fastmcp` (PrefectHQ) | PrefectHQ FastMCP | | `authplane` | Python 3.11+ | Framework-agnostic | `mcp >=1.28` renamed the elicitation field from `elicitationId` (camelCase) to `elicitation_id` (snake_case), which breaks `authplane-mcp` at the wire layer — a fix is planned, not yet cut. Track [python-sdk issues](https://github.com/AuthPlane/python-sdk/issues) if you're on 1.28+. --- ## `authplane-mcp` — Official MCP Python SDK adapter ### Quickstart ```python import asyncio from authplane import InboundDPoPOptions from authplane_mcp import authplane_mcp_auth, install_request_context, require_scope from mcp.server.fastmcp import FastMCP async def main() -> None: auth = await authplane_mcp_auth( issuer="https://auth.example.com", resource="https://mcp.example.com/mcp", scopes=["tools/query"], enforce_scopes_on_all_requests=True, inbound_dpop=InboundDPoPOptions(required=True), ) mcp = FastMCP("my-server", port=8080, json_response=True, **auth) install_request_context(mcp) # required for inbound DPoP @mcp.tool() async def query(sql: str) -> str: require_scope("tools/query") return f"Ran: {sql}" try: await mcp.run_streamable_http_async() finally: await auth.aclose() asyncio.run(main()) ``` **How the return value plugs into `FastMCP`:** `authplane_mcp_auth()` returns an `AuthplaneAuthResult`. It implements the mapping protocol — `**auth` yields exactly the two keys `FastMCP()` expects (`token_verifier`, `auth`). `.client` is exposed as a plain attribute for `client.exchange()` calls from your tool code. `.aclose()` releases the JWKS refresh task + httpx pool. ### `authplane_mcp_auth()` options Same shape as `authplane-fastmcp` and `authplaneMcpAuth` in TS — see [full reference](https://github.com/AuthPlane/python-sdk/blob/main/authplane-mcp/docs/user-guide.md#configuration-reference) for every option with defaults. The most-used: | Option | Type | Purpose | |---|---|---| | `issuer` | `str` | AS URL. Used for RFC 8414 discovery + JWT `iss` validation. Required. | | `resource` | `str` | Your resource URI. Used as JWT `aud` and PRM `resource`. Required. | | `scopes` | `list[str]` | Scopes this resource advertises. Empty list valid; PRM will show no scopes. | | `enforce_scopes_on_all_requests` | `bool` | Advertise `scopes` in PRM AND enforce at request layer. Default `False` — per-tool `require_scope()` is the intended granular pattern. | | `inbound_dpop` | `InboundDPoPOptions` | Requires `install_request_context(mcp)`. See below. | | `as_credentials` | `ASCredentials` | For `IntrospectionRevocation` and `client.exchange()`. | | `revocation_checker` | `IntrospectionRevocation \| callable \| None` | Post-signature-check revocation. None disables (offline validation only). | | `dpop` | `DPoPProvider` | For outbound calls to AS. | | `dev_mode` | `bool` | Relax SSRF checks (allow `http://localhost`). Remove in production. | | `allowed_algorithms` | `list[str]` | Default `["RS256", "ES256"]`. | | `jwks_refresh_seconds` | `int` | Default `300`. | | `clock_skew_seconds` | `int` | Default `30`. | | `metadata_refresh_seconds` | `int` | Default `3600`. | ### Scope enforcement Two layers, use both: **Request-layer** (`enforce_scopes_on_all_requests=True`) — the MCP SDK's `RequireAuthMiddleware` rejects any request whose token doesn't carry every scope in `scopes=[...]`. Also required for PRM `scopes_supported` to be populated (OAuth-discovery clients like Claude Code request scopes based on this). **Per-tool** (`require_scope("scope")` inside each handler) — granular. Remains correct even when request-layer enforcement is on (no-op in that case). ```python from authplane_mcp import require_scope @mcp.tool() async def query(sql: str) -> str: require_scope("tools/query") # raises PermissionError if scope missing return f"Ran: {sql}" ``` ### Inbound DPoP To *enforce* DPoP proofs on inbound requests you need three things: 1. `inbound_dpop=InboundDPoPOptions(required=True)` on `authplane_mcp_auth()`. 2. `install_request_context(mcp)` after building `FastMCP`. 3. The AS must issue DPoP-bound tokens (`AUTHPLANE_DPOP_ENABLED=true`). Why `install_request_context`? The official MCP Python SDK's `FastMCP` doesn't expose a middleware seam. The adapter wraps `mcp.streamable_http_app` and installs `AuthplaneRequestContextMiddleware` that publishes the active Starlette `Request` on a `ContextVar` before the verifier runs — the verifier reads it to build a `DPoPRequestContext` and forward it to `AuthplaneResource.verify()`. Without this call, DPoP-bound requests fail closed with `401 WWW-Authenticate: DPoP error="invalid_dpop_proof"` — the misconfiguration surfaces immediately rather than as a silent bypass. The call is idempotent — a second call on the same `FastMCP` instance is a no-op. ### Token exchange (RFC 8693) Vend upstream tokens or delegate on behalf of the user from inside a tool handler: ```python from authplane.oauth import TokenExchangeOptions from mcp.server.auth.middleware.auth_context import get_access_token @mcp.tool() async def read_calendar() -> dict[str, str]: require_scope("tools/read_calendar") inbound = get_access_token() downstream = await auth.client.exchange(TokenExchangeOptions( subject_token=inbound.token, resources=("https://www.googleapis.com/calendar/v3",), scope="https://www.googleapis.com/auth/calendar", )) return {"token_type": downstream.token_type, "expires_in": str(downstream.expires_in)} ``` The result includes `access_token`, `token_type` (`"Bearer"` or `"DPoP"` if the subject was DPoP-bound), `expires_in`, and `scope`. ### URL elicitation for consent If the exchange target requires the user to complete an upstream OAuth Connect flow (they haven't yet linked Google Calendar), the AS returns `error=consent_required` with a `consent_url`. The adapter wraps `client.exchange` to translate this into `UrlElicitationRequiredError` — MCP JSON-RPC error code `-32042`. The MCP SDK re-raises it from tool handlers, the client sees the elicitation, and the user visits the URL. **No `try/except` needed in your tool code.** ### Cleanup `auth.aclose()` cancels the JWKS refresh task and closes the httpx pool. Always wrap `mcp.run_streamable_http_async()` in `try/finally`. --- ## `authplane-fastmcp` — PrefectHQ FastMCP adapter Different framework, similar-shaped API. Uses `from fastmcp import FastMCP` (PrefectHQ), not the official MCP Python SDK. ### Quickstart ```python import asyncio from fastmcp import FastMCP from authplane_fastmcp import authplane_auth async def main() -> None: auth = await authplane_auth( issuer="https://auth.example.com", base_url="https://mcp.example.com", scopes=["tools/query"], ) mcp = FastMCP("my-server", **auth) @mcp.tool() def query(sql: str) -> str: """Execute a query.""" return f"Ran: {sql}" try: await mcp.run_async(transport="http", port=8080) finally: await auth.aclose() asyncio.run(main()) ``` The audience URL is auto-derived from `base_url + mcp_path` (`mcp_path` defaults to `/mcp`). You can also pass an explicit `resource=` if your setup diverges. ### `authplane_auth()` options Same shape as `authplane_mcp_auth()` with two differences: - `base_url` + `mcp_path` instead of `resource` (or pass `resource` explicitly). - No `enforce_scopes_on_all_requests` — PrefectHQ FastMCP filters tools from the catalog per-scope instead (see below). - Same `inbound_dpop`, `revocation_checker`, `as_credentials`, `dpop`, `dev_mode`, `allowed_algorithms`, cache tunables. ### Scope enforcement — different from `authplane-mcp` PrefectHQ FastMCP uses `require_scopes` from `fastmcp.server.auth` as a decorator argument on `@mcp.tool`: ```python from fastmcp.server.auth import require_scopes @mcp.tool(auth=require_scopes("tools/query")) def query(sql: str) -> str: """Requires tools/query scope.""" return f"Ran: {sql}" @mcp.tool(auth=require_scopes("tools/admin", "tools/delete")) def delete_all() -> str: """Requires BOTH scopes.""" return clear_database() ``` **Enforcement happens by filtering tools out of the catalog**, not by returning 403 on call. A caller missing `tools/query` sees `tools/list` without `query` present, and a `tools/call` for `query` returns HTTP 200 with `{"isError": true, "content": [{"text": "Unknown tool: 'query'"}]}` — **not** a 403. UX layers expecting 403 to prompt re-auth won't see one; key off `isError` + tool-not-found content text instead. ### Inbound DPoP Setting `inbound_dpop=InboundDPoPOptions(required=True)` on `authplane_auth()` **fully enforces DPoP proof validation on every incoming request** — the verifier checks the proof, binding, and replay cache before your tool handler runs. No additional middleware is needed. No extra wiring is needed: the fastmcp verifier reads the raw request via `fastmcp.server.dependencies.get_http_request()` when it builds the `DPoPRequestContext`. (`install_request_context(mcp)` belongs to `authplane-mcp`, not this package.) The AS must issue DPoP-bound tokens (`AUTHPLANE_DPOP_ENABLED=true`) for enforcement to have anything to check. ### Token exchange, elicitation, cleanup Same shape as `authplane-mcp` above — `auth.client.exchange(TokenExchangeOptions(...))` from inside a tool, elicitation auto-translation to `-32042`, `await auth.aclose()` in `finally`. --- ## `authplane` — Core primitives Framework-agnostic package. Use it when: - You're writing a custom transport or framework integration. - You want to verify tokens outside a request context (batch job, background worker). - You need programmatic PRM generation without the adapter's Starlette wiring. - You want direct control over the httpx client / DPoP outbound / caching. ### Building an `AuthplaneClient` ```python from authplane import AuthplaneClient, ASCredentials client = await AuthplaneClient.create( issuer="https://auth.example.com", auth=ASCredentials(client_id="my-tool", client_secret="…"), dev_mode=False, jwks_refresh_seconds=300, ) # Later, at shutdown: await client.aclose() ``` ### Building a resource + verifying a token ```python verifier = client.resource( resource="https://mcp.example.com/mcp", scopes=["tools/query"], ) claims = await verifier.verify(access_token, dpop_request=None) # claims.sub, claims.scopes, claims.audience, claims.dpop_proof, claims.act, claims.agent_id, ... ``` `AuthplaneResource.verify()` returns `VerifiedClaims` with all standard JWT claims plus AuthPlane extensions (`agent_id`, `act` chain, `dpop_proof` for DPoP-bound tokens). ### Token exchange from the core client ```python from authplane.oauth import TokenExchangeOptions response = await client.exchange(TokenExchangeOptions( subject_token=inbound_jwt, resources=("https://api.example.com/downstream",), scope="tools/write", )) # response.access_token, response.token_type, response.expires_in ``` ### Inbound DPoP Build a `DPoPRequestContext` from your framework's request object and pass it to `verify(dpop_request=ctx)`. The `DPoPRequestContext` wraps method + URL + `DPoP` header value(s). See [core user-guide §8 Inbound DPoP Summary](https://github.com/AuthPlane/python-sdk/blob/main/authplane/docs/user-guide.md#8-inbound-dpop-summary) for the shape. ### Error handling Every failure mode is a typed exception in `authplane.errors`: - `TokenMissingError` — no `Authorization` header, or unparseable - `TokenExpiredError` / `InvalidSignatureError` / `InvalidClaimsError` — expiry, signature, or claim validation failed - `InsufficientScopeError` — token lacks required scope(s) - `TokenRevokedError` — introspection returned `active: false` - `DPoPBindingMismatchError` / `DPoPReplayDetectedError` — DPoP failures - `ConsentRequiredError` — token exchange needs upstream Connect (contains `consent_url`) `http_status` and `www_authenticate` are module-level functions in `authplane.errors` that take an error instance and produce the correct RFC 6750 / 9449 challenge for your response: ```python from authplane.errors import http_status, www_authenticate try: claims = await verifier.verify(access_token, dpop_request=None) except Exception as err: status = http_status(err) challenge = www_authenticate(err) # return an HTTP response with `status` and a `WWW-Authenticate: ` header ``` --- ## Related - [SDKs overview](/sdks/overview) — pick between adapters - [Quickstart](/quickstart) — `authplane-mcp` end-to-end in 10 minutes - [Your first MCP server](/first-mcp-server) — line-by-line walkthrough - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — configuring inbound DPoP correctly - [Guides: Wire up the Token Vault](/guides/token-vault) — using `client.exchange()` for upstream vending - [Concepts: DPoP](/concepts/dpop) — why proof-of-possession, when to enable - **Full user-guides** in the python-sdk repo: - [authplane-mcp](https://github.com/AuthPlane/python-sdk/blob/main/authplane-mcp/docs/user-guide.md) - [authplane-fastmcp](https://github.com/AuthPlane/python-sdk/blob/main/authplane-fastmcp/docs/user-guide.md) - [authplane core](https://github.com/AuthPlane/python-sdk/blob/main/authplane/docs/user-guide.md) --- ## sdks/typescript.mdx --- title: TypeScript description: "Five @authplane/* packages — mcp (official SDK), fastmcp (punkpeye), hono, nestjs, and core — with quickstarts and the API surface you need." section: SDKs sectionOrder: 3 order: 3 --- # TypeScript > **TL;DR** — Five packages. `@authplane/mcp` targets the **official MCP TypeScript SDK**. `@authplane/fastmcp` targets **punkpeye/fastmcp**. `@authplane/hono` and `@authplane/nestjs` target their respective web frameworks. `@authplane/sdk` is the core — everything else builds on it. All adapters share the same option surface (`issuer`, `resource`, `scopes`, `requiredScopes`, `inboundDPoP`, `revocationChecker`, `devMode`, …) and produce the same conceptual return value (`client`, `verifier`, framework-native middleware, PRM handler). ## Install ```bash # Official MCP TypeScript SDK npm install @authplane/sdk @authplane/mcp @modelcontextprotocol/sdk express zod # punkpeye FastMCP npm install @authplane/sdk @authplane/fastmcp fastmcp zod # Hono npm install @authplane/sdk @authplane/hono hono # NestJS npm install @authplane/sdk @authplane/nestjs @nestjs/common @nestjs/core reflect-metadata rxjs # Core primitives only npm install @authplane/sdk ``` Requires Node 20 LTS or newer for `@authplane/mcp` and `@authplane/sdk`. `@authplane/hono` and `@authplane/nestjs` require Node 22 LTS or newer. `@authplane/sdk` is a peer of every adapter. --- ## `@authplane/mcp` — Official MCP TypeScript SDK adapter ### Quickstart ```ts import express from "express"; import crypto from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { authplaneMcpAuth } from "@authplane/mcp"; import { z } from "zod"; const server = new McpServer({ name: "my-server", version: "1.0.0" }); server.tool( "echo", "Echo message", { message: z.string() }, async ({ message }) => ({ content: [{ type: "text", text: message }] }), ); const auth = await authplaneMcpAuth({ issuer: "http://localhost:9000", resource: "http://localhost:3000/mcp", scopes: ["tools/echo"], }); const app = express(); app.use(express.json()); app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler); const transports = new Map(); app.all("/mcp", auth.bearerAuth, async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && transports.has(sessionId)) { await transports.get(sessionId)!.handleRequest(req, res, req.body); return; } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), }); transports.set(transport.sessionId ?? crypto.randomUUID(), transport); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); app.listen(3000); ``` ### `authplaneMcpAuth(options)` — what you get back | Return field | Type | Use | |---|---|---| | `client` | `AuthplaneClient` | Owns JWKS + metadata refresh; use for `client.exchange()`, `client.introspect()`. Call `client.close()` on shutdown. | | `verifier` | `AuthplaneResource` | Direct token verification if you need to bypass middleware. | | `bearerAuth` | Express middleware | Verifies token, enforces `requiredScopes`, attaches `req.auth`. | | `protectedResourceMetadataPath` | `string` | Path for the PRM handler (`/.well-known/oauth-protected-resource/mcp`). | | `protectedResourceMetadataHandler` | Express handler | Serves the RFC 9728 PRM document. | | `protectedResourceMetadata` | object | The PRM JSON payload if you want to serve it yourself. | Options (highlights): | Option | Default | Purpose | |---|---|---| | `issuer` | required | AS URL for RFC 8414 discovery. | | `resource` | required | JWT `aud` and PRM `resource`. | | `scopes` | `[]` | Advertised in PRM. Default `requiredScopes`. | | `requiredScopes` | = `scopes` | Enforced by `bearerAuth` on every request. Pass `[]` to disable request-layer enforcement. | | `inboundDPoP` | undefined | Enable inbound DPoP verification + PRM advertising. | | `asCredentials` | undefined | Enable introspection/revocation and `client.exchange()`. | | `revocationChecker` | undefined | Enable RFC 7662 introspection revocation. | | `devMode` | `false` | Relax SSRF for local dev. | ### Per-tool scope enforcement `bearerAuth` enforces `requiredScopes` globally. For per-tool guards use `requireScope(scope)` from `@authplane/mcp` inside your tool handler — it reads `req.auth` and throws if the scope is missing. Details in the [full user-guide](https://github.com/AuthPlane/ts-sdk/blob/main/packages/mcp/docs/user-guide.md). ### Cleanup Call `await auth.client.close()` on shutdown to release JWKS/metadata timers. --- ## `@authplane/fastmcp` — punkpeye FastMCP adapter ### Quickstart ```ts import { FastMCP, requireScopes } from "fastmcp"; import { authplaneFastMcpAuth, type AuthplaneFastMcpSession } from "@authplane/fastmcp"; import { z } from "zod"; const auth = await authplaneFastMcpAuth({ issuer: "http://localhost:9000", resource: "http://localhost:8080/mcp", scopes: ["tools/read"], requiredScopes: ["tools/read"], inboundDPoP: { required: true }, devMode: true, }); const server = new FastMCP({ name: "my-server", version: "1.0.0", authenticate: auth.authenticate, oauth: auth.oauth, }); server.addTool({ name: "read", description: "Return the query.", parameters: z.object({ query: z.string() }), canAccess: requireScopes("tools/read"), execute: async ({ query }) => ({ content: [{ type: "text", text: `You asked: ${query}` }] }), }); await server.start({ transportType: "httpStream", httpStream: { port: 8080, endpoint: "/mcp" }, }); ``` ### `authplaneFastMcpAuth(options)` — what you get back | Field | Purpose | |---|---| | `authenticate` | `NonNullable` — verifies the bearer token, enforces `requiredScopes`, returns a typed session. | | `oauth` | `NonNullable` — publishes RFC 9728 PRM via FastMCP's `oauth` config. | | `client` | Underlying `AuthplaneClient` (wrapped for elicitation). | | `verifier`, `tokenVerifier`, `protectedResourceMetadata`, `protectedResourceMetadataUrl` | Lower-level access. | `AuthplaneFastMcpSession` is the session shape published by `authenticate` — its `token` field carries the `VerifiedClaims` (subject at `session.token.sub`, plus `clientId`, `scopes`, `expiresAt`, and the raw claims). ### DPoP + FastMCP `authenticate` double-invocation FastMCP calls `authenticate` twice per HTTP request (once at the request gate, once when building the session payload). RFC 9449 inbound replay verification can only run once per proof — the adapter uses Node's `AsyncLocalStorage` scoped to the request's async context to collapse both invocations onto a single `verifyAccessToken` promise. This means `inboundDPoP: { required: true }` works out of the box against `fastmcp@^3.35` (verified on 3.35.x and 4.0.1). If a future FastMCP version moves the second invocation to a callsite that loses async-context propagation, you may see spurious `DPoPReplayDetected` errors — file an issue. ### Cleanup `await auth.client.close()` on shutdown. --- ## `@authplane/hono` — Hono adapter ### Quickstart ```ts import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { authplaneHonoAuth, type HonoAuthVariables } from "@authplane/hono"; const auth = await authplaneHonoAuth({ issuer: "http://localhost:9000", resource: "http://localhost:8090/mcp", scopes: ["tools/weather"], devMode: true, }); const app = new Hono<{ Variables: HonoAuthVariables }>(); app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler); app.use("/mcp", auth.bearerAuth); app.post("/mcp", (c) => { const info = c.get("auth"); // VerifiedClaims return c.json({ ok: true, clientId: info.clientId, scopes: info.scopes }); }); serve({ fetch: app.fetch, port: 8090 }); ``` ### Return shape | Field | Purpose | |---|---| | `client` | Non-nullable `AuthplaneClient` (Hono's factory always creates one). | | `verifier` | `AuthplaneResource`. | | `bearerAuth` | `MiddlewareHandler<{ Variables: HonoAuthVariables }>`. | | `protectedResourceMetadataPath` | Route path for the PRM handler. | | `protectedResourceMetadataHandler` | Hono `Handler` serving the PRM. | | `protectedResourceMetadata` | The PRM JSON payload. | ### Context shape (`c.get("auth")`) After `bearerAuth` runs, `c.get("auth")` returns `VerifiedClaims` — `sub`, `clientId`, `scopes`, `issuer`, `audience`, `expiresAt`, `issuedAt`, `notBefore`, `jti`, `kid`, and `raw` (the full claim object). Call `info.requireScope(scope)` to enforce a scope inline; the adapter also exports a `requireScope("scope")` middleware for per-route enforcement. ### Runtime portability `@authplane/hono` is runtime-agnostic — the adapter itself has no Node-only APIs. Deploy to Node, Bun, Deno, Cloudflare Workers, or wherever Hono runs. The only Node-specific piece in the quickstart above is `@hono/node-server`; swap it for the runtime's Hono entrypoint on other platforms. ### Cleanup `await auth.client.close()` on shutdown. --- ## `@authplane/nestjs` — NestJS adapter ### Quickstart ```ts import "reflect-metadata"; import { Body, Controller, Module, Post, UseGuards } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { AuthInfo, AuthplaneAuthGuard, AuthplaneModule, RequireScopes, type VerifiedClaims, } from "@authplane/nestjs"; @Controller("mcp") @UseGuards(AuthplaneAuthGuard) class McpController { @Post("tools/weather") @RequireScopes("tools/weather") async weather(@AuthInfo() info: VerifiedClaims, @Body() body: { city: string }) { return { content: [{ type: "text", text: `${body.city}: sunny (caller=${info.clientId})` }], }; } } @Module({ imports: [ AuthplaneModule.forRoot({ issuer: "http://localhost:9000", resource: "http://localhost:8090/mcp", scopes: ["tools/weather"], devMode: true, }), ], controllers: [McpController], }) class AppModule {} const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); // AuthplaneShutdownHook runs client.close() await app.listen(8090); ``` ### `AuthplaneModule.forRoot` / `.forRootAsync` `forRoot(options)` for the sync case. `forRootAsync({ useFactory | useClass | useExisting, inject, imports })` for the DI case — composes naturally with `ConfigModule`: ```ts AuthplaneModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ issuer: cfg.getOrThrow("AUTHPLANE_ISSUER"), resource: cfg.getOrThrow("AUTHPLANE_RESOURCE"), scopes: cfg.get("AUTHPLANE_SCOPES") ?? [], }), }) ``` ### What the module provides (DI tokens) | Token | Bound to | |---|---| | `AUTHPLANE_CLIENT` | `AuthplaneClient` — for token exchange / introspection. | | `AUTHPLANE_RESOURCE` | `AuthplaneResource` — per-resource verifier. | | `AUTHPLANE_TOKEN_VERIFIER` | Same instance; separate token so tests can override with a mock. | | `AuthplaneAuthGuard` | `CanActivate` guard (used with `@UseGuards`). | | `AuthplaneExceptionFilter` | RFC 6750 §3-compliant filter. | | `AuthplaneShutdownHook` | Runs `client.close()` on `OnApplicationShutdown`. | `@RequireScopes("scope1", "scope2")` decorator + `@AuthInfo()` param decorator give you clean per-route guarding and access to `VerifiedClaims`. ### Express vs. Fastify Both platforms work unchanged — the adapter uses an internal `RequestAdapter` anti-corruption layer over Express + Fastify. `@nestjs/platform-express` is the default; `@nestjs/platform-fastify` is a drop-in. ### Cleanup `app.enableShutdownHooks()` wires the built-in `AuthplaneShutdownHook` to release timers on app exit. Skip if you're managing lifecycle manually and call `await client.close()` yourself. --- ## `@authplane/sdk` — Core primitives Framework-agnostic. Use it when: - You're writing a custom transport or framework integration. - You need to verify tokens outside a request context (batch, worker). - You want programmatic PRM generation without adapter wiring. ### Build a client + resource ```ts import { AuthplaneClient, IntrospectionRevocation } from "@authplane/sdk/core"; const client = await AuthplaneClient.create({ issuer: "https://auth.example.com", auth: { clientId: "my-tool", clientSecret: "…" }, devMode: false, }); const resource = client.resource({ resource: "https://mcp.example.com/mcp", scopes: ["tools/query"], revocationChecker: new IntrospectionRevocation(), }); // Later, at shutdown: await client.close(); ``` ### Verify a token ```ts const claims = await resource.verify(accessToken); // claims.sub, claims.scopes, claims.audience, claims.dpopProof, claims.expiresAt, ... claims.requireScope("tools/query"); // throws InsufficientScope if missing ``` ### Token exchange ```ts const response = await client.exchange({ subjectToken: inboundJwt, resources: ["https://api.example.com/downstream"], scope: "tools/write", }); // response.accessToken, response.tokenType ("Bearer" | "DPoP"), response.expiresIn ``` ### Inbound DPoP Build a `DPoPRequestContext` from your framework's request (`method`, `url`, `DPoP` header values) and pass it to `resource.verify(token, dpopContext)`. Full shape in the [core user-guide DPoP section](https://github.com/AuthPlane/ts-sdk/blob/main/packages/sdk/docs/user-guide.md#dpop-bound-tokens-rfc-9449). ### Error types Every failure mode is a typed exception exported from `@authplane/sdk/core`: - `TokenMissing`, `TokenExpired`, `InvalidSignature`, `InvalidClaims`, `InsufficientScope`, `TokenRevoked` - `DPoPBindingMismatch`, `DPoPReplayDetected`, `DPoPProofMissing`, `InvalidDPoPProof` - `ConsentRequiredError` (has `consentUrl`) - `MetadataFetchError`, `JWKSFetchError` The module also exports `httpStatus(error)` and `wwwAuthenticate(error, {resourceMetadataUrl, scope})` helper functions producing the correct RFC 6750 / RFC 9449 challenge for your response. ### Cleanup `await client.close()`. Idempotent. --- ## Related - [SDKs overview](/sdks/overview) — pick between adapters - [Quickstart](/quickstart) — `@authplane/fastmcp` end-to-end in 10 minutes - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) - [Guides: Wire up the Token Vault](/guides/token-vault) - **Full user-guides in the ts-sdk repo:** - [`@authplane/mcp`](https://github.com/AuthPlane/ts-sdk/blob/main/packages/mcp/docs/user-guide.md) - [`@authplane/fastmcp`](https://github.com/AuthPlane/ts-sdk/blob/main/packages/fastmcp/docs/user-guide.md) - [`@authplane/hono`](https://github.com/AuthPlane/ts-sdk/blob/main/packages/hono/docs/user-guide.md) - [`@authplane/nestjs`](https://github.com/AuthPlane/ts-sdk/blob/main/packages/nestjs/docs/user-guide.md) - [`@authplane/sdk`](https://github.com/AuthPlane/ts-sdk/blob/main/packages/sdk/docs/user-guide.md) --- ## security/dpop.mdx --- title: DPoP description: "RFC 9449 deep dive — proof structure, algorithms, nonce flow, HTU derivation, and every security invariant AuthPlane enforces on inbound proofs." section: Security sectionOrder: 8 order: 3 --- # DPoP > **TL;DR** — Complete spec-level reference for AuthPlane's DPoP (RFC 9449) implementation: proof JWT structure, allowed algorithms + always-rejected ones, `htu`/`htm`/`ath`/`jti` semantics, nonce flow, replay detection, HTU derivation traps (reverse-proxy pitfall), and configuration knobs. If you're **enabling** DPoP, start at [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — this page is the security depth. ## Why DPoP A regular Bearer token is like a house key — whoever holds it can use it. DPoP binds each token to a specific key pair the client holds and proves possession of on every request. Steal the token from a log, network tap, or compromised proxy → useless without the private key. **Enable when:** tokens transit multiple services, high-security environments, compliance requires proof-of-possession, network traffic might be observable. **Skip when:** local dev, everything behind mTLS on trusted internal network, tokens have very short expiry (< 5 min), added complexity isn't worth the residual-risk reduction. **Performance:** ~1 ms crypto per request. Proof ~500 bytes. ## Proof JWT — headers | Header | Value | Notes | |---|---|---| | `typ` | `dpop+jwt` | Exact string. Any other `typ` → rejected. | | `alg` | `ES256`, `RS256`, or `PS256` | See allowed algorithms below. | | `jwk` | Client's **public** key as JWK | Private key in `jwk` → always rejected. | ## Proof JWT — claims | Claim | Required | Semantics | |---|---|---| | `jti` | Always | Unique UUID. Server rejects reused `jti`. | | `htm` | Always | HTTP method (`POST`, `GET`, ...). Exact match against actual request. | | `htu` | Always | HTTP target URL — the request URI with query and fragment stripped (scheme + authority + path; authority includes port when non-default). Compared after RFC 3986 syntax/scheme normalization. See HTU derivation below. | | `iat` | Always | Unix timestamp. Server checks \|now − iat\| ≤ `proof_lifetime`. | | `nonce` | When server requires it | Value from `DPoP-Nonce` response header on prior 401. | | `ath` | At resource server | `base64url(SHA-256(access_token))` — binds proof to token. | **`ath` is required at the resource server** (your MCP server verifying the token), but **omitted at the token endpoint** (client doesn't have the token yet). ## Allowed algorithms | Algorithm | Key type | Status | |---|---|---| | `ES256` | ECDSA P-256 | **Recommended** — compact proofs (~200 bytes), fast signing | | `RS256` | RSA 2048+ | Widely supported, larger proofs (~350 bytes) | | `PS256` | RSA-PSS 2048+ | PSS-padding variant | **Always rejected** (security invariants): - `alg: none` — allows unsigned proofs (trivially forgeable) - `HS256`, `HS384`, `HS512` — symmetric algorithms; verifier would need signing key, defeating proof-of-possession - Private key in `jwk` header — would leak private key to the server ## HTU derivation — the reverse-proxy trap The server derives `htu` from the incoming request, applies RFC 3986 syntax/scheme normalization (lowercased scheme, lowercased host, percent-encoding decode where reserved chars aren't affected, default-port stripped), and only then compares against the proof's normalized `htu`. This is where reverse proxies silently break DPoP. **Server derivation:** ``` htu = :// # authority = host[:port]; port dropped when default ``` - `scheme` — from `X-Forwarded-Proto` header (honored) or the connection scheme - `authority` — host (and non-default port) from the incoming `Host` header (AuthPlane does **not** consult `X-Forwarded-Host`) - `path` — from the request line (query + fragment stripped) **Reverse-proxy gotcha:** a proxy that strips or forgets to set `X-Forwarded-Proto: https` when TLS-terminating in front causes the derived `htu` to be `http://...` while the client's proof says `https://...`. A proxy that rewrites `Host` — replacing the public hostname with the upstream — likewise causes host-mismatch on every request. Either way, every DPoP request → `400 invalid_dpop_proof`. Symptom looks identical to a client bug. **Fix:** configure your reverse proxy to set `X-Forwarded-Proto` for the scheme AND to **preserve the original `Host` header**. Nginx default rewrites `Host` to the upstream server, so add `proxy_set_header Host $host;`. Caddy preserves `Host` by default. Full configs in [Operate: Standalone → Reverse proxy](/operate/standalone#reverse-proxy). **Test:** log the *normalized* derived `htu` on your MCP server; compare against the *normalized* proof `htu`. A mismatch survives normalization — the two forms must be structurally different, not just differently punctuated. ## JTI replay detection Every proof's `jti` is stored in `dpop_nonces` on first successful verification. A second request with the same `jti` → `invalid_dpop_proof` `"DPoP proof jti has already been used"`. Retention: `dpop.proof_lifetime` (default 60 s). Proofs older than that are rejected on the `iat` check anyway, so their JTIs no longer need to be tracked. The `dpop_nonces` table grows unbounded without scheduled purging — schedule `authserver purge --only=dpop-nonces` externally. See [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge#scheduled-purge). ## Server-issued nonces (`require_nonce`) Optional extra layer. When `dpop.require_nonce: true`, every proof must include a `nonce` claim matching a server-issued value. Flow — note the AS uses the RFC 6749 §5.2 error envelope (**not** `WWW-Authenticate: DPoP`, which is the resource-server form from RFC 9449 §9): ``` 1. Client → AS proof without nonce 2. AS → Client HTTP 400 Bad Request (RFC 9449 §8) DPoP-Nonce: Content-Type: application/json { "error": "use_dpop_nonce", "error_description": "Authorization server requires nonce in DPoP proof" } 3. Client → AS fresh proof, this time with nonce= 4. AS → Client 200 (proof accepted) ``` Server nonce = HMAC-signed random value with `dpop.nonce_ttl` expiry (default 60 s). SDKs read `DPoP-Nonce` from the response, cache the current value, and include it in every subsequent proof. **Trade-off:** extra round trip on first request per session. Worth it for high-security deployments where you want proofs bound to server-controlled time windows narrower than the client's clock resolution. ## Access-token-hash (`ath`) At resource servers, proofs include `ath = base64url(SHA-256(access_token))`. Binds the proof to the specific token being presented — even if an attacker captures both proof and token, they can't substitute a different token behind the same proof. Missing `ath` at a resource server → `invalid_dpop_proof`. Missing at the token endpoint is expected (client doesn't have the token yet). ## `cnf.jkt` in access tokens DPoP-bound access tokens carry a confirmation claim: ```json { ... "cnf": { "jkt": "0iaeMlxbX3MR9k0DBjKBR9HBkfHTwIsuw_2dPjsLwBE" } } ``` `jkt` = base64url-encoded SHA-256 thumbprint of the client's public JWK, per [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638). Resource-server verification: `jkt` in the token must match `SHA-256(jwk)` in the proof. ## Configuration ```yaml dpop: enabled: true # default false — additive when off proof_lifetime: 60s # 10s..300s nonce_ttl: 60s # server nonce validity require_nonce: false # true = extra round-trip, tighter replay window ``` Env-var equivalents in [Reference: Configuration → DPoP](/reference/configuration#optional-grants--disabled-by-default). `dpop_signing_alg_values_supported: ["ES256", "RS256", "PS256"]` is advertised in `/.well-known/oauth-authorization-server` when `dpop.enabled: true`. ## Metrics - `authplane_dpop_proofs_validated_total` — successful verifications - `authplane_dpop_proofs_rejected_total` — rejections, labelled by reason (`replay`, `nonce_missing`, `nonce_invalid`, `htu_mismatch`, `htm_mismatch`, `iat_out_of_window`, `signature_invalid`, `disallowed_alg`, `private_key_in_header`) `rate(authplane_dpop_proofs_rejected_total{reason="replay"}[5m]) > 0` in normal ops = client bug or attack. Alert. ## Security invariants (T14, T15) From [threat model](/security/threat-model): - **T14 · DPoP proof replay** — mitigated by `jti` store + `proof_lifetime` + optional nonce. - **T15 · DPoP algorithm confusion** — mitigated by hardcoded allowlist of `ES256`/`RS256`/`PS256`; `alg: none`, symmetric algorithms, and private-key-in-header always rejected. ## Related - [Guides: Enable DPoP end-to-end](/guides/enable-dpop) — actionable enablement guide - [Concepts: DPoP](/concepts/dpop) — mental model + when to enable - [SDKs: Python](/sdks/python#authplane-fastmcp-dpop-caveat) — the PrefectHQ fastmcp DPoP caveat - [Reference: Configuration → DPoP](/reference/configuration#optional-grants--disabled-by-default) - [Reference: Errors → DPoP codes](/reference/errors#dpop-specific-codes-rfc-9449) - [Security: Threat model → T14/T15](/security/threat-model) - [Full source in authserver repo](https://github.com/authplane/authserver/blob/main/docs/security/dpop.md) — extended text --- ## security/key-management.mdx --- title: Key management description: "Signing key lifecycle, storage backends (keyfile / postgres_key / vault_transit), rotation semantics, algorithm choice, and JWKS caching." section: Security sectionOrder: 8 order: 4 --- # Key management > **TL;DR** — Three signing-key stores: `keyfile` (default, PEM on disk — single-instance), `postgres_key` (encrypted-at-rest in DB, multi-instance via `LISTEN/NOTIFY`), `vault_transit` (private key never leaves Vault — HSM-grade). Rotation is zero-downtime: new key becomes `current`, old stays in JWKS until it expires. SDK JWKS cache picks up new keys within 5 min by default. Losing the private key = every outstanding JWT unverifiable — back up keyfile deployments religiously. ## Lifecycle ``` Generate → Active (signing + verification) → Rotated (verification only) → Removed from JWKS ``` 1. **Generate** — first boot creates the key automatically. Nothing to configure. 2. **Active** — the current key signs all new JWTs. Public key in JWKS. 3. **Rotated** — after `authserver admin key rotate`, a new key takes over signing. Old key stays in JWKS so existing tokens still verify. 4. **Removed** — after every token signed with the old key has expired, the old key leaves the JWKS. Default retention of the "previous" key = `dcr.default_token_expiry` (15 min) + some grace period — the old key stays around long enough for in-flight tokens to complete but not indefinitely. ## Storage backends ### `keyfile` (default) — single-instance PEM files on disk. Simplest option; works well for single-instance deployments. ```yaml signing: algorithm: ES256 key_store: keyfile key_path: /var/lib/authserver/keys ``` Directory contains: - `current.pem` — active signing key (private + public) - `rotated-.pem` — previous keys kept for verification **Watch out for:** - **File permissions** — only the authserver process user should read them. `chmod 600` + owned by the `authserver` user. If anyone else can read, they can forge tokens. - **Backups** — back up the `keys/` directory. **Losing the private key = every outstanding JWT unverifiable** (MCP servers reject them until users re-authenticate). - **Docker** — use a named volume (`authserver-data`) so keys persist across container restarts. Without a volume, key rotation state resets on restart. ### `postgres_key` — multi-instance (HA) Multiple AuthPlane instances behind a load balancer need to share the signing key. `postgres_key` stores encrypted keys in the DB — all instances read from the same place, `LISTEN/NOTIFY` on the `signing_key_change` channel propagates rotations across instances in milliseconds. ```yaml signing: algorithm: ES256 key_store: postgres_key ``` Requires `storage.driver: postgres` AND `data_encryption` configured (AES-256-GCM or Vault Transit). The signing key is encrypted with the data-encryption driver before being written to `signing_keys`. ### `vault_transit` — maximum security Keys are managed by HashiCorp Vault's Transit engine. **Private key never leaves Vault** — AuthPlane sends the JWT payload to Vault for signing, gets back the signature. ```yaml signing: algorithm: ES256 key_store: vault_transit vault_transit: address: https://vault:8200 mount: transit key_name: authserver-signing approle: role_id: "..." secret_id: "..." ``` Full setup + Vault CLI walkthrough in [Operate: Vault Transit signing](/operate/vault-transit). **Cost:** ~2 ms latency per signing operation (Vault round trip). Fine for `/oauth/token` throughput on any reasonable Vault deployment. **Benefits:** keys never touch disk, Vault audit log records every sign, FIPS compliance possible with Vault-backed HSM. ## Rotation ### When to rotate - **Calendar cadence** — every 90 days as part of key-hygiene policy. - **After suspected exposure** — backup loss, stolen disk, operator departure with past `keys/` access. - **During algorithm migration** — ES256 ↔ RS256. Rotate to switch algorithms gradually; old tokens keep verifying until they expire. ### How to rotate Zero-downtime hot operation. Previous key stays in the JWKS document so outstanding tokens remain verifiable; new tokens are signed with the new key from the first `/oauth/token` call after rotation. ```bash # Via Admin API curl -X POST http://localhost:9001/admin/keys/rotate \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Or via CLI authserver admin key rotate ``` Response: ```json { "current_kid": "kid_new_abc", "previous_kid": "kid_old_xyz", "rotated_at": "2026-07-01T00:00:00Z" } ``` ### What happens under the hood 1. New key pair generated + persisted (keyfile writes to `key_path`; Vault Transit + `postgres_key` write to their backends). 2. New key becomes `current`; the previously current key demotes to `previous`. Older keys are removed from JWKS so key-compromise blast radius is bounded. 3. `/.well-known/jwks.json` now includes BOTH `current` and `previous` keys. 4. Verifiers that cache the JWKS pick up the new key on their next refresh (SDKs default to 5 min cache TTL — configurable via `jwks_refresh_seconds`). 5. All tokens signed after rotation carry the new `kid`. In-flight tokens signed by the previous key continue to verify until they expire. ### Multi-instance propagation - `postgres_key` — `LISTEN/NOTIFY` on `signing_key_change` channel; all instances reload within milliseconds. - `vault_transit` — Vault is the source of truth; instances all sign via Vault, no local key to propagate. - `keyfile` on shared PVC — instances see the new file when their filesystem watchers fire (may take up to `jwks_refresh_seconds`). Use `SIGHUP` for immediate reload: `kill -HUP $(pidof authserver)` or `docker kill -s HUP ...`. - `keyfile` on separate hosts — you're on your own for propagation. Not recommended for multi-instance. ### JWKS caching considerations Every SDK caches the JWKS with a default TTL of 5 min (`jwks_refresh_seconds`). Right after rotation: - Tokens signed with the new key verify only once the SDK's cache has picked up the new `kid`. - Tokens signed with the old key continue to verify because the old key is still in the JWKS. - Worst case: 5 min of the SDK rejecting new tokens with a "kid not found" error. For coordinated rotations, lower `jwks_refresh_seconds` on the SDK side temporarily. Or accept the 5-min window — nothing breaks, just a rejection window for new tokens during that period. ## Algorithm selection | Algorithm | Key type | Recommendation | |---|---|---| | **ES256** | ECDSA P-256 | **Default recommended** — compact tokens (~500 bytes), fast verification, wide support | | **RS256** | RSA 2048+ | Use only when environment requires RSA (legacy IdPs, HSM constraints) | AuthPlane rejects `HS256` and other symmetric algorithms for JWT signing — the shared secret model doesn't fit multi-party token verification. Switching algorithms: rotate with the new algorithm. The old-algorithm key stays in JWKS for its retention window; old tokens verify against it. New tokens use the new algorithm. Zero-downtime migration. ## JWKS endpoint Served at `/.well-known/jwks.json`, unauthenticated, cacheable. Shape: ```json { "keys": [ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "use": "sig", "kid": "current-kid", "alg": "ES256" }, { "kty": "EC", ... "kid": "previous-kid", "alg": "ES256" } ] } ``` ## Recommendations for MCP servers 1. **Cache JWKS** — SDKs do this by default. Don't fetch per request. 2. **Verify `kid`** — tokens carry `kid` in the header; use the matching key from JWKS. Reject if `kid` isn't in the current JWKS (either the AS hasn't propagated a new key yet, or the token was signed with a key that's been rotated out). 3. **Handle refresh windows** — during rotation, expect a small window where new tokens might be rejected while your JWKS cache is stale. Bounded by `jwks_refresh_seconds`. ## Backup and disaster recovery - **`keyfile`** — back up `key_path/` directory. Rotation state = which key is `current` vs `previous` is only in the file naming; ordering by timestamp is safe. - **`postgres_key`** — back up the DB. Data-encryption key must also be preserved (via env var backup). - **`vault_transit`** — follow your Vault backup procedure. Vault is the source of truth; AuthPlane doesn't need a separate backup. **Recovery from lost private key:** you can't. Every outstanding JWT that was signed with the lost key becomes unverifiable. Clients hit `401 invalid_token` until they re-authenticate. Rotate to a new key immediately; users complete a fresh OAuth flow. ## Security invariants (T9) From [threat model → T9](/security/threat-model): - `keyfile` files: chmod 600, owned by AuthPlane user. - `postgres_key`: encrypted at rest via `data_encryption`. - `vault_transit`: private key never on AuthPlane host. - Rotation on suspicion or every 90 days. ## Related - [Operate: Vault Transit](/operate/vault-transit) — HSM-grade signing walkthrough - [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge#backup) — backup procedures per storage driver - [Reference: Configuration → signing](/reference/configuration#signing) — every knob - [Security: Threat model → T9](/security/threat-model) - [Full source in authserver repo](https://github.com/authplane/authserver/blob/main/docs/security/key-management.md) --- ## security/reporting-vulnerabilities.mdx --- title: Reporting vulnerabilities description: "How to report a security issue in AuthPlane privately, what's in scope, and the response SLAs." section: Security sectionOrder: 8 order: 5 --- # Reporting vulnerabilities > **TL;DR** — Use [GitHub Private Vulnerability Reporting](https://github.com/authplane/authserver/security/advisories/new) for anything you'd rather not disclose in a public issue. Acknowledgment within 48 hours, initial assessment within 5 business days, fix within 7 days for critical / 14 days for high. In-scope covers auth bypass, token forgery, DPoP-proof bypass, token-exchange privilege escalation, and the standard OWASP categories. Out-of-scope: DoS (unless < 10 requests trigger it), social engineering, upstream dependency issues, self-hosted misconfiguration. ## How to report **Do not open a public GitHub issue for security vulnerabilities.** Use **GitHub Private Vulnerability Reporting**: [github.com/authplane/authserver/security/advisories/new](https://github.com/authplane/authserver/security/advisories/new). This ensures: - Your report stays confidential — visible only to maintainers. - Coordinated disclosure — we can ship a fix before public disclosure. - You get credit for responsible disclosure (unless you prefer anonymity). For any of the language SDKs, the equivalent private reporting is at: - Python: [github.com/AuthPlane/python-sdk/security/advisories/new](https://github.com/AuthPlane/python-sdk/security/advisories/new) - TypeScript: [github.com/AuthPlane/ts-sdk/security/advisories/new](https://github.com/AuthPlane/ts-sdk/security/advisories/new) - Go: [github.com/AuthPlane/go-sdk/security/advisories/new](https://github.com/AuthPlane/go-sdk/security/advisories/new) ## What to include - **Description** — what the vulnerability is - **Steps to reproduce** or proof-of-concept - **Affected versions** — release tags or commit SHAs - **Impact assessment** — what an attacker could do - **Suggested mitigation** (optional) ## Response timeline | Stage | SLA | |---|---| | Acknowledgment | within 48 hours | | Initial assessment | within 5 business days | | Critical fix | < 7 days | | High fix | < 14 days | | Public disclosure | after fix is released + downstream users given migration window | ## What's in scope - **Authentication bypass** — OAuth flow, PKCE, client authentication - **Token forgery, replay, or privilege escalation** - **Cryptographic weaknesses** — signing, encryption, key management - **Injection** — SQL, template, header, log-injection - **Sensitive data exposure** — tokens, secrets, keys in logs or responses - **DPoP proof bypass or binding issues** (RFC 9449 §4.3, §7.1) - **Token exchange authorization bypass** (RFC 8693 §4.1) - **Cross-site attacks** — CSRF, XSS on consent / login pages - **CIMD document validation flaws** (draft-ietf-oauth-client-id-metadata-document) - **Broker/upstream credential leakage** ## What's out of scope - **Denial of service** — unless a single or a handful (< 10) of requests trigger it, DoS reports are out of scope. Rate limiting is a hardening topic, not a vulnerability. - **Social engineering** — anything requiring user interaction to work around correctly-implemented protocol. - **Issues in dependencies** — report upstream (to the dependency's maintainer). Notify us so we can update. - **Self-hosted misconfiguration** — documenting the correct config is preferable to filing an advisory (open a docs issue instead). - **Missing security headers on unauthenticated endpoints** — file as a hardening request. - **Physical access attacks** — treat as out of AuthPlane's control. ## Coordinated disclosure We follow standard coordinated disclosure practice: 1. You report privately. 2. We acknowledge within 48 hours. 3. We work with you to reproduce and patch. 4. We backport the fix to supported release lines. 5. We publish a security advisory with credit to the reporter (unless you request anonymity). 6. We notify high-severity subscribers via the GitHub security-alerts feed on the affected repository. If you plan to publish research, coordinate the disclosure date with us so downstream users can upgrade first. We aim to give at least 7 days after the release before you go public. ## Supported versions Only the latest minor release receives security patches. If you're on a version older than the current stable minor, upgrade first. Track releases at [github.com/authplane/authserver/releases](https://github.com/authplane/authserver/releases). ## Security-relevant design docs For context on what the design is *supposed* to defend against: - [Security: Threat model](/security/threat-model) — 16 named threats + mitigations - [Security: Token design](/security/token-design) — why 15-min JWTs, opaque refresh, mandatory rotation - [Security: DPoP](/security/dpop) — RFC 9449 depth - [Security: Key management](/security/key-management) — signing key lifecycle ## Related - [Security: Threat model](/security/threat-model) - [SECURITY.md in authserver repo](https://github.com/authplane/authserver/blob/main/SECURITY.md) — source of the policy above - Per-SDK `SECURITY.md`: [Python](https://github.com/AuthPlane/python-sdk/blob/main/SECURITY.md) · [TypeScript](https://github.com/AuthPlane/ts-sdk/blob/main/SECURITY.md) · [Go](https://github.com/AuthPlane/go-sdk/blob/main/SECURITY.md) --- ## security/threat-model.mdx --- title: Threat model description: "The 16 threats AuthPlane is designed to resist, with the specific mitigation for each and what to monitor to detect real attacks." section: Security sectionOrder: 8 order: 1 --- # Threat model > **TL;DR** — 16 named threats spanning auth-code interception, token theft, client impersonation, session hijacking, JWKS spoofing, admin API abuse, key compromise, CIMD tampering, vault token theft, unauthorized upstream vending, DPoP replay + algorithm confusion, and token-exchange privilege escalation. Each has a specific mitigation (usually multiple layers) and a metric or log signal to alert on. This is the operator-facing summary; the full mitigation source lives in `authserver/docs/security/threat-model.md`. ## Trust boundaries ```mermaid flowchart TD subgraph Internet Client["MCP Client
(Claude)"] User["User
Browser"] end AS9000["AuthPlane :9000
public OAuth API"] AS9001["AuthPlane :9001
Admin API (internal only)"] DB["Database (SQLite/PG)"] MCP["MCP Server
Protected resource"] Client -->|TLS boundary| AS9000 User -->|TLS boundary| AS9000 AS9000 --> AS9001 AS9001 --> DB ``` Three trust boundaries matter: 1. **Internet → TLS termination** — everything outside is untrusted; TLS terminates before AuthPlane (reverse proxy or LB). 2. **Public → Admin API** — `:9001` must NEVER be reachable from the internet; separate listener specifically to firewall. 3. **AuthPlane → Database** — if the DB is compromised, most bets are off; encrypted upstream refresh grants (`broker_grants`) remain protected by the encryption layer. ## The 16 threats ### T1 · Authorization code interception Attacker grabs the auth code from the browser redirect, races to exchange it. - **Mitigations:** PKCE-S256 (verifier never leaves client), single-use atomic code consumption, 10-min expiry, exact `redirect_uri` matching (no wildcards). - **Signal:** spike in `authserver_auth_denied_total{reason="invalid_pkce"}`. ### T2 · Refresh token theft and replay Attacker steals a refresh token from a leaked log / disk / memory. - **Mitigations:** mandatory rotation, family-based reuse detection — if the same refresh token is used twice, the entire family is revoked immediately. - **Signal:** `rate(authserver_refresh_token_reuse_total[5m]) > 0` — critical alert. Every trigger = confirmed theft or client bug. ### T3 · Client impersonation Attacker crafts a fake OAuth client to trick users. - **Mitigations:** confidential clients require secret verification (bcrypt); DCR `approved_redirects` mode restricts registrations; suspended clients can't issue tokens. - **Signal:** unexpected `authserver_clients_registered_total` growth in `dcr.mode: open` deployments. ### T4 · Session hijacking Attacker steals the user's session cookie. - **Mitigations:** `secure` + `HttpOnly` cookies (production requirement), signed with `session.secret` (32+ bytes required in prod), IP/UA-tracked, expires per `session.max_age`. - **Signal:** unusual `authserver_login_attempts_total{result="stolen_session_reuse"}`. ### T5 · Credential brute force Attacker guesses passwords via `/login`. - **Mitigations:** rate limiter tracks per-IP failures (`rate_limit.auth_fail_max`, default 10 per 10min), lockout for `rate_limit.auth_lockout` (default 15min), bcrypt cost factor makes each guess slow. - **Signal:** `rate(authserver_login_attempts_total{result="failure"}[5m]) > threshold`. ### T6 · Open redirect Attacker crafts a `redirect_uri` that sends the user (and their code) somewhere they control. - **Mitigations:** exact string matching against registered `redirect_uris`; `connect.allowed_return_urls` for the Connect flow. - **Signal:** `authserver_auth_denied_total{reason="redirect_uri_mismatch"}`. ### T7 · JWKS spoofing Attacker MITMs the JWKS fetch to serve a key they control. - **Mitigations:** SDKs use HTTPS by default (SSRF guards + HTTPS-only unless `dev_mode`); operators must front AuthPlane with TLS; JWKS signatures anchor to a static `issuer` claim the SDK matches. - **Signal:** SDK-side `authplane_jwks_fetch_failures_total`; MCP server observability. ### T8 · Admin API abuse Attacker reaches `:9001` and creates rogue clients / rotates keys / exfiltrates data. - **Mitigations:** API key auth (`AUTHPLANE_ADMIN_API_KEY`, 32+ bytes in prod), separate listener (`:9001` not exposed publicly), Kubernetes NetworkPolicy or reverse-proxy IP allowlist, per-action audit log. - **Signal:** `authserver_admin_ops_total` per action; audit log query for unusual patterns. ### T9 · Signing key compromise Attacker steals the current signing key and forges tokens. - **Mitigations:** `keyfile` restricted to authserver user (chmod 600); `postgres_key` encrypts at rest with `data_encryption` driver; `vault_transit` keeps private key server-side (never on AuthPlane host); rotation on suspicion. - **Signal:** unusual `authserver_key_rotation_total` (rotations you didn't trigger) — investigate the process that did. ### T10 · CIMD document tampering Attacker serves a fake CIMD document to trick DCR into registering with attacker-controlled metadata. - **Mitigations:** `cimd.require_https: true` (default); fetched documents cached and revalidated; content-type + JSON validation on fetch. - **Signal:** `authserver_cimd_fetch_duration_seconds` outliers; consider disabling CIMD if not used (`cimd.enabled: false`). ### T11 · Stored vault token theft Attacker with DB access reads upstream refresh grants. - **Mitigations:** all `broker_grants` rows encrypted at rest with AES-256-GCM (`data_encryption: aes_master`) or Vault Transit; encryption key stored via env var, never in DB. - **Signal:** DB access itself is the primary detection — instrument at storage layer. ### T12 · Unauthorized upstream token vending Attacker gets AuthPlane to vend an upstream token for a resource/scope they shouldn't have. - **Mitigations:** three-bound consent model — `consent_grants` (per-agent attestation) + `broker_grants` (per-provider grant) + scope narrowing at exchange time; per-resource `policy.exchange.allowed_client_ids`. - **Signal:** `authserver_upstream_token_issued_total` unusual by (client, provider) combo; audit log filtered by `token_vended`. ### T13 · Connect-flow CSRF Attacker tricks a logged-in user into connecting the attacker's account. - **Mitigations:** HMAC-signed state tokens using `connect.state_secret` (32+ bytes), single-use per Connect; state-bound to session cookie; `allowed_return_urls` guards post-Connect redirect. - **Signal:** `authserver_connect_denied_total{reason="state_invalid"}`. ### T14 · DPoP proof replay Attacker captures a DPoP proof and reuses it. - **Mitigations:** per-proof `jti` stored in `dpop_nonces` table, replay detection at verify time, 60s `proof_lifetime`, optional server-issued nonce (`dpop.require_nonce: true`) narrows the replay window. - **Signal:** `rate(authplane_dpop_proofs_rejected_total{reason="replay"}[5m])`. ### T15 · DPoP algorithm confusion Attacker crafts a proof with `alg: none` or a symmetric algorithm. - **Mitigations:** allowed algorithms whitelisted at boot (`ES256`, `RS256`, `PS256`); `alg: none` and all symmetric algorithms always rejected; private key in `jwk` header always rejected. - **Signal:** `authplane_dpop_proofs_rejected_total{reason="disallowed_alg"}` should always be 0. ### T16 · Token exchange privilege escalation Client A exchanges its token for a token for resource B they shouldn't have access to. - **Mitigations:** per-resource `policy.exchange.allowed_client_ids`, scope narrowing (requested ⊆ subject scope), chain-depth limit (`token_exchange.max_chain_depth`, default 5), audit trail preserves full `act` chain. - **Signal:** `authplane_token_exchange_denied_total` spikes on specific (client, resource) pairs. ## Residual risks (not fully mitigated) - **Physical database access** — encryption at rest is limited to the `broker_grants` table; the rest of the DB is trusted. Compromise = broad blast radius. - **Admin API key leak** — no automatic key rotation for admin key; leaked key = full instance takeover. Manual rotation is fast (`admin.api_key` env var change + restart). - **Compromised MCP server** — AuthPlane can't detect a rogue MCP server logging bearer tokens. DPoP-bound tokens narrow this significantly; without DPoP, MCP-server-side security is your concern. ## Operational hardening checklist - Set `admin.api_key` to 32+ bytes; expose `:9001` internal-only. - Set `session.secret` to 32+ bytes; set `session.secure: true`. - Enable `data_encryption` (required if `broker_providers` set). - Use `vault_transit` signing for multi-instance production; `postgres_key` at minimum. - Wire the audit log to a long-term store (SIEM, S3+Glacier). - Alert on the 6 rules in [Guides: Monitoring → alerts](/guides/monitoring#six-alerts-worth-having). - Rotate signing keys on suspicion or every 90 days. - Restrict CORS (`server.allowed_origins`) to the browser origins that actually need it. ## Related - [Security: Token design](/security/token-design) — why 15-min access tokens, opaque refresh, no ROPC - [Security: DPoP](/security/dpop) — deep dive on RFC 9449 mitigations - [Security: Key management](/security/key-management) — key lifecycle + rotation policy - [Security: Reporting vulnerabilities](/security/reporting-vulnerabilities) - [Guides: Monitoring](/guides/monitoring) — every signal above with concrete PromQL - [Full source in the authserver repo](https://github.com/authplane/authserver/blob/main/docs/security/threat-model.md) — extended narrative per threat --- ## security/token-design.mdx --- title: Token design description: "Five token types AuthPlane issues — access (JWT), refresh (opaque), machine (JWT), exchanged (JWT), auth code (opaque) — with the design reasoning behind each choice." section: Security sectionOrder: 8 order: 2 --- # Token design > **TL;DR** — AuthPlane issues five token types. Access tokens are JWTs (RFC 9068) — 15 min, verified locally against JWKS. Refresh tokens are **opaque strings** — 7 days, revocable per family, mandatory rotation. Machine tokens are JWTs with `sub = client_id`, 1h, no refresh. Exchanged tokens are JWTs with `act`-chain — 15 min or configurable. Auth codes are opaque, single-use, 10 min. Every design choice was picked over an alternative for a specific security reason; this page walks through each. ## The five token types at a glance | Token | Format | Lifetime | Purpose | |---|---|---|---| | **Access token** | JWT (`at+jwt`) | 15 min | MCP server authorization | | **Refresh token** | Opaque (random string) | 7 days | Renew access tokens | | **Machine token** | JWT (`at+jwt`) | 1 hour | Service-to-service (no user) | | **Exchanged token** | JWT (`at+jwt`) | 15 min – 1 hour | Delegation with `act` chain | | **Auth code** | Opaque (random string) | 10 min | One-time code → tokens | ## Access tokens (JWT, RFC 9068) The primary credential your MCP server sees. **Header:** ```json { "typ": "at+jwt", "alg": "ES256", "kid": "key-2026-02" } ``` **Payload:** ```json { "iss": "http://localhost:9000", "sub": "user-uuid-v7", "aud": ["http://mcp-server:3000/mcp"], "exp": 1708762500, "iat": 1708761600, "nbf": 1708761600, "jti": "token-uuid-v7", "client_id": "client-uuid-v7", "scope": "tools/echo tools/query_database" } ``` DPoP-bound tokens add `cnf.jkt` (base64url thumbprint of the client's public key). AuthPlane extensions add `agent_id`, `agent_chain`, `act` when applicable. **How your MCP server verifies:** 1. Fetch AuthPlane's JWKS (cache; refresh every 5 min). 2. Verify signature with the key matching `kid`. 3. Check `iss` = your configured AuthPlane issuer. 4. Check `aud` contains your resource URI. 5. Check `exp` > now, `nbf` ≤ now (with `clock_skew_seconds` leeway, default 30 s). 6. Check `scope` contains what your tool requires. Every AuthPlane SDK does all this in one call. See [SDKs overview](/sdks/overview). ### Why 15 minutes? Short lifetime = small blast radius if leaked. Long enough that most requests use a valid access token (no refresh dance per call). Refresh is fast — 1 network hop, no user interaction. Bumping to 1h+ starts to matter for security posture on multi-hop deployments where a leaked token means an hour of unauthorized access. ## Refresh tokens (opaque) Opaque random strings, NOT JWTs. Stored server-side. ### Why opaque instead of JWT? - **Revocable per-family** — refresh tokens are grouped into "families" (originally, this issuance and its rotations). Detected theft revokes the entire family in one DB update. - **No self-decoding** — a leaked opaque handle reveals nothing about the user; a leaked JWT reveals every claim. - **Server-side state** — each rotation writes a new row and revokes the old one atomically. ### Mandatory rotation (RFC 9700 §4.14) Every `refresh_token` grant returns a *new* refresh token and invalidates the presented one. **If the same refresh token is used twice, the entire family is revoked** — every access token and refresh token derived from it is invalidated immediately. This is why `authserver_refresh_token_reuse_total > 0` is a critical alert — every trigger means either theft or a client bug that will lock the user out. ### Storage - Access token: not stored (stateless JWT). - Refresh token: stored in `refresh_tokens` table with family FK; `broker_grants` table for upstream refresh grants (encrypted). - Auth code: stored in `auth_sessions` with `consumed_at` for atomic single-use. ## Machine tokens (client credentials) Same JWT format as access tokens, with two differences: - `sub` = the `client_id` (not a user UUID) — tells MCP servers "this is machine identity". - No refresh token — machines re-authenticate every hour by re-presenting their client secret. ### Why no refresh token for machines? - Machines can re-authenticate instantly (they hold the secret). - Smaller blast radius on token leak (max 1h). - Simpler rotation (no family tracking). - Forced re-auth every hour surfaces secret rotation immediately. Machine tokens support DPoP the same way user tokens do — send a `DPoP` header on `/oauth/token` to get `token_type: DPoP` and `cnf.jkt`. ## DPoP-bound tokens Any of the above token types can be DPoP-bound (RFC 9449). Only what changes: - `token_type: "DPoP"` instead of `"Bearer"` - `cnf` claim added: `{"jkt": ""}` - `Authorization: DPoP ` scheme on requests (not `Bearer`) - Client must send a fresh DPoP proof per request Algorithm restrictions (always): - `ES256`, `RS256`, `PS256` — accepted - `HS256`, `HS384`, `HS512` — always rejected (symmetric — verifier would need signing key) - `alg: none` — always rejected (unsigned proofs trivially forgeable) - Private key in `jwk` header — always rejected (would leak private key) Deep dive: [Security: DPoP](/security/dpop). ## Exchanged tokens (RFC 8693) Same JWT format, plus an `act` claim describing the acting party: ```json { "sub": "user-42", // preserved from subject "aud": "https://downstream.example.com", "scope": "tools/read", "act": { "sub": "agent-A", // who's acting on user's behalf "actor_type": "agent" } } ``` Chained exchanges (A → B → C) nest: `act.act` = previous actor. Full delegation chain reconstructable from the token alone; RFC 8693 §4.1 ¶6 says only the outermost actor is authoritative for access-control decisions. `act.act.sub` inner values are only as trustworthy as the issuer of the original subject token — AuthPlane only accepts its own tokens on the exchange path today, so every inner-hop value was stamped by AuthPlane on a prior exchange. Deep dive: [Concepts: Delegation & act-chain](/concepts/delegation-act-chain). ## Auth codes (opaque) Opaque, 10-minute lifetime, atomic single-use (`UPDATE ... WHERE consumed_at IS NULL RETURNING`). Bound to: - `code_challenge` (PKCE-S256) — verifier presented at `/oauth/token` must hash to this - `client_id` and `redirect_uri` — exact match required - `resource` — flows to `aud` on the eventual JWT Attempted reuse triggers `invalid_grant "authorization code has already been used"` and revokes the token family issued from the successful exchange. ## `jti` semantics Every JWT-format token has a `jti` (UUID v7 — monotonic sortable). Uses: - **Correlation** — grep logs by `jti` to trace one token end-to-end. - **Revocation** — `POST /oauth/revoke` marks the `jti` revoked (RFC 7009). - **Introspection** — `POST /oauth/introspect` returns `active: false` after revocation (SDKs can enable this via `revocation_checker`). - **DPoP proof replay detection** — separate `jti` on the DPoP proof itself, stored in `dpop_nonces`. ## Why no ROPC (Resource Owner Password Credentials) OAuth 2.1 §1.3 defines only authorization-code, refresh, and client-credentials — ROPC is **removed** from the spec, not merely deprecated. It teaches users to hand their password to random clients, breaks 2FA, and provides no delegation semantics. AuthPlane doesn't implement it. If you need machine auth, use `client_credentials`. If you need federated login, use `oidc.enabled` or XAA. ## Why no Implicit grant Same story — OAuth 2.1 §10.1 **removes** implicit ("omitted from OAuth 2.1"), it isn't just deprecated. Auth code + PKCE covers every use case implicit grant addressed, with better security. ## Related - [Concepts: Grants & flows](/concepts/grants-and-flows) — how each token type ties to a grant - [Security: DPoP](/security/dpop) — proof-of-possession deep dive - [Security: Key management](/security/key-management) — how the JWT signing key lifecycle works - [Reference: RFC compliance](/reference/rfc-compliance) — every RFC referenced above with coverage - [Full source in the authserver repo](https://github.com/authplane/authserver/blob/main/docs/security/token-design.md) --- ## topologies/broker-mcp.mdx --- title: Agent + brokered MCP description: "MCP wraps an upstream OAuth provider (Google, GitHub, Slack) — AuthPlane vends upstream tokens per request via RFC 8693 token exchange. Three-bound consent." section: Deployment topologies sectionOrder: 5 order: 3 --- # Agent + brokered MCP > **At a glance.** The MCP wraps an upstream OAuth provider (Google, GitHub, Linear). AuthPlane brokers tokens from the upstream IdP per request. The agent never sees the upstream credentials; the upstream API never sees AuthPlane. Per-user audit. Three-bound consent model. ## Topology ```mermaid flowchart TB User["User"] AS["AuthPlane"] IdP["Upstream IdP
(Google)"] Agent["Agent"] API["Upstream API
(Google Cal)"] Broker["Broker MCP
slug:
google-cal"] User <-->|consent + connect| AS AS <-->|OAuth refresh/vend| IdP AS --> Agent IdP --> API Agent -->|"upstream bearer
(vended by AS)"| API ``` **Broker MCP** — a Resource with `backend_kind: broker`, referencing a `broker_provider` row (the upstream OAuth app). **Upstream IdP** — signs the bearer the upstream API accepts. **AuthPlane** — orchestrates two consents (user→agent at AS, user→upstream at IdP) and vends a fresh upstream bearer per request. ## Flow — first time (upstream connect needed) ```mermaid sequenceDiagram participant Agent participant AS as AuthPlane participant User participant IdP as Upstream IdP participant MCP as Broker MCP participant API as Upstream API Agent->>AS: /oauth/authorize?resource=google-cal&scope=… AS-->>User: consent screen (Agent × google-cal) User->>AS: approve AS-->>Agent: 302 + code Agent->>AS: /oauth/token grant_type=authorization_code AS->>AS: AS notes: no broker_grant for (user, google) yet AS-->>Agent: 400 consent_required, consent_url=/connect/google Agent->>User: surface the connect URL User->>AS: GET /connect/google?return_url=… AS->>IdP: redirect to upstream OAuth authorize User->>IdP: consent IdP->>AS: callback with code → upstream tokens AS->>AS: write broker_grants row (encrypted refresh) AS->>User: 302 to return_url User->>Agent: retry Agent->>AS: /oauth/token (retry, now succeeds) AS->>IdP: refresh/vend with stored upstream credential AS-->>Agent: upstream bearer (narrowed scopes) Agent->>MCP: call with upstream bearer MCP->>API: forward upstream bearer ``` ## Flow — subsequent calls ```mermaid sequenceDiagram participant Agent participant AS as AuthPlane participant IdP as Upstream IdP participant MCP as Broker MCP participant API as Upstream API Agent->>AS: /oauth/token (token-exchange or refresh) AS->>IdP: vend per-request (broker_grants hit) AS-->>Agent: fresh upstream bearer Agent->>MCP: call with upstream bearer MCP->>API: forward ``` **The upstream bearer is never cached in AuthPlane** — every `/oauth/token` hits the upstream IdP for a fresh vend. ## When to use - MCP is a thin proxy in front of a third-party OAuth-protected API. - User's upstream credentials should be managed centrally by AuthPlane (not scattered across MCPs). - Upstream API expects its own bearer on the wire. **Don't use when:** - MCP has its own scopes and issues its own tokens → use [single-mcp](/topologies/single-mcp). - You want a gateway to sit in front → [mcp-gateway-broker](/topologies/mcp-gateway-broker). - Multiple agents share one broker MCP through a gateway → mcp-gateway-broker + fronting-link. ## How to configure **Full walkthrough** in [Guides: Wire up the Token Vault](/guides/token-vault). Summary: **YAML seed:** ```yaml broker_providers: - slug: google display_name: Google protocol: oauth config_data: client_id: "your-google-oauth-client-id" client_secret_ref: CONNECTOR_GOOGLE_SECRET authorize_url: https://accounts.google.com/o/oauth2/v2/auth token_url: https://oauth2.googleapis.com/token extra_auth_params: { access_type: offline, prompt: consent } # both needed for Google to return a refresh_token resources: - slug: google-cal display_name: Google Calendar backend_kind: broker broker_provider_slug: google scopes: - name: calendar:read description: Read Google Calendar events upstream: https://www.googleapis.com/auth/calendar.readonly ``` Data encryption (`data_encryption.driver: aes_master` or `vault_transit_encrypt`) is **required** when broker_providers are configured — boot fails otherwise. ## How AuthPlane handles it - **Connect flow** — user browses to `/connect/google`, AuthPlane runs the upstream OAuth handshake, callback writes an encrypted `broker_grants` row. - **Exchange dispatch** — `BrokerIssuer` runs the three-bound consent gates (see below), then dispatches to the `brokerproto` adapter matching the provider's protocol (`oauth`, `api_key`, `service_account`). - **Refresh** — automatic when the upstream access token has expired. Concurrent vends are serialized via optimistic locking (`423 Locked` on race — SDKs retry). **Three-bound consent** — every vend runs through: ``` requested_scopes ⊆ consent_grants.scopes (per-agent attestation) ⊆ broker_grants.scopes_granted (per-provider grant) ``` Failure returns `consent_required` with the right `consent_url` (either AS-side re-consent or `/connect/{provider}`). SDKs auto-translate to MCP `-32042` elicitation. ## Verify ```bash # Confirm the connection was recorded authserver admin grant list-user-grants --user user-42 # → broker_grants (1): user-42, google, scopes_granted=[...] # Confirm token-exchange works curl -X POST http://localhost:9000/oauth/token \ -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ -d "subject_token=$USER_TOKEN" \ -d "resource=google-cal" \ -d "scope=https://www.googleapis.com/auth/calendar.readonly" \ -d "client_id=$MCP_CLIENT_ID" \ -d "client_secret=$MCP_CLIENT_SECRET" # → { "access_token": "ya29...", "token_type": "Bearer", "expires_in": 3599 } ``` ## See also - [Guides: Wire up the Token Vault](/guides/token-vault) — full end-to-end - [Guides: Upstream connections](/guides/upstream-connections) — per-protocol broker provider setup - [Concepts: Token Vault](/concepts/token-vault) — mental model - [Topologies: MCP gateway → broker](/topologies/mcp-gateway-broker) — fronting variant - [Reference: Errors → Broker/Vault](/reference/errors#broker--vault) --- ## topologies/client-credentials-hop.mdx --- title: Client-credentials hop description: "Gateway calls hidden infra as ITSELF using client_credentials on the second hop — deliberately drops user context. Use when downstream doesn't need user identity." section: Deployment topologies sectionOrder: 5 order: 6 --- # Client-credentials hop > **At a glance.** A gateway sits between the user's agent and hidden downstream infrastructure. The first hop is user-authenticated. The second hop is the gateway acting as **itself** via client_credentials — deliberately dropping user context. Use when the downstream service doesn't need to know which user triggered the call. ## Topology ```mermaid flowchart LR Agent["Agent"] Gateway["Gateway
(MCP, M2M client)"] Backend["Hidden backend
(Mint)"] AS["AuthPlane"] Agent -->|"Bearer
aud=gw
(user)"| Gateway Gateway -->|"Bearer
aud=backend
(gateway as itself)"| Backend Gateway -->|JWKS + PRM| AS Backend -->|JWKS + PRM| AS ``` Two Resources: **gateway** (agents authenticate against this) + **hidden backend** (only the gateway reaches this). Gateway is BOTH a Resource AND an OAuth client with `client_credentials`. ## Flow ```mermaid sequenceDiagram participant Agent participant Gateway participant AS as AuthPlane participant Backend Agent->>AS: user auth-code flow → token with aud=gateway Agent->>Gateway: POST /mcp Authorization: Bearer Gateway->>Gateway: validates the user token (JWKS cached) Gateway->>AS: POST /oauth/token
grant_type=client_credentials
client_id=$GATEWAY_ID
client_secret=$GATEWAY_SECRET
resource=hidden-backend
scope=internal/write AS-->>Gateway: machine token (aud=hidden-backend, sub=gateway_id) Gateway->>Backend: POST /internal Authorization: Bearer Backend->>Backend: executes as authenticated caller = the gateway Gateway-->>Agent: response ``` **User context is intentionally lost on step 4** — the downstream `sub` claim is the gateway's client_id, not the original user. If the backend needs the user identity, use [mcp-gateway-mint](/topologies/mcp-gateway-mint) with token exchange instead. ## When to use - Downstream infrastructure only cares "is this call from the gateway", not which user triggered it. - Gateway is a trusted boundary and hides the user identity from downstream by design. - Downstream systems can't handle per-user tokens (legacy internal APIs, aggregators). - Compliance says user identity shouldn't traverse the internal boundary. **Don't use when:** - Downstream needs the user identity (for per-user auth, quotas, or audit) → [mcp-gateway-mint](/topologies/mcp-gateway-mint) with token exchange preserves `sub`. - Downstream is a Broker resource vending upstream tokens → [mcp-gateway-broker](/topologies/mcp-gateway-broker). - You want the second hop audited per-user in AuthPlane → mcp-gateway-mint. ## How to configure **CLI:** ```bash # 1. Enable client_credentials at boot export AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true # 2. Register the hidden backend as a Mint Resource authserver admin resource create \ --slug hidden-backend \ --uri https://backend.internal/api \ --backend-kind mint \ --scopes 'internal/write||Internal write' # 3. Register the gateway (Resource AND confidential client) authserver admin resource create \ --slug gateway \ --uri https://gateway.example.com/mcp \ --backend-kind mint \ --scopes 'tools/read||Read tools' \ --scopes 'tools/write||Write tools' authserver admin client create \ --name gateway \ --grant-types authorization_code,refresh_token,client_credentials \ --auth-method client_secret_post \ --scopes 'tools/read||Read tools' \ --scopes 'tools/write||Write tools' \ --scopes 'internal/write||Internal write' ``` The gateway client has BOTH grant types because it acts as an OAuth server for agents (`authorization_code`) AND as a client against the hidden backend (`client_credentials`). ## How AuthPlane handles it Two independent token flows share no state: - User flow: standard authorization_code → `consent_grants` row for (user, agent, gateway) → JWT with `sub=user`, `aud=gateway`. - Machine flow: `client_credentials` → no consent, no user → JWT with `sub=gateway_id`, `aud=hidden-backend`. The backend audit shows the gateway as the caller. To attribute a specific request to a user, correlate via `trace_id` (OTEL) or `request_id` — both hops carry them. ## Verify ```bash # Two separate issuances per agent tool call — user token and machine token authserver admin issuance list --client gateway --limit 5 # → one with sub=user-42 aud=gateway (from step 1) # → one with sub=gateway aud=hidden-backend (from step 4-5) # Decode both — user identity is on the first, gateway identity is on the second ``` ## See also - [Topologies: MCP gateway → hidden Mint](/topologies/mcp-gateway-mint) — the alternative that PRESERVES user identity via token exchange - [Concepts: Grants & flows → Client credentials](/concepts/grants-and-flows#client-credentials) - [Topologies: Backend service + MCP](/topologies/m2m-client-credentials) — pure M2M without a preceding user hop --- ## topologies/direct-fanout.mdx --- title: Agent + multiple MCPs (Direct fanout) description: "One agent, N MCPs, one user — per-MCP consent, per-MCP audience-bound tokens. Simplest multi-resource shape." section: Deployment topologies sectionOrder: 5 order: 2 --- # Agent + multiple MCPs (Direct fanout) > **At a glance.** One agent talks to N MCPs directly. The user consents at each MCP separately. Each token's `aud` is bound to its target MCP — cross-MCP token replay is rejected at audience validation. No infra between agent and MCPs. Simplest multi-resource shape. ## Topology ```mermaid flowchart TD AS["AuthPlane"] MCPA["MCP A
read"] MCPB["MCP B
query"] MCPC["MCP C
write"] Agent["Agent"] AS --> MCPA AS --> MCPB AS --> MCPC Agent -->|Bearer per-MCP| MCPA Agent -->|Bearer per-MCP| MCPB Agent -->|Bearer per-MCP| MCPC ``` Each of MCP A / B / C is registered as its own Mint Resource with its own scope catalog. The agent is one OAuth client that gets a separate audience-bound token per MCP. ## Flow Per-MCP flow is identical to [single-mcp](/topologies/single-mcp). The user gets N consent screens on first use — one per MCP resource. ```mermaid sequenceDiagram participant Agent participant MCP participant AS as AuthPlane participant User loop For each MCP in {A, B, C} Agent->>MCP: GET /.well-known/oauth-protected-resource MCP-->>Agent: PRM Agent->>AS: /oauth/authorize?resource=mcp-X&scope=… AS-->>User: consent screen for THIS mcp-X User->>AS: approve AS-->>Agent: 302 + code Agent->>AS: /oauth/token AS-->>Agent: token with aud=mcp-X Agent->>MCP: call with Bearer(aud=mcp-X) end ``` **Token from MCP A replayed to MCP B is rejected** — `aud` claim doesn't match MCP B's resource URI. This is the audience-binding guarantee of [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707). ## When to use - Multiple independent MCP servers with distinct scope catalogs. - User accepts per-MCP consent (the default; usually fine). - No gateway, no infrastructure between agent and MCPs. **Don't use when:** - User wants **one** consent screen for all MCPs → wait for RFC 8707 multi-resource authorization (on the roadmap), or use [mcp-gateway-mint](/topologies/mcp-gateway-mint) with one consented gateway. - MCPs need to reach each other with an agent-attributed token → [mcp-gateway-mint](/topologies/mcp-gateway-mint). - Any MCP wraps an upstream IdP → mix in [broker-mcp](/topologies/broker-mcp) for that one. ## How to configure **CLI:** ```bash # 1. Register every MCP authserver admin resource create --slug mcp-a --backend-kind mint --uri https://mcp-a.example.com --scopes 'read||Read access' authserver admin resource create --slug mcp-b --backend-kind mint --uri https://mcp-b.example.com --scopes 'query||Query access' authserver admin resource create --slug mcp-c --backend-kind mint --uri https://mcp-c.example.com --scopes 'write||Write access' # 2. Register the agent (one client across all MCPs) authserver admin client create \ --name my-agent \ --grant-types authorization_code,refresh_token \ --auth-method none \ --scopes 'read||Read access' \ --scopes 'query||Query access' \ --scopes 'write||Write access' ``` **YAML seed:** ```yaml resources: - { slug: mcp-a, backend_kind: mint, uri: https://mcp-a.example.com, scopes: [{name: read}] } - { slug: mcp-b, backend_kind: mint, uri: https://mcp-b.example.com, scopes: [{name: query}] } - { slug: mcp-c, backend_kind: mint, uri: https://mcp-c.example.com, scopes: [{name: write}] } ``` ## How AuthPlane handles it Each MCP is a separate resource with its own audit trail. `consent_grants` has one row per (user, agent, mcp-X). One agent client, three separate consent states — revoking consent for MCP-A doesn't affect MCP-B or MCP-C. Every token is minted with the exact URI of its target as `aud`. The SDK on each MCP verifies `aud` matches its own URI — cross-MCP replay fails audience validation without any coordination between MCPs. ## Verify ```bash # List issuances per resource authserver admin issuance list --resource mcp-a --client my-agent # Grants per user — should show 3 rows if all three MCPs have been consented authserver admin grant list-user-grants --user user-42 ``` ## See also - [Choose your topology](/choose-your-topology) - [Concepts: Resource servers & PRM](/concepts/resource-servers-prm) — audience binding under the hood - [Topologies: MCP gateway → hidden Mint](/topologies/mcp-gateway-mint) — the alternative when you want one consent for many MCPs - [Reference: RFC 8707](/reference/rfc-compliance#rfc-8707--resource-indicators-for-oauth-20) — resource-indicator semantics --- ## topologies/enterprise-xaa.mdx --- title: Enterprise-Asserted Agent Identity (XAA) description: "Corporate IdP signs a JWT asserting the agent's identity. AuthPlane accepts it via jwt-bearer grant (RFC 7523) and mints an MCP token — no per-user consent screen." section: Deployment topologies sectionOrder: 5 order: 10 --- # Enterprise-Asserted Agent Identity (XAA) > **At a glance.** Your corporate IdP (Okta, Entra ID, Auth0) signs an "ID-JAG" JWT asserting *the user* the agent is acting for; the agent is the OAuth client presenting the assertion. AuthPlane validates it against the IdP's JWKS, evaluates policy (IdP × client × scope × resource), resolves the assertion's `sub` to a local user via subject mapping, and mints an MCP token with `sub=user` and `act=agent`. **No per-user consent screen.** For enterprise fleets where the IdP is the authority on which humans (and, transitively, which agents) may access which resources. ## Topology ```mermaid flowchart LR IdP["Enterprise IdP
(Okta, Entra)"] Agent["MCP Agent"] AS["AuthPlane"] MCP["MCP Server"] IdP -->|"signs ID-JAG"| Agent Agent -->|"jwt-bearer assert."| AS AS -->|mints MCP token| MCP ``` The enterprise IdP is the source of truth for **agent identity** — separate from any user login (which may not exist at all in a headless deployment). AuthPlane validates the assertion, applies policy, and issues the MCP token. ## Flow ```mermaid sequenceDiagram participant Agent as MCP Agent participant IdP as Enterprise IdP participant AS as AuthPlane participant MCP as MCP Server Agent->>IdP: requests an ID-JAG for a given end-user IdP-->>Agent: signs a JWT with header {typ: "oauth-id-jag+jwt"}, includes:
iss:
aud:
sub:
exp, iat, jti
(The agent identity is NOT in the assertion — the agent authenticates
to AuthPlane as an OAuth client, with its own client_id/secret.) Agent->>AS: POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=
client_id=$CLIENT_ID ← this is the AGENT
client_secret=$CLIENT_SECRET
resource=https://mcp.example.com/mcp
scope=tools/read AS->>AS: validates:
Assertion signature against registered IdP's JWKS (cached)
iss matches a trusted IdP
aud matches the IdP's registered audience
exp within max_assertion_age (default 5m)
jti not previously used (replay guard) AS->>AS: runs policy engine:
(idp × client × scope × resource) tuple must match at least one policy
Deny by default AS->>AS: resolves subject:
auto_map: local user is "{iss}:{sub}"
strict: local user comes from an explicit xaa_subject_mappings row (else deny) AS->>AS: mints MCP access token:
sub = ← the human, not the agent
act = { sub: } ← the agent is the actor
scope = intersection(requested, policy.scopes)
aud = requested resource AS-->>Agent: { access_token, token_type=Bearer, expires_in=3600 } Agent->>MCP: standard Bearer request ``` DPoP works normally — send a `DPoP` header on step 3 to get a DPoP-bound token. ## When to use - Fleet of enterprise agents, central policy control needed. - Headless environments (CI runners, cron jobs, backend services) where browser consent isn't possible. - You want the corporate IdP to be the authority on which humans (and their agents) can access which resources — not AuthPlane's consent UI. - Interactive per-user consent screens don't fit the flow (the human already consented at the IdP). **Don't use when:** - Interactive users can consent in a browser → [oidc-federated-login](/topologies/oidc-federated-login) is simpler. - No enterprise IdP → use standard [single-mcp](/topologies/single-mcp) or [m2m-client-credentials](/topologies/m2m-client-credentials). - You want AuthPlane consent screens as an audit trail → XAA replaces those with policy audit rows. ## How to configure **Full step-by-step** in [Guides: Enterprise-Managed Auth](/guides/xaa). Summary: **Enable XAA:** ```yaml xaa: enabled: true token_expiry: 1h max_assertion_age: 5m subject_mode: auto_map # or "strict" jwks_cache_ttl: 1h ``` XAA config is YAML-only at v0.1.x — no env-var overrides. **Register the trusted IdP** (no CLI subcommand; use the admin REST API): ```bash curl -s -X POST http://localhost:9001/admin/idps \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp Okta", "issuer": "https://acme.okta.com", "jwks_uri": "https://acme.okta.com/.well-known/jwks.json", "audience": "https://auth.example.com" }' ``` **Create a policy** (deny by default; at least one match needed — no CLI subcommand; use the admin REST API): ```bash curl -s -X POST http://localhost:9001/admin/xaa/policies \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "idp_id": "idp_abc123", "client_ids": ["my-mcp-client"], "scopes": ["tools/echo", "tools/search"], "resources": ["https://mcp.example.com/mcp"] }' ``` **Register the MCP client with `jwt-bearer` grant** (must be confidential): ```bash authserver admin client create \ --name enterprise-agent \ --grant-types urn:ietf:params:oauth:grant-type:jwt-bearer \ --auth-method client_secret_post \ --scopes 'tools/echo||Echo tool' \ --scopes 'tools/search||Search tool' ``` ## How AuthPlane handles it - `JWTBearerService.Exchange` validates the assertion (`XAAIDPService` handles JWKS lookup + signature verification; `AssertionJTIStore` guards replay). - Policy engine (`XAAPolicyService`) evaluates all policies for the assertion's IdP; ANY match allows, none matches = `access_denied`. - Subject mapping — `auto_map` uses `{iss}:{sub}` as the token's `sub`; `strict` looks up an explicit `subject_mapping` row and refuses if absent. - `MintIssuer` signs the token; `machine_token_store` records it for revocation + introspection. - Metrics: `authplane_xaa_policy_evaluation_total{decision}`, `authplane_xaa_idp_operations_total`, `authplane_xaa_subject_resolutions_total`. ## Testing without a real corporate IdP Use [xaa.dev](https://xaa.dev) — public XAA playground built on Okta. Two-minute setup with ngrok. Full walkthrough at [`authserver/docs/how-to/test-xaa-with-xaa-dev.md`](https://github.com/authplane/authserver/blob/main/docs/how-to/test-xaa-with-xaa-dev.md). ## Verify ```bash # Trusted IdP registered (no CLI subcommand; query the admin REST API) curl -s http://localhost:9001/admin/idps \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # At least one policy exists curl -s http://localhost:9001/admin/xaa/policies \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" # Metric proves policy evaluation is happening curl -s http://localhost:9001/metrics | grep authplane_xaa_policy_evaluation_total # → authplane_xaa_policy_evaluation_total{decision="allow"} # authplane_xaa_policy_evaluation_total{decision="deny"} ``` ## See also - [Guides: Enterprise-Managed Auth](/guides/xaa) — end-to-end walkthrough - [Concepts: Cross-App Access (XAA)](/concepts/xaa) — mental model - [Concepts: Grants & flows → JWT Bearer](/concepts/grants-and-flows#jwt-bearer--cross-app-access-xaa) - [Test XAA with xaa.dev (authserver repo)](https://github.com/authplane/authserver/blob/main/docs/how-to/test-xaa-with-xaa-dev.md) - [Reference: Configuration → xaa](/reference/configuration#xaa-and-jwt-bearer--yaml-only-at-v01x) --- ## topologies/folded-resource.mdx --- title: Folded resource (internal services behind one MCP) description: "Multiple internal services hidden behind a single MCP boundary. AuthPlane sees one Resource; the MCP fans out privately using its own service-mesh auth." section: Deployment topologies sectionOrder: 5 order: 5 --- # Folded resource > **At a glance.** Multiple internal services live behind one MCP boundary. AuthPlane sees one Resource (`mcp-a`). The MCP internally calls other services using its own mesh auth (mTLS, service tokens, whatever) — AuthPlane doesn't participate in the internal hop. Simplest way to compose internal microservices behind an MCP without exposing each as a separate resource. ## Topology ```mermaid flowchart TD Agent["Agent"] MCP["MCP-A
(visible to AuthPlane)"] Svc1["Svc1
(int.)"] Svc2["Svc2
(..)"] Svc3["Svc3
(..)"] Agent -->|"Bearer
aud=mcp-a"| MCP MCP -->|"mesh auth
(mTLS, SA token, etc.)
invisible to AuthPlane"| Svc1 MCP -->|"mesh auth
(mTLS, SA token, etc.)
invisible to AuthPlane"| Svc2 MCP -->|"mesh auth
(mTLS, SA token, etc.)
invisible to AuthPlane"| Svc3 ``` Only MCP-A is a Resource. Svc1/2/3 are AuthPlane-invisible — their mesh auth is your own concern (Istio, Linkerd, SPIFFE, whatever your infra provides). ## Flow ```mermaid sequenceDiagram participant Agent participant MCP as MCP-A participant Svc1 participant Svc2 participant Svc3 Agent->>MCP: POST /mcp Authorization: Bearer MCP->>MCP: validates the token against AuthPlane's JWKS (cached) MCP->>MCP: tool handler decides internally to fan out MCP->>Svc1: mesh call (mTLS SAN=svc1, no AuthPlane involvement) MCP->>Svc2: mesh call MCP->>Svc3: mesh call MCP-->>Agent: assembles response, returns to Agent ``` Steps 3a-3c are outside AuthPlane's model — the internal hop is your service mesh's concern. ## When to use - Backend services are internal implementation details of one MCP. - You don't want per-service consent / per-service audience — the boundary is the MCP. - You have a service mesh with strong internal auth already. - Fan-out scopes are decided by tool-handler logic, not by user consent. **Don't use when:** - Users need visibility/consent into individual downstream services → each service is its own Resource, use [direct-fanout](/topologies/direct-fanout). - The internal hop wants an agent-attributed audit trail → use [mcp-gateway-mint](/topologies/mcp-gateway-mint) instead — the gateway becomes an OAuth client that hits real Mint resources with delegated tokens. ## How to configure Only the outer MCP needs registration — same as [single-mcp](/topologies/single-mcp): ```bash authserver admin resource create \ --slug mcp-a \ --uri https://mcp-a.example.com/mcp \ --backend-kind mint \ --scopes 'tools/query||Query tools' \ --scopes 'tools/write||Write tools' authserver admin client create \ --name my-agent \ --grant-types authorization_code,refresh_token \ --auth-method none \ --scopes 'tools/query||Query tools' \ --scopes 'tools/write||Write tools' ``` Internal services get no AuthPlane config. Register them with your mesh instead. ## How AuthPlane handles it Nothing special. The topology is `single-mcp` from AuthPlane's perspective. The "folded" part is entirely internal to MCP-A's process — an implementation detail of the tool handler. If you later want to expose an internal service as its own Resource (per-service consent, per-service audit), promote it out of the fold and register it individually. ## Verify ```bash # Only one resource visible to AuthPlane authserver admin resource list # → mcp-a (Svc1/2/3 do NOT appear) # Only one issuance per (agent, user) authserver admin issuance list --client my-agent --resource mcp-a ``` Internal hop is not audited by AuthPlane — instrument it with your mesh's observability (OTEL traces from MCP-A carry through to Svc1/2/3 with `traceparent`). ## See also - [Choose your topology](/choose-your-topology) - [Topologies: MCP gateway → hidden Mint](/topologies/mcp-gateway-mint) — the alternative when each internal call should be AuthPlane-audited - [Concepts: Architecture](/concepts/architecture#unified-resource-model) — the unified Resource model --- ## topologies/m2m-client-credentials.mdx --- title: Backend service + MCP (no user) description: "Machine-to-machine — a backend worker, CI pipeline, or automated agent calls an MCP as itself. Client credentials grant, no user, no consent screen." section: Deployment topologies sectionOrder: 5 order: 4 --- # Backend service + MCP (no user) > **At a glance.** No human, no browser, no consent screen. A backend service authenticates with its own client_id + client_secret and gets a machine token. Standard OAuth 2.1 client_credentials grant (RFC 6749 §4.4). Same audience binding as the user flow — token is bound to the target MCP's resource URI. ## Topology ```mermaid flowchart LR Backend["Backend service
(worker, CI, bot)"] AS["AuthPlane"] MCP["MCP Server"] Backend -->|M2M auth| AS AS -->|JWKS + PRM| MCP Backend -->|"Bearer(aud=mcp)"| MCP ``` Two components on the AS side: **backend service** — a confidential OAuth client with `client_credentials` in its grant types. **MCP server** — the Mint Resource. No user. No consent grants stored. ## Flow ```mermaid sequenceDiagram participant Backend participant AS as AuthPlane participant MCP as MCP Server Backend->>AS: POST /oauth/token
grant_type=client_credentials
client_id=$BACKEND_CLIENT_ID
client_secret=$BACKEND_SECRET
scope=tools/query
resource=https://mcp.example.com/mcp AS-->>Backend: { access_token, token_type=Bearer, expires_in=3600 } Backend->>MCP: POST /mcp Authorization: Bearer MCP->>AS: GET /.well-known/jwks.json (cached) AS-->>MCP: JWKS MCP-->>Backend: response ``` No refresh token — machines re-authenticate every hour (or whatever `client_credentials.token_expiry` is). **Token claim shape:** `sub` = the backend's `client_id` (not a user), `client_id` = same, `aud` = target resource URI. ## When to use - A **CI/CD pipeline** calling MCP tools to trigger builds, run analysis, deploy. - A **backend worker** processing a queue, calling MCP tools for summarization / DB queries / notifications. - An **automated agent** with no human in the loop. - A **monitoring service** calling health-check tools. **Don't use when:** - A user is in the loop and should authorize → [single-mcp](/topologies/single-mcp) with authorization_code. - The service needs to act *on behalf of a user* (use the user's GitHub token) → [broker-mcp](/topologies/broker-mcp) with token exchange, or the service-account-user pattern (see [Concepts: Grants & flows](/concepts/grants-and-flows#client-credentials)). ## How to configure **Enable the grant** (disabled by default): ```yaml client_credentials: enabled: true token_expiry: 1h ``` Or `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true`. **Register the resource** (same as single-mcp): ```bash authserver admin resource create \ --slug my-mcp \ --uri https://mcp.example.com/mcp \ --backend-kind mint \ --scopes 'tools/query||Query tools' ``` **Register the backend as a confidential client:** ```bash authserver admin client create \ --name backend-worker \ --grant-types client_credentials \ --auth-method client_secret_post \ --scopes 'tools/query||Query tools' # → save client_id and client_secret; secret is bcrypt-hashed after this call ``` **Request a token from the backend:** ```bash curl -X POST http://localhost:9000/oauth/token \ -d "grant_type=client_credentials" \ -d "client_id=$CLIENT_ID" \ -d "client_secret=$CLIENT_SECRET" \ -d "scope=tools/query" \ -d "resource=https://mcp.example.com/mcp" ``` ## How AuthPlane handles it - `TokenService.ClientCredentials` verifies `client_id` + `client_secret` (bcrypt compare against stored hash). - Scope resolution: if request includes `scope`, token gets `intersection(requested, client.registered_scopes)`; if request omits `scope`, token gets all client's registered scopes. - No `consent_grants` write — machines don't consent. - `MintIssuer` signs the JWT with `sub = client_id`, writes an `issuances` audit row. - Optional DPoP binding: if the request has a `DPoP` header, token gets `cnf.jkt`. ## Verify ```bash # Confirm the issuance authserver admin issuance list --client backend-worker --limit 3 # Decode the token — sub should equal client_id echo "" | cut -d. -f2 | base64 -d | jq '{sub, client_id, aud, scope}' ``` ## See also - [Concepts: Grants & flows → Client credentials](/concepts/grants-and-flows#client-credentials) - [Guides: Admin API → Register a client](/guides/admin-api#register-an-mcp-client-manual-not-via-dcr) - [Reference: Configuration → Optional grants](/reference/configuration#optional-grants--disabled-by-default) — `client_credentials.enabled` - [Topologies: Client-credentials hop](/topologies/client-credentials-hop) — variant where a gateway drops user context on the second hop --- ## topologies/mcp-gateway-broker.mdx --- title: MCP gateway → broker description: "A gateway fronts an upstream-IdP-backed service. AuthPlane vends the upstream bearer to the gateway, which forwards it to the third-party API." section: Deployment topologies sectionOrder: 5 order: 8 --- # MCP gateway → broker > **At a glance.** Same gateway pattern as [mcp-gateway-mint](/topologies/mcp-gateway-mint), but the downstream is a Broker resource (upstream OAuth provider like Google or GitHub). Gateway exchanges the user token for an upstream-IdP bearer via RFC 8693 with a Broker target. Fronting-link authorizes the gateway to vend from the broker on the user's behalf. Preserves user identity and gateway act-chain in the vending audit. ## Topology ```mermaid flowchart TB User["User"] AS["AuthPlane"] IdP["Upstream IdP
(Google)"] Agent["Agent"] Gateway["Gateway
(MCP)
fronting-link
(gateway → google-cal)"] Broker["Broker MCP
(google-cal)"] API["Upstream API
(Cal API)"] User -->|consent| AS AS <-->|OAuth refresh/vend| IdP AS --> Agent AS --> Gateway IdP --> Broker Agent -->|"user token
aud=gateway"| Gateway Gateway -->|"upstr. bearer"| Broker Broker --> API ``` Three Resources: visible **gateway** (Mint), hidden **Broker MCP** (backend_kind=broker), **upstream API** (external, not AuthPlane-aware). Fronting-link connects gateway → broker MCP. ## Flow ```mermaid sequenceDiagram participant Agent participant Gateway participant AS as AuthPlane participant Google participant Broker as Broker MCP participant Cal as Upstream API Agent->>AS: user auth-code → token (aud=gateway, scope=tools/calendar) Agent->>Gateway: POST /mcp Bearer Gateway->>Gateway: validates the user token Gateway->>AS: POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=
resource=google-cal ← Broker target
scope=https://www.googleapis.com/auth/calendar
client_id=$GATEWAY_ID
client_secret=$GATEWAY_SECRET AS->>AS: fronting_link(gateway → google-cal) exists → allowed AS->>AS: three-bound consent gates run against (user, gateway, google-cal)
+ (user, google broker_provider) AS->>Google: vend/refresh with stored upstream credential AS-->>Gateway: upstream Google bearer (audited: sub=user, act.sub=gateway) Gateway->>Broker: forward upstream bearer to Broker MCP Broker->>Cal: forward to upstream API Note over Agent,Cal: Response bubbles back ``` If the user hasn't connected Google yet (no `broker_grants` row), step 6 returns `consent_required` with a `consent_url` for `/connect/google`. SDK translates to MCP `-32042` — client walks the user through the connect flow, then retries. ## When to use - Multiple agents share access to a third-party API through one gateway. - You want centralized upstream-credential management (all users' Google grants in AuthPlane, not scattered). - The gateway does something value-add before forwarding (rate limiting, request rewriting, response filtering, request/response caching). - Direct-broker access from agents isn't desired (agents shouldn't hold gateway secrets; gateway does). **Don't use when:** - Single agent, single broker MCP → [broker-mcp](/topologies/broker-mcp) directly. - No upstream provider (all internal) → [mcp-gateway-mint](/topologies/mcp-gateway-mint). - Gateway can drop user identity → [client-credentials-hop](/topologies/client-credentials-hop). ## How to configure Two extra steps on top of the [broker-mcp](/topologies/broker-mcp) setup: **CLI:** ```bash # Already-configured: broker_provider (google), Broker resource (google-cal), data_encryption # Add: # 1. Register the gateway as a Mint Resource authserver admin resource create \ --slug gateway \ --uri https://gw.example.com/mcp \ --backend-kind mint \ --scopes 'tools/calendar||Calendar tools' # 2. Fronting link: gateway may exchange into google-cal on the user's behalf authserver admin fronting create \ --source gateway \ --target google-cal \ --scope-map "tools/calendar:https://www.googleapis.com/auth/calendar" # 3. Register the gateway as a confidential client with token-exchange grant authserver admin client create \ --name gateway \ --grant-types authorization_code,refresh_token,urn:ietf:params:oauth:grant-type:token-exchange \ --auth-method client_secret_post \ --scopes 'tools/calendar||Calendar tools' ``` `--scope-map` maps agent-facing scopes to upstream OAuth scopes. ## How AuthPlane handles it - Broker `TokenExchangeService` verifies the exchange caller (gateway) is an authorized front (fronting-link check) OR is in `runtime.client_ids` of the source resource. - Three-bound consent gates: `consent_grants` (user × agent × google-cal) and `broker_grants` (user × google broker_provider). - On success, `BrokerIssuer` dispatches to the `brokerproto/oauth` adapter to refresh/vend a fresh upstream bearer. - `issuances` row records the vend with `act` chain preserved. ## Verify ```bash # Fronting link exists authserver admin fronting list # → gateway → google-cal, scope-map: tools/calendar:https://.../auth/calendar # End-to-end curl proving vend works curl -X POST http://localhost:9000/oauth/token \ -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ -d "subject_token=$USER_TOKEN" \ -d "resource=google-cal" \ -d "scope=https://www.googleapis.com/auth/calendar" \ -d "client_id=$GATEWAY_ID" \ -d "client_secret=$GATEWAY_SECRET" # → { "access_token": "ya29..." (real Google token), "expires_in": 3599 } ``` ## See also - [Topologies: Agent + brokered MCP](/topologies/broker-mcp) — non-gateway version - [Topologies: MCP gateway → hidden Mint](/topologies/mcp-gateway-mint) — Mint variant - [Guides: Wire up the Token Vault](/guides/token-vault) - [Guides: Runtime client binding](/guides/runtime-client-binding) — when fronting-links don't cover you - [Concepts: Token Vault](/concepts/token-vault) — three-bound consent --- ## topologies/mcp-gateway-mint.mdx --- title: MCP gateway → hidden Mint description: "A gateway fronts a hidden MCP. AuthPlane issues JWTs to the gateway carrying an act-claim chain naming the downstream. User identity + agent chain preserved end to end." section: Deployment topologies sectionOrder: 5 order: 7 --- # MCP gateway → hidden Mint > **At a glance.** A gateway sits between agents and hidden downstream MCP infrastructure. Unlike [client-credentials-hop](/topologies/client-credentials-hop), user identity IS preserved on the second hop via RFC 8693 token exchange, and the downstream sees an `act`-claim chain naming the gateway. Uses **fronting-links** — an operator-vouching declaration that bypasses the runtime-client-binding gate for gateway-fronted patterns. ## Topology ```mermaid flowchart LR User["User"] AS["AuthPlane"] Agent["Agent"] Gateway["Gateway
(MCP)"] Hidden["Hidden Mint
MCP
(invisible to user)
aud=hidden-mint
sub=user
act.sub=gateway"] User -->|consent| AS AS --> Agent AS --> Gateway Agent -->|"user token
aud=gateway"| Gateway Gateway -->|"RFC 8693 ex."| Hidden ``` Two Mint Resources — visible **gateway** and hidden **downstream**. A **fronting-link** declares that gateway is authorized to front tokens to downstream. Token on the second hop preserves `sub=user` but adds `act.sub=gateway_id`. ## Issued-token shape (Option β) The exchanged token that hits the hidden Mint: ```json { "sub": "user-42", // preserved "aud": "https://hidden-mint.internal/api", "client_id": "", // fronted-source, not gateway "act": { "sub": "", // gateway is the acting party "actor_type": "agent" }, "scope": "internal/read" } ``` `sub` = original user (audit trail). `act.sub` = the gateway that made the exchange call (who to hold accountable if the second hop misbehaves). `client_id` = the fronted-source slug (per Option β — see [Concepts: Architecture § Unified Resource model](/concepts/architecture#unified-resource-model)). ## Flow ```mermaid sequenceDiagram participant Agent participant Gateway participant AS as AuthPlane participant Hidden as Hidden Mint Agent->>AS: user auth-code → user token (aud=gateway) Agent->>Gateway: POST /mcp Bearer Gateway->>Gateway: validates the user token Gateway->>AS: POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=
resource=hidden-mint ← target
client_id=$GATEWAY_ID
client_secret=$GATEWAY_SECRET AS->>AS: checks fronting_link(gateway → hidden-mint) exists → bypass runtime-client-binding gate AS-->>Gateway: exchanged token: sub=user, aud=hidden-mint, act.sub=gateway Gateway->>Hidden: POST /api Bearer Hidden->>Hidden: validates the exchanged token Hidden->>Hidden: executes with user identity + gateway act-chain visible ``` ## When to use - Multiple agents access the same internal infra through one boundary (the gateway). - You want user identity preserved on the second hop (unlike [client-credentials-hop](/topologies/client-credentials-hop)). - You want the audit trail to name BOTH user AND gateway (act-chain). - Downstream MCPs shouldn't be directly reachable from agents. **Don't use when:** - Downstream doesn't need user identity → [client-credentials-hop](/topologies/client-credentials-hop) is simpler. - Downstream wraps an upstream IdP → [mcp-gateway-broker](/topologies/mcp-gateway-broker). - No gateway needed (agent talks to MCPs directly) → [direct-fanout](/topologies/direct-fanout). ## How to configure **CLI:** ```bash # 1. Register both Resources authserver admin resource create --slug gateway --uri https://gw.example.com/mcp --backend-kind mint --scopes 'tools/read||Read tools' authserver admin resource create --slug hidden-mint --uri https://hidden.internal/api --backend-kind mint --scopes 'internal/read||Internal read' # 2. Create the fronting link — gateway may front tokens to hidden-mint authserver admin fronting create \ --source gateway \ --target hidden-mint \ --scope-map "tools/read:internal/read" # 3. Register the gateway as a confidential client with token-exchange authserver admin client create \ --name gateway \ --grant-types authorization_code,refresh_token,urn:ietf:params:oauth:grant-type:token-exchange \ --auth-method client_secret_post \ --scopes 'tools/read||Read tools' \ --scopes 'internal/read||Internal read' ``` `--scope-map` on the fronting-link tells AuthPlane how to map source-side scopes to target-side scopes during the exchange. Format: `SOURCE:TARGET,SOURCE:TARGET`. **CLI caveat:** the `--scope-map` flag splits on the FIRST `:`, so source-side scope names that themselves contain `:` (like `tool:list`) can't be expressed via CLI — use REST/YAML for those. **Enable token-exchange** at boot: `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true`. ## How AuthPlane handles it - Standard user auth-code flow issues a token with `aud=gateway`. - On the exchange call, `TokenExchangeService` looks up `fronting_links(source=gateway, target=hidden-mint)`. Found → bypass the runtime-client-binding gate (that gate lives on the broker dispatch path — see [Guides: Runtime client binding](/guides/runtime-client-binding)). - `MintIssuer` signs the exchanged token per Option β shape: `sub` preserved from subject, `client_id` = fronted-source slug, `act.sub` = the exchange caller's client_id. - `issuances` audit row records both parties + the exchange chain. Multi-hop is supported — chained exchanges (A → B → C) nest `act` claims. See [Concepts: Delegation & act-chain](/concepts/delegation-act-chain). ## Verify ```bash # Confirm the fronting link authserver admin fronting list # → gateway → hidden-mint, scope-map: tools/read:internal/read # Trigger an exchange and inspect the resulting act chain authserver admin issuance list --client gateway --limit 3 # → one row with sub=user aud=hidden-mint act.sub=gateway echo "" | cut -d. -f2 | base64 -d | jq '{sub, aud, client_id, act}' ``` ## See also - [Concepts: Delegation & act-chain](/concepts/delegation-act-chain) - [Guides: Runtime client binding](/guides/runtime-client-binding) — the gate this topology bypasses via fronting-link - [Topologies: Client-credentials hop](/topologies/client-credentials-hop) — the simpler alternative that drops user identity - [Topologies: MCP gateway → broker](/topologies/mcp-gateway-broker) — broker variant of gateway fronting --- ## topologies/oidc-federated-login.mdx --- title: OIDC-federated user login description: "Users sign in via Google Workspace / Okta / Entra ID / Auth0 / any OIDC provider. AuthPlane never sees passwords; still issues the MCP tokens." section: Deployment topologies sectionOrder: 5 order: 9 --- # OIDC-federated user login > **At a glance.** Stack this on top of any other topology to change **how users authenticate**. Users click "Sign in with ``", authenticate at your upstream IdP (Google Workspace / Okta / Entra / Auth0), and AuthPlane auto-provisions or links a local account. Token issuance still happens in AuthPlane — federation only changes login. One upstream IdP at a time; use Dex as a broker for multi-provider setups. ## Topology ```mermaid flowchart LR User["User
browser"] IdP["Upstream IdP
(Okta, Google, Entra)"] AS["AuthPlane"] Topo["Agent, MCP,
Broker, ...
(any topo.)"] User -->|login| IdP IdP -->|ID token| User AS -->|OAuth code exchange| IdP AS -->|"consent + token flow
proceeds as normal"| Topo ``` **Only login changes.** Consent, token issuance, resource binding, scope enforcement — all identical to the non-federated version of your topology. ## Flow ```mermaid sequenceDiagram participant Agent participant AS as AuthPlane participant User participant Google Agent->>AS: /oauth/authorize?resource=…&scope=… AS->>User: Login page — clicks "Sign in with Google" User->>AS: GET /oidc/start?redirect=… AS->>Google: 302 to https://accounts.google.com/o/oauth2/v2/auth?… User->>Google: authenticates (MFA, SSO, whatever) Google->>AS: 302 to /oidc/callback?code=… AS->>Google: POST /token (exchange code for ID token) Google->>AS: ID token with email, name, sub, exp, ... AS->>AS: validates ID token (JWKS from Google's discovery) AS->>AS: looks up local user by (provider, provider_sub) — creates one
on first sight using email + name from the ID token AS->>User: 302 to /consent (or /authorize continuation) AS->>AS: (rest of OAuth flow is standard) ``` Then: consent → auth code → `/oauth/token` → tokens. Every step from `/oauth/authorize` onward is identical to any other topology on this section. ## When to use - Your org already uses Google Workspace / Okta / Entra ID / Auth0 / any OIDC provider. - You don't want to manage passwords in AuthPlane. - You need your IdP's MFA, conditional access, session policies, group memberships. - AuthPlane is being deployed for a team with existing corporate accounts. **Don't use when:** - Your users are external — federation to a corporate IdP won't make sense. - You need multiple upstream IdPs simultaneously (Google + Okta at the same time) → use [Dex](https://dexidp.io/) as a broker in front of AuthPlane. - Users don't have a browser (headless services) → [XAA](/topologies/enterprise-xaa) with `jwt-bearer` grant is the right move. ## How to configure **Full walkthrough per provider** in [Guides: Federate to your IdP](/guides/federate-idp). Summary: **Register at your IdP:** - App type: web application (OIDC). - Redirect URI: `https:///oidc/callback`. - Note the client_id + client_secret + issuer URL. **AuthPlane config:** ```yaml oidc: enabled: true issuer: https://accounts.google.com # or your Okta / Entra / Auth0 issuer client_id: "your-client-id" client_secret: "your-client-secret" # or use client_secret_ref (env-var name) display_name: Google # login button text redirect_uri: https://auth.example.com/oidc/callback scopes: [openid, email, profile] ``` Env-var equivalents: `AUTHPLANE_OIDC_ENABLED`, `AUTHPLANE_OIDC_ISSUER`, `AUTHPLANE_OIDC_CLIENT_ID`, `AUTHPLANE_OIDC_CLIENT_SECRET`, etc. ## How AuthPlane handles it - `/oidc/start` — generates state + PKCE, sets HTTP-only session cookie, 302s to upstream `/authorize`. - `/oidc/callback` — validates state (HMAC-signed), exchanges code, validates the ID token against upstream JWKS (cached via `xaa.jwks_cache_ttl`). - `UserAuthService` — looks up the local user by the stable `(provider, provider_sub)` pair from the ID token. If present, that's the account. If absent, auto-provisions a local user, using the ID token's `email` and `name` claims to populate the new row (email is *not* the match key — a user can rename their email at Google and the same account will still resolve). - Session cookie is stamped; user is redirected back to `/oauth/authorize` — the OAuth flow that triggered login continues from where it left off. `oidc.show_local_login: false` hides the password form entirely — everyone must authenticate through the IdP. ## Verify ```bash # Metadata check — /.well-known/openid-configuration doesn't announce OIDC (that's the IdP) # but the login page shows the button curl -s http://localhost:9000/login | grep -i "sign in with google" # After login, user appears in the admin table authserver admin user list # → email=alice@example.com, provider="google", provider_sub="1234567890" ``` ## See also - [Guides: Federate to your IdP](/guides/federate-idp) — full per-provider setup - [Reference: Configuration → OIDC](/reference/configuration#oidc--upstream-oidc-federation) — every knob - [Concepts: Cross-App Access (XAA)](/concepts/xaa) — the *other* enterprise-IdP integration (agent identity assertion, not user login) --- ## topologies/single-mcp.mdx --- title: Agent + single MCP description: "The canonical baseline — one agent, one MCP server, one user, OAuth 2.1 authorization-code + PKCE, per-user audit." section: Deployment topologies sectionOrder: 5 order: 1 --- # Agent + single MCP > **At a glance.** One agent, one MCP, one user — the canonical baseline every other topology builds on. OAuth 2.1 authorization-code flow with PKCE. Per-user audit. No encapsulation. Shipped at v0.1.x. ## Topology ```mermaid flowchart LR User["User
(browser)"] AS["AuthPlane"] Agent["MCP Agent
(Claude)"] MCP["MCP Server
resource: mcp-a
scopes: read, write"] User <-->|consent| AS Agent -->|JWKS + PRM| AS Agent -->|"Bearer (aud=mcp-a)"| MCP ``` **Four components:** - **User** — provides consent at AuthPlane via browser. - **Agent** — the OAuth client. Holds the user's bearer token. - **AuthPlane** — issues the JWT, signs with its own key (the **Mint** — `backend_kind: mint`). - **MCP Server** — the Resource. Validates the JWT against AuthPlane's JWKS on every call. ## Flow ```mermaid sequenceDiagram participant User participant Agent as MCP Agent participant AS as AuthPlane participant MCP as MCP Server Agent->>MCP: GET /.well-known/oauth-protected-resource MCP-->>Agent: PRM {authorization_servers, scopes_supported} Agent->>AS: /oauth/authorize?resource=mcp-a&scope=read
&code_challenge=…&code_challenge_method=S256 AS-->>User: consent screen (Agent × mcp-a × scopes) User->>AS: approve AS-->>Agent: 302 redirect + auth code Agent->>AS: /oauth/token grant_type=authorization_code
code_verifier=… AS-->>Agent: access token bound to mcp-a (JWT, aud=mcp-a) Agent->>MCP: POST /mcp Authorization: Bearer MCP->>AS: GET /.well-known/jwks.json (cached) AS-->>MCP: JWKS MCP-->>Agent: response ``` ## When to use - Single-purpose MCP server with its own scope catalog. - Agent only needs to reach this one MCP for the user. - Per-MCP, per-agent consent is acceptable (typically the right default). **Don't use when:** - Agent reaches multiple MCPs without separate per-MCP consent → [direct-fanout](/topologies/direct-fanout) or [mcp-gateway-mint](/topologies/mcp-gateway-mint). - MCP wraps an upstream IdP (Google, GitHub) → [broker-mcp](/topologies/broker-mcp). - No user (machine-to-machine only) → [m2m-client-credentials](/topologies/m2m-client-credentials). ## How to configure Two operations: register the MCP as a Mint Resource, then register the agent as a public OAuth client. **CLI:** ```bash # 1. Register the Resource authserver admin resource create \ --slug mcp-a \ --uri https://mcp-a.example.com \ --backend-kind mint \ --display-name "MCP A" \ --scopes 'read||Read access' \ --scopes 'write||Write access' # 2. Register the agent as a public client (PKCE-only) authserver admin client create \ --name my-agent \ --grant-types authorization_code,refresh_token \ --auth-method none \ --scopes 'read||Read access' \ --scopes 'write||Write access' # → copy the returned client_id ``` **YAML seed:** ```yaml resources: - slug: mcp-a backend_kind: mint display_name: MCP A uri: https://mcp-a.example.com scopes: - name: read description: Read access - name: write description: Write access ``` For Admin UI + REST API forms, see [Guides: Admin API](/guides/admin-api). **Resource URI must match byte-for-byte** what your MCP server publishes in its PRM. See [Configuration → Resources](/reference/configuration#resources) for the URI-mismatch trap. ## How AuthPlane handles it - `/oauth/authorize` looks up the client and resource, records consent in `consent_grants`, issues an auth code. - `/oauth/token` (auth-code grant) verifies PKCE, mints a JWT via `MintIssuer`, writes an audit row in `issuances`, returns access + refresh. - Subsequent tool calls hit your MCP server; the SDK adapter verifies the JWT locally against the cached JWKS — no round trip to AuthPlane per request. Refresh flow is standard OAuth 2.1 with mandatory rotation (see [Concepts: Grants & flows](/concepts/grants-and-flows#refresh-token)). ## Verify ```bash # Inspect the issuance authserver admin issuance list --resource mcp-a --limit 5 # → shows sub, client_id, scope, jti, expires_at # Decode the JWT (aud should equal mcp-a's URI) echo "" | cut -d. -f2 | base64 -d | jq .aud ``` ## See also - [Choose your topology](/choose-your-topology) — decision flowchart - [Concepts: Grants & flows](/concepts/grants-and-flows#authorization-code--pkce) - [Guides: Connect an MCP client](/guides/connect-mcp-client) — client-side setup - [Quickstart](/quickstart) — end-to-end runnable version of this topology --- ## troubleshooting/common-errors.mdx --- title: Common errors description: "The top-20 errors AuthPlane operators actually hit, each with cause and fix. Everything else in the full catalog at Reference: Errors." section: FAQ & Troubleshooting sectionOrder: 9 order: 2 --- # Common errors > **TL;DR** — The errors you'll actually hit while wiring things up, in the order you'll hit them. Each has cause + fix. Full domain-error catalog + OAuth error codes + WWW-Authenticate patterns live in [Reference: Errors](/reference/errors) — this page is the operator-focused subset. ## Authentication errors ### `401 invalid_token` on every request, no other detail **Cause:** wrong `aud` — token issued for a different resource URI than yours. **Fix:** ensure the client sends `resource=` on `/oauth/authorize` and `/oauth/token`. The URI must match the resource registration byte-for-byte. See [Configuration → Resources](/reference/configuration#resources) for the URI-mismatch trap. ### `401 invalid_dpop_proof` when the client IS sending a DPoP header **Cause A (Python):** forgot `install_request_context(mcp)` — the adapter can't build the DPoP request context without it, so every proof fails closed. **Cause B (any language):** reverse proxy rewrote the scheme or host. The server derives `htu` from `r.Host` and only honors `X-Forwarded-Proto` for the scheme (nothing for host) — so if the proxy is presenting a different `Host` to AuthPlane than the client is signing over, DPoP fails closed. **Fix A:** add the `install_request_context(mcp)` call after `mcp = FastMCP(...)`. **Fix B:** configure the proxy to **preserve the original `Host` header** (nginx: `proxy_set_header Host $host;` — the default forwards `$proxy_host`, which is wrong). If you're terminating TLS at the proxy, also set `X-Forwarded-Proto: https`. See [Guides: Enable DPoP → reverse-proxy trap](/guides/enable-dpop#reverse-proxy-gotchas--the-htu-trap). ### `401 invalid_dpop_proof "jti already used"` on the second request from the same client **Cause:** client is reusing the same DPoP proof `jti` across requests. **Fix:** client bug — generate a fresh UUID per proof. Every AuthPlane SDK does this correctly; if you're implementing a client yourself, this is the first thing to check. ### `401 use_dpop_nonce` on the first DPoP request **Cause:** AS requires nonce (`dpop.require_nonce: true`); client didn't send one. **Fix:** client extracts the `DPoP-Nonce` header from the 401 response and includes it in the next proof's `nonce` claim. All AuthPlane SDKs handle this automatically. ### `403 insufficient_scope` on a specific tool **Cause:** the token's `scope` claim doesn't contain what `require_scope` demands. **Fix:** check granted scopes in the Admin UI Issuances panel. May need to expand `scopes` on the resource registration, or the client's `scope` field on registration. ## OAuth flow errors ### `400 invalid_grant "authorization code has already been used"` **Cause:** client tried to exchange the same code twice, or the code expired (`AuthCodeTTL = 10 minutes` — codes are single-use). **Fix:** restart the flow — codes are single-use. If you're seeing this constantly, check whether your client is retrying after network errors (retry logic should NOT re-use the same code). ### `400 invalid_grant "PKCE verification failed"` **Cause:** client sent wrong `code_verifier` or generated the wrong `code_challenge`. **Fix:** ensure `code_challenge = BASE64URL(SHA256(code_verifier))`. Common bug: URL encoding the verifier before hashing, or hashing the wrong string. ### `400 invalid_grant "refresh token has already been used"` OR `"token family revoked due to reuse detection"` **Cause:** refresh token used twice — either concurrent refresh (client bug) or theft. AuthPlane treats every refresh-token reuse as theft and revokes the entire token family. **Fix:** restart auth from scratch. All tokens in the family are revoked. If you keep hitting this, your client has a concurrency bug — serialize refresh calls per token family. ### `400 unsupported_grant_type` on `client_credentials` **Cause:** `client_credentials.enabled: false` (the default). **Fix:** set `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` and restart. ### `400 unsupported_grant_type` on `token-exchange` or `jwt-bearer` **Cause:** same — grants are disabled by default. **Fix:** `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true` or `xaa.enabled: true` (YAML only). ### `400 unauthorized_client` **Cause:** client's registration `grant_types` doesn't include the requested grant. **Fix:** update the client via `authserver admin client update` or re-register. ### `400 invalid_scope` **Cause:** requested scope not registered on the target resource. **Fix:** add the scope to the resource, or drop it from the request. Scopes must be declared at the resource level, not just on the client. ## Broker / upstream errors ### `400 consent_required` + `consent_url` in the token-exchange response **Cause:** user hasn't completed the Connect flow for a Broker resource (`consent_url` will point at `/connect/{provider}`), OR requested scopes exceed what the user consented to (`consent_url` will point at re-consent). **Fix:** MCP SDK auto-translates this to MCP JSON-RPC `-32042`; MCP client shows the URL to the user. If you're seeing raw `consent_required`, your SDK's elicitation wiring is broken — check the SDK's URL elicitation setup. ### MCP client shows `-32042 URL elicitation required` **Cause:** same as above — token-exchange consent needed. **Fix:** follow the URL in `error.data.url` (Connect flow or re-consent). SDK / client should do this automatically. ## Configuration & boot errors ### Boot fails with `"session.secret is required"` **Cause:** `server.issuer` is not localhost and `session.secret` is unset. **Fix:** `openssl rand -hex 32` and set `AUTHPLANE_SESSION_SECRET`. ### Boot fails with `"admin.api_key is required"` **Cause:** same — production issuer without an API key. **Fix:** generate one and set `AUTHPLANE_ADMIN_API_KEY`. ### Boot fails with `"data_encryption required when broker_providers configured"` **Cause:** you registered broker providers but didn't enable encryption at rest. **Fix:** set `data_encryption.driver` to `aes_master` (simplest) or `vault_transit_encrypt` (enterprise). See [Guides: Wire up the Token Vault → Step 1](/guides/token-vault#step-1--data-encryption-at-rest). ### Admin UI shows "Failed to fetch" everywhere **Cause:** CORS not configured; browser blocks calls to `:9001` from a different origin. **Fix:** set `AUTHPLANE_SERVER_ALLOWED_ORIGINS` with the origin of your admin UI host. ## Discovery errors ### PRM 404 at `/.well-known/oauth-protected-resource` **Cause:** SDK didn't mount the PRM handler (Go only; Python/TS auto-mount). **Fix (Go):** `http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())`. ### AS metadata 404 at `/.well-known/oauth-authorization-server` **Cause:** MCP client is hitting the wrong URL — probably your resource server instead of AuthPlane. **Fix:** check that PRM's `authorization_servers` field points at AuthPlane, not your MCP server's URL. ### MCP Inspector says "no tools" **Cause:** Inspector URL points at the host root without `/mcp`. **Fix:** use `npx @modelcontextprotocol/inspector http://localhost:8080/mcp` — with the `/mcp` path or whatever your SDK's endpoint is configured for. ## When you can't find it here - [Reference: Errors](/reference/errors) — complete catalog with error envelope + all `WWW-Authenticate` patterns - [Troubleshooting: Debugging](/troubleshooting/debugging) — end-to-end debugging checklist - [Troubleshooting: Getting help](/troubleshooting/getting-help) — where to file issues and get support ## Related - [Reference: Errors](/reference/errors) — full domain error catalog - [Guides: Enable DPoP end-to-end](/guides/enable-dpop#troubleshooting) — DPoP-specific errors - [Guides: Federate to your IdP](/guides/federate-idp#troubleshooting) — OIDC federation errors - [Guides: Enterprise-Managed Auth](/guides/xaa#troubleshooting) — XAA errors --- ## troubleshooting/debugging.mdx --- title: Debugging checklist description: "End-to-end debugging checklist for when something is broken and you don't know at which layer — from health checks to token decode to trace correlation." section: FAQ & Troubleshooting sectionOrder: 9 order: 3 --- # Debugging checklist > **TL;DR** — When something is broken and you don't know where, work top-down through this checklist. Each step is a 30-second check. Nine of ten broken deployments turn out to be discovery misconfig, resource-URI mismatch, or a missing env var — this page catches those fast. ## Step 1 — Is AuthPlane reachable? ```bash $ curl -s http://localhost:9000/health {"status":"ok","time":"2026-07-01T00:00:00Z","db":"ok"} ``` - **Response OK** → AuthPlane is running and can reach its DB. Next step. - **Connection refused** → AuthPlane isn't running. Check `docker ps` / `systemctl status authserver` / pod status. - **`"status":"degraded","db":"error"`** → DB unreachable. For Postgres check the DSN + network reachability; for SQLite check the file exists and is writable by the AuthPlane user. ## Step 2 — Does discovery work? **AS metadata:** ```bash $ curl -s http://localhost:9000/.well-known/oauth-authorization-server | jq .issuer "http://localhost:9000" ``` - **`issuer` matches your `AUTHPLANE_SERVER_ISSUER`** → OK. - **`issuer` doesn't match** → wrong `AUTHPLANE_SERVER_ISSUER` at boot; every downstream check (JWT `iss` validation) will fail. - **404** → wrong URL. Discovery is on port 9000 (public), not 9001. **JWKS:** ```bash $ curl -s http://localhost:9000/.well-known/jwks.json | jq '.keys | length' 2 ``` - **≥ 1 keys** → OK. `current` + optionally `previous`. - **0 keys** → key store misconfig. Check `signing.key_store` and the key path/DB/Vault backing it. **PRM (on your MCP server, not AuthPlane):** ```bash $ curl -s https://mcp.example.com/.well-known/oauth-protected-resource | jq . ``` - **Full JSON with `resource`, `authorization_servers`, `scopes_supported`** → OK. - **404** → SDK didn't mount PRM handler (Go: `http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())`; Python/TS auto-mount). - **`authorization_servers` empty** → wrong `issuer` passed to your SDK. ## Step 3 — Do scopes advertise correctly? ```bash $ curl -s http://localhost:9000/.well-known/oauth-authorization-server | jq .scopes_supported ["tools/read","tools/write"] ``` Should list every scope from every resource you've registered. Empty = no resources registered, or scopes not declared on any resource. Check: `authserver admin resource list`. ## Step 4 — Is your token signed by the expected key? Decode the JWT (never do this in production without redacting logs): ```bash $ echo "" | cut -d. -f1 | base64 -d | jq . { "alg": "ES256", "typ": "at+jwt", "kid": "kid_current_abc" } ``` - **`kid`** should be in the JWKS from step 2. If not → JWKS is stale on the SDK side (rare; refresh happens every 5 min). - **`typ: at+jwt`** → RFC 9068 compliant (AuthPlane's default). ## Step 5 — Does the token's `aud` match your resource? ```bash $ echo "" | cut -d. -f2 | base64 -d | jq '{iss, aud, sub, scope, exp}' { "iss": "http://localhost:9000", "aud": ["http://mcp.example.com/mcp"], "sub": "user-42", "scope": "tools/read", "exp": 1708762500 } ``` - **`aud` doesn't contain your resource URI** → RFC 8707 mismatch. Client didn't send `resource=` on `/oauth/authorize`. The fix is on the client (or your PRM advertisement) — `AUTHPLANE_OAUTH_REQUIRE_SCOPE` only affects *scope defaulting*, not audience enforcement, and toggling it will not repair an audience mismatch. - **`iss` doesn't match AuthPlane's issuer** → wrong AuthPlane URL in the SDK's config. - **`scope`** — check it contains what `require_scope()` demands in your tool handler. - **`exp`** — is the token expired? Check clock skew (default tolerance is 30 s). ## Step 6 — Is your MCP server's SDK reading the token? Structured slog logs (JSON format) from your SDK: ``` level=INFO msg="verified" sub=user-42 scope=tools/read aud=http://mcp.example.com/mcp ``` - **Log present** → SDK is reading + verifying tokens. - **No log at all** → SDK isn't wired into the request path. Check middleware ordering. Ensure `bearerAuth` (or equivalent) runs BEFORE your tool handlers. - **Log with error** → follow the error code — likely one of the top-20 in [Common errors](/troubleshooting/common-errors). ## Step 7 — Are metrics telling you a story? ```bash $ curl -s http://localhost:9001/metrics | grep -E "^(authserver|authplane)_" | head -20 authserver_tokens_issued_total 42 authserver_auth_denied_total{reason="invalid_pkce"} 3 authplane_dpop_proofs_validated_total 15 authplane_dpop_proofs_rejected_total{reason="replay"} 0 ``` - **`auth_denied_total{reason="…"}`** growing → clients failing for a reason you can query. Filter by `reason` label to narrow. - **`dpop_proofs_rejected_total`** > 0 → DPoP clients hitting issues (proof format, replay, htu mismatch). See [Guides: Enable DPoP → troubleshooting](/guides/enable-dpop#troubleshooting). - **`upstream_token_refresh_total{outcome="failed"}`** > 0 → broker refresh failing (user revoked upstream, or provider is down). ## Step 8 — Correlate a specific failing request end-to-end Every AuthPlane log line carries `request_id`, `trace_id`, `span_id`. Grab one from a failure: ``` level=ERROR msg="token issuance failed" error=ErrInvalidPKCE code=invalid_grant client_id=my-client resource=http://mcp.example.com/mcp request_id=r_abc123 trace_id=t_def456 span_id=s_ghi789 ``` Query all logs with that `trace_id`: ``` $ kubectl logs -l app=authplane --tail=1000 | grep trace_id=t_def456 ``` Or if you have OTEL traces wired ([Guides: Monitoring → OpenTelemetry](/guides/monitoring#opentelemetry--traces--logs--metrics)), find the trace in your tracing backend — every span from HTTP handler → service → storage → crypto is there. ## Step 9 — Reproduce with MCP Inspector If nothing above turns up the issue, run MCP Inspector against your MCP server: ```bash $ npx @modelcontextprotocol/inspector https://mcp.example.com/mcp ``` Inspector shows every request/response of the OAuth flow inline — see [Guides: Testing with MCP Inspector](/guides/mcp-inspector). If Inspector works but your target client (Claude Code, Cursor, etc.) doesn't, it's a client-specific quirk — see [Guides: Connect an MCP client → Known Claude Code quirks](/guides/connect-mcp-client#known-claude-code-quirks) and [`authserver/docs/compatibility.md`](https://github.com/authplane/authserver/blob/main/docs/compatibility.md). ## Step 10 — Check the audit log Every security-relevant event writes an `audit_events` row. There's no `admin audit …` CLI subcommand — query via the Admin REST API: ```bash $ curl -s "http://localhost:9001/admin/audit?since=2026-07-01T00:00:00Z&user_id=user-42&limit=20" \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" ``` You'll see `token_issued`, `token_revoked`, `consent_granted`, `broker_grant_updated`, etc. — the full sequence of what actually happened for that user in AuthPlane. ## When to escalate If you've gone through all ten steps and can't pin the failure — file a bug at [github.com/authplane/authserver/issues](https://github.com/authplane/authserver/issues) with: - Version (`authserver version` or the container tag) - Redacted config - Redacted request/response of the failing OAuth call - Log lines with `trace_id` - Metric values from `/metrics` if relevant See [Getting help](/troubleshooting/getting-help). ## Related - [Common errors](/troubleshooting/common-errors) — top-20 with fixes - [Reference: Errors](/reference/errors) — complete error catalog - [Guides: Monitoring](/guides/monitoring) — logs + metrics wiring - [Guides: Testing with MCP Inspector](/guides/mcp-inspector) — step-9 in depth - [Getting help](/troubleshooting/getting-help) --- ## troubleshooting/faq.mdx --- title: FAQ description: "Common questions teams ask before they deploy AuthPlane — positioning, compliance, deployment, capacity, and operational concerns." section: FAQ & Troubleshooting sectionOrder: 9 order: 1 --- # FAQ > **TL;DR** — The questions we see most from teams evaluating AuthPlane for production. If yours isn't here, [Common errors](/troubleshooting/common-errors), [Debugging](/troubleshooting/debugging), and [Getting help](/troubleshooting/getting-help) probably cover it. ## Positioning ### What is AuthPlane? AuthPlane is an open-source OAuth 2.1 authorization server built for the Model Context Protocol, deployed as a single Go binary on your own infrastructure. It issues tokens, publishes discovery, handles consent, and vaults upstream OAuth grants — everything MCP servers need for spec-compliant auth without the SaaS lock-in. ### Does AuthPlane replace my existing identity provider? No. AuthPlane federates to Google Workspace, Okta, Entra ID, Auth0, or any OIDC provider ([Guides: Federate to your IdP](/guides/federate-idp)). You keep your IdP to authenticate the humans; AuthPlane handles the AI agents' identity and the OAuth token path. ### Should I self-host or use a managed service? It's a deployment choice. Self-hosting keeps token issuance, identity data, and upstream secrets inside your perimeter with no vendor in the critical path. If you'd rather not operate it yourself, AuthPlane's managed cloud offering (on the way) is the same product with an identical wire protocol — you can start self-hosted and move later, or vice versa. ### How does AuthPlane compare to Auth0 / Keycloak / rolling your own? - **Auth0 / Descope / WorkOS** — general-purpose SaaS identity, per-MAU billing, not purpose-built for MCP. AuthPlane is MCP-first, and available self-hosted (flat cost, your perimeter) or managed. - **Keycloak** — powerful but heavyweight and not built for MCP. AuthPlane is purpose-built for OAuth 2.1 + MCP Authorization spec. - **Rolling your own** — you'd need to implement 16 RFCs correctly and stay on top of revisions. AuthPlane implements them and ships a conformance test suite. ### How long does it take to get running? One `docker run` brings up the OAuth 2.1 endpoints and admin UI. Most teams have a runnable example registered within ten minutes. See [Quickstart](/quickstart). ## Compliance & security ### Is AuthPlane SOC 2 certified? Not as a shipping vendor, no. AuthPlane is **software you self-host** — the certification model that applies to your deployment is your own organization's. The code is AGPL-3.0 and reviewable; the [threat model](/security/threat-model) and [token design](/security/token-design) are documented; security disclosures go through [GitHub Private Vulnerability Reporting](https://github.com/authplane/authserver/security/advisories/new) (see [Reporting vulnerabilities](/security/reporting-vulnerabilities)). ### Can I run it air-gapped? Yes. The binary makes zero outbound network calls unless you explicitly enable error reporting or configure OIDC federation to an external IdP. Both are opt-in. ### What RFCs does AuthPlane implement? The full list is in [Reference: RFC compliance](/reference/rfc-compliance) — 16 RFCs including OAuth 2.1 (auth code + PKCE + refresh + client_credentials + token exchange + JWT bearer), DPoP, DCR, PRM, resource indicators, introspection, revocation, JWT AT, and problem details. Every deviation is opt-in and documented. Note: OAuth 2.1 itself is still an active IETF draft (`draft-ietf-oauth-v2-1`), not a published RFC — AuthPlane implements the latest revision. ### How are secrets handled? Session secrets, admin API keys, and upstream OAuth client secrets are all env-var only — never in YAML. Refresh grants stored in `broker_grants` are encrypted at rest (AES-256-GCM or Vault Transit). Signing keys either live in a permission-restricted keyfile, encrypted in Postgres, or in HashiCorp Vault Transit (never on disk). ## Deployment & operations ### Do you support multi-tenant isolation? First-class multi-tenant isolation is post-v1.0. Today, full isolation means separate AuthPlane instances per tenant — which is the same model most regulated environments require anyway. All AuthPlane state (clients, users, resources, providers, grants, issuances, keys) lives in one DB; if you need hard isolation, one AuthPlane per tenant is the pattern. ### Minimum hardware? Single instance: 512 MB RAM, 1 vCPU handles typical loads (< 100 TPS token issuance). The binary is < 50 MB. SQLite storage: as much disk as your issuances table can grow to (~1 KB per token issuance row). Postgres storage: as much as your Postgres instance can hold. ### How do I set up HA? Postgres for storage (`storage.driver: postgres`), `vault_transit` or `postgres_key` for signing (so keys are shared across instances), stateless replicas behind a load balancer, `LISTEN/NOTIFY` handles config propagation. See [Operate: Kubernetes](/operate/kubernetes) for the Helm chart with `autoscaling.enabled` + `podDisruptionBudget`. ### What's the performance impact on my MCP server? Effectively none on the request path. The SDK validates tokens locally against the JWKS (cached). AuthPlane itself only handles token issuance, refresh, and admin operations — none of which sit in front of your tool calls. ### Do I need to run `authserver purge`? Yes if you enable DPoP, client credentials, or XAA — expired-data tables (`dpop_nonces`, `machine_tokens`, `assertion_jti`) grow unbounded without purging. Schedule via systemd timer, Docker sidecar, or Kubernetes CronJob. Recipes in [Operate: Backup, upgrade, purge](/operate/backup-upgrade-purge#scheduled-purge). ### How do I migrate from SQLite to Postgres? - Point `storage.driver: postgres` and set `storage.postgres.dsn`. - Run `authserver migrate` against the empty Postgres DB. - Data migration between drivers is manual today — no `authserver export/import` yet. For most deployments the practical path is: stand up the Postgres instance fresh (no historical data), rotate credentials so clients re-register via DCR. Grants are per-user and rebuild organically as users next authenticate. ### How do I upgrade AuthPlane? `docker pull` (or download the new binary), run `authserver migrate` (idempotent — safe to run against an already-migrated DB), restart. Migrations are forward-only; there's no rollback. Read the release notes before major-version bumps. See [Operate: Backup, upgrade, purge → Upgrade](/operate/backup-upgrade-purge#upgrade). ### DPoP vs mTLS — which should I use? Different scopes. mTLS binds a **connection** to a client certificate. DPoP binds a **token** to a client key pair — the token remains usable across connections, but only by the holder of the corresponding private key. In multi-hop deployments, DPoP proofs travel with the token; mTLS terminates at the first hop. Use DPoP for tokens that transit multiple services; use mTLS for a hardened perimeter around AuthPlane itself if you want it. ## Interop ### Which MCP clients has AuthPlane been tested with? Automated tests: MCP Inspector 0.14.x (full flow). Known-bugs handled: Claude Code (`AUTHPLANE_OAUTH_REQUIRE_SCOPE=false` workaround). Manually validated: Claude Desktop, Cursor. Pending validation: VS Code Copilot Chat. Full matrix in [compatibility.md in the authserver repo](https://github.com/authplane/authserver/blob/main/docs/compatibility.md), which is the living source of truth. ### Which languages/frameworks have SDKs? Python (official MCP SDK + PrefectHQ FastMCP), TypeScript (official MCP SDK, punkpeye FastMCP, Hono, NestJS), Go (official MCP SDK, mark3labs/mcp-go, generic net/http). See [SDKs overview](/sdks/overview) for the full matrix. C#, Java, and Rust SDKs are in the codebase but not yet listed as first-class options in these docs. ## Where to next - **Something's broken** → [Common errors](/troubleshooting/common-errors) or [Debugging](/troubleshooting/debugging). - **Getting help** → [Getting help](/troubleshooting/getting-help). - **Evaluating for production** → [Security: Threat model](/security/threat-model) + [Operate: Kubernetes](/operate/kubernetes). - **Just starting** → [Quickstart](/quickstart). --- ## troubleshooting/getting-help.mdx --- title: Getting help description: "GitHub Issues, Discussions, security disclosures, and what to include in a bug report so the maintainers can help fast." section: FAQ & Troubleshooting sectionOrder: 9 order: 4 --- # Getting help > **TL;DR** — Bug reports and feature requests go to GitHub Issues on the relevant repo. Questions and discussions go to GitHub Discussions. Security vulnerabilities go to Private Vulnerability Reporting. Most issues get a response within one business day. Include the version + redacted config + logs with `trace_id` and you'll get help fast. ## Where to go | Type | Where | |---|---| | Bug in AuthPlane server | [github.com/authplane/authserver/issues](https://github.com/authplane/authserver/issues) | | Bug in Python SDK | [github.com/AuthPlane/python-sdk/issues](https://github.com/AuthPlane/python-sdk/issues) | | Bug in TypeScript SDK | [github.com/AuthPlane/ts-sdk/issues](https://github.com/AuthPlane/ts-sdk/issues) | | Bug in Go SDK | [github.com/AuthPlane/go-sdk/issues](https://github.com/AuthPlane/go-sdk/issues) | | Bug in these docs | [github.com/AuthPlane/docs/issues](https://github.com/AuthPlane/docs/issues) | | Question / discussion | [github.com/authplane/authserver/discussions](https://github.com/authplane/authserver/discussions) | | MCP client compatibility report | [MCP compatibility issue template](https://github.com/authplane/authserver/blob/main/.github/ISSUE_TEMPLATE/mcp-compatibility.md) | | Security vulnerability | [Private vulnerability reporting](https://github.com/authplane/authserver/security/advisories/new) — see [Security: Reporting vulnerabilities](/security/reporting-vulnerabilities) | Most issues get an initial response within one business day. Security reports are acknowledged within 48 hours ([SLA](/security/reporting-vulnerabilities#response-timeline)). ## What to include in a bug report The faster you can give us these five things, the faster we can help: **1. Version.** ```bash $ authserver version authserver v0.1.x commit=abcdef1 built=2026-01-01T00:00:00Z ``` For containers: the image tag (`docker inspect | grep -i tag`). For SDKs: the version from your dependency file (`pip show authplane-mcp`, `npm ls @authplane/mcp`, `go list -m github.com/authplane/go-sdk/mcp`). **2. Config (redacted).** Full YAML config OR relevant `AUTHPLANE_*` env vars. **Redact:** - `session.secret`, `admin.api_key` - All OAuth `client_secret`, `client_secret_ref` values - `data_encryption.aes_master.key_env` env-var VALUES (not names) - `vault_transit.token` / `approle.secret_id` - Postgres DSN passwords `AUTHPLANE_*` names alone are fine; secret VALUES need redaction. **3. Repro steps.** The minimal sequence that reproduces the failure. If it's the OAuth flow, a redacted `curl` for each step is ideal. If it's an SDK bug, a minimal `server.py` / `server.ts` / `main.go` that triggers it. **4. Log lines (with `trace_id`).** Grab a few lines around the failure — `WARN` and `ERROR` levels are usually enough. Include `trace_id` so we can follow the request across services. **Redact tokens** — never post an unredacted access token, refresh token, or session cookie in a public issue. **5. What you expected vs what happened.** One sentence each. Skip "it should work" — say "I expected the token endpoint to return an access token with `aud=X`; instead it returned `403 access_denied`". ## Anti-patterns to avoid - **"Nothing works."** — nine of ten times, one specific thing doesn't work. Isolate it. - **Screenshots of terminals.** — paste the text. Screenshots aren't grep-able and take longer to read. - **Posting tokens.** — never. Redact `eyJ...` blobs before hitting Send. If we need the token structure, decode the JWT payload and paste the JSON (still redacted for user identifiers). - **"Same as issue #X but different."** — link the issue you're comparing to and describe the delta. - **"Doesn't work with Claude Code."** — check [`authserver/docs/compatibility.md`](https://github.com/authplane/authserver/blob/main/docs/compatibility.md) first; known Claude Code quirks are documented (see [Guides: Connect an MCP client → Known Claude Code quirks](/guides/connect-mcp-client#known-claude-code-quirks)). ## For feature requests Open an issue with the tag `feature`. Include: - The problem you're trying to solve (not the solution you have in mind). - Why existing features don't cover it. - Concrete use case with real numbers where possible ("we have 200 agents, each needing X"). We prioritize based on breadth of demand + alignment with the roadmap. Not everything gets built — but every request gets read. ## For MCP client compatibility If you're testing a new MCP client and hit spec-drift issues, file via the [MCP compatibility issue template](https://github.com/authplane/authserver/blob/main/.github/ISSUE_TEMPLATE/mcp-compatibility.md). Document which scenarios (C.1–C.10 per compatibility.md) pass/fail. If you can add an automated test to `e2e/scenarios/`, even better — PRs welcome. ## For security Never open a public issue for a security vulnerability. Use [GitHub Private Vulnerability Reporting](https://github.com/authplane/authserver/security/advisories/new) — details in [Security: Reporting vulnerabilities](/security/reporting-vulnerabilities). Coordinated disclosure preserves users and gets you credit. ## Commercial / enterprise support AuthPlane is AGPL-3.0 open source. There is no paid support tier today — GitHub Issues + Discussions are the entire support surface. If you're deploying at enterprise scale and need contractual support, contact `hello@authplane.ai` and we'll point you at the current options. ## Related - [FAQ](/troubleshooting/faq) - [Common errors](/troubleshooting/common-errors) - [Debugging checklist](/troubleshooting/debugging) - [Security: Reporting vulnerabilities](/security/reporting-vulnerabilities) - [`authserver/docs/compatibility.md`](https://github.com/authplane/authserver/blob/main/docs/compatibility.md) — living MCP client compatibility matrix