Architecture
TL;DR — AuthPlane is one Go binary with two HTTP servers:
:9000for public OAuth traffic,:9001for 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, stdlibnet/http. This page is the mental model — one read makes the rest of the docs make sense.
What runs where
flowchart TD
Client["MCP Client<br/>(Claude Desktop, Cursor, …)"]
Browser["User Browser"]
subgraph AS9000["authserver :9000"]
Discovery["Discovery<br/>AS meta<br/>JWKS"]
OAuth["OAuth<br/>Authorize<br/>Token<br/>Introspect<br/>Revoke"]
LoginConsent["Login/Consent"]
Services["Services<br/>Authorize · Token · DCR/CIMD · Consent<br/>UserAuth · JWKS · Admin · Audit<br/>Introspection · Connect · BrokerIssuer<br/>TokenExchange · JWTBearer · AgentIdent."]
Store["Store<br/>SQLite<br/>Postgres<br/>AES enc"]
Signing["Signing<br/>Keyfile<br/>Postgres<br/>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)<br/>Clients · Users · Resources · Providers · Grants<br/>Issuances · Signing Keys · Audit · System"]
end
Client -->|"1. Discover PRM on your MCP server<br/>2. Discover AS metadata<br/>3. Register client (DCR or CIMD)<br/>4. Login<br/>5. Consent<br/>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/<resource-path>.:9001— Admin REST API under/admin/*and the built-in React Admin UI at/admin/ui/. RequiresAUTHPLANE_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.
flowchart TD
L1["api/http/ · api/admin/ · web/admin/<br/>OAuth handlers · Admin REST+UI · React SPA<br/>Primary adapters (inbound)"]
L2["internal/ports/input/<br/>What the world asks us to do<br/>Input ports (interfaces)"]
L3["internal/services/<br/>Orchestrates domain operations<br/>Business logic"]
L4["internal/ports/output/<br/>What we need from the world<br/>Output ports (interfaces)"]
L5["internal/adapters/sqlite/ keyfile/ oidc/<br/>internal/adapters/postgres/ cimd/<br/>internal/adapters/aesmaster/ connector/ hcvault/<br/>Secondary adapters (outbound)"]
L6["internal/domain/<br/>Pure business types — stdlib only<br/>Domain entities + errors"]
L1 --- L2 --- L3 --- L4 --- L5 --- L6
Dependency rules
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:
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=…<br/>oauthHandler (api/http)
Handler->>Service: AuthorizePort.StartAuthorization (input port)<br/>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)<br/>loginHandler
Handler->>Service: UserAuthPort.Authenticate<br/>UserAuthService
Service->>Store: UserStore.GetByEmail (output port → SQLite adapter)
Service->>Crypto: bcrypt.Compare
Handler-->>Client: 302 to /consent
Client->>Handler: 3. POST /consent (approve)<br/>consentHandler
Handler->>Service: ConsentPort.GrantConsent<br/>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)<br/>oauthHandler
Handler->>Service: TokenPort.ExchangeCode<br/>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 <access_token> (or Authorization: DPoP <access_token> + 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.
Domain errors live in a single file — internal/domain/errors.go. Each carries an OAuth error code for wire-level mapping:
The full catalog is in 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, direct-fanout, folded-resource, and the mint variant of gateway topologies.broker— AuthPlane vends an upstream-provider access token via RFC 8693 token exchange, gated by the three-bound consent model. Used by 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=<provider-slug>. 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 and Guides: Wire up the 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.
Observability
Every request is instrumented:
- Structured logs (
slog) withtrace_id,span_id,request_idon 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 and Reference: Metrics.
Docker packaging
The production image uses a multi-stage build:
golang:1.25-alpine— compiles the binary withCGO_ENABLED=0gcr.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 — the OAuth grants that ride on top of this architecture
- Concepts: Resource servers & PRM — how your MCP server is modeled
- Reference: Configuration — every knob exposed by every layer
- Guides: Monitoring — turning observability on
- Security: Threat model — trust boundaries and 16 named threats