Skip to main content

Go SDK

The Go SDK is the supported, production-ready way to add AuthSec to an MCP server. It validates bearer tokens (JWT or via introspection), enforces a tool→scope policy fetched from AuthSec, filters tools/list responses by what the caller is allowed to see, and serves the RFC 9728 protected-resource metadata endpoint that 401 challenges point at.

If you haven't already, walk through From zero to a launched MCP server — it covers the surrounding setup (registration, scope mapping, activation) that the SDK plugs into.

Install

go get github.com/authsec-ai/sdk-authsec/packages/go-sdk@latest

The module path is long because the SDK lives in a monorepo. Import alias authsec keeps things readable:

import authsec "github.com/authsec-ai/sdk-authsec/packages/go-sdk"

Ten-line quickstart

The full executable lives at sdk-authsec/packages/go-sdk/examples/quickstart/main.go. Paste from there — it's CI-tested against a live backend, so the version on this page can't drift from what actually works.

cfg := authsec.Config{
Issuer: base, // POST /resource-servers response: issuer_url
AuthorizationServer: base,
JWKSURL: base + "/oauth/jwks", // jwks_uri
IntrospectionURL: base + "/oauth/introspect", // introspection_endpoint
ResourceServerID: rsID, // id
IntrospectionClientID: rsID,
IntrospectionClientSecret: rsSecret, // introspection_secret
ResourceURI: resourceURI, // resource_url
}

mux := http.NewServeMux()
if err := authsec.MountMCP(mux, "/mcp", yourMCPHandler, cfg); err != nil {
log.Fatal(err)
}
http.ListenAndServe(":8000", mux)

That's the whole integration. The SDK takes over from here:

  • The mux serves /mcp (your handler, wrapped) and /.well-known/oauth-protected-resource/mcp (RFC 9728 metadata that the 401 challenge URL points at).
  • Every incoming request gets its Authorization: Bearer ... token verified.
  • tools/list responses are filtered to the subset your caller has scopes for.
  • tools/call is gated by the scope mapping you configured on the Tools tab in the admin UI.

Where Config values come from

Every Config field maps to a field in the POST /authsec/resource-servers response. Paste, don't hand-write:

Config fieldResponse fieldWhat it is
Issuerissuer_urlThe AuthSec backend's OAuth issuer URL.
AuthorizationServerissuer_urlSame value — the SDK uses this to construct the sdk-policy URL.
JWKSURLjwks_uriPublic keys for local JWT verification.
IntrospectionURLintrospection_endpointThe token introspection endpoint (Basic auth).
ResourceServerIDidThe UUID of this resource server.
IntrospectionClientIDidThe username half of the Basic auth pair. Same value as ResourceServerID.
IntrospectionClientSecretintrospection_secretThe password half. Only shown once; rotate via the admin UI.
ResourceURIresource_urlYour MCP server's externally-reachable URL. What aud claims are bound to.

The two SDK URLs (scope_matrix_url, manifest_url) in the response are derived from AuthorizationServer + ResourceServerID — you don't pass them in directly.

PolicyMode

Controls where the SDK gets its tool→scope mapping from.

ModeWhen to useWhat happens
PolicyModeRemoteRequiredProduction. Default when ResourceServerID is set.SDK fetches the scope matrix from AuthSec at startup. If the fetch fails, startup fails. Fresh policy is pulled lazily on stale reads.
PolicyModeRemoteWithLocalFallbackResilient deployments.Try remote first; fall back to Config.ToolScopes if AuthSec is unreachable. Both ResourceServerID and ToolScopes must be set.
PolicyModeLocalOnlyAir-gapped or test environments.Never call AuthSec. Use Config.ToolScopes only.
PolicyModeOpenTest only. Logs a startup warning. Do not use in production.Skip all tool-level policy. Any valid token can call any tool.

When PolicyMode is left unset, the SDK infers:

  • RemoteRequired if ResourceServerID is set
  • LocalOnly if ToolScopes is set but ResourceServerID isn't
  • Open if neither is set

In practice you'll either let the default land on RemoteRequired or explicitly set RemoteWithLocalFallback for resilience.

ValidationMode

Controls how the SDK verifies each token.

ModeWhat it doesTradeoff
ValidationModeJWTOnlyVerify signature locally against JWKS.Lowest latency. Revocations not detected until token expires.
ValidationModeIntrospectionOnlyCall /oauth/introspect on every request.Highest fidelity (catches revocations). Adds a network call per request.
ValidationModeJWTOrIntrospectTry JWT first; fall back to introspection on parse failure.Tolerant. Used during gradual rollouts.
ValidationModeJWTAndIntrospectVerify both; either failure denies.Strictest. Recommended default when both URLs are set.

When unset, the SDK picks:

  • JWTAndIntrospect if both JWKSURL and IntrospectionURL are set
  • JWTOnly if only JWKSURL is set
  • IntrospectionOnly if only IntrospectionURL is set

See JWT vs token introspection for MCP servers for the deeper tradeoff.

Runtime API

For most use cases MountMCP is enough. The lower-level functions exist when you need to compose the pieces yourself.

MountMCP(mux, pattern, handler, cfg) error

Recommended installation API. Registers:

  1. Your handler at pattern (e.g. /mcp), wrapped with token + scope enforcement.
  2. The protected-resource metadata endpoint at BuildResourceMetadataPath(cfg.ResourceURI).
err := authsec.MountMCP(mux, "/mcp", yourMCPHandler, cfg)

WrapMCPHTTP(next, cfg) (http.Handler, error)

Returns a wrapped handler without touching a mux. Use this if you're not using net/http.ServeMux (e.g. chi, gin, gorilla/mux).

wrapped, err := authsec.WrapMCPHTTP(yourMCPHandler, cfg)
router.Handle("/mcp", wrapped)

You're responsible for separately exposing the metadata endpoint:

metadata, _ := authsec.ProtectedResourceHandler(cfg)
router.Handle(authsec.BuildResourceMetadataPath(cfg.ResourceURI), metadata)

NewRuntime(cfg) (*Runtime, error)

Low-level constructor. Returns a Runtime you can call individual methods on:

  • rt.Wrap(handler) http.Handler — same as WrapMCPHTTP but without re-constructing the Runtime.
  • rt.AuthMiddleware() func(http.Handler) http.Handler — standard Go middleware that validates the bearer token and injects the *Principal into the request context. No tool-level enforcement; just authentication.
  • rt.AuthorizeTool(ctx, principal, toolName) error — explicit per-tool authorization check. Returns ErrInsufficientScope or ErrPolicyUnavailable.
  • rt.ProtectedResourceHandler() http.Handler — the metadata endpoint.

Use these when you want to plug AuthSec into an existing custom transport (gRPC, WebSocket, etc.) rather than HTTP.

Principal

The validated identity, available on the request context after the middleware runs:

func myHandler(w http.ResponseWriter, r *http.Request) {
p, ok := authsec.PrincipalFromContext(r.Context())
if !ok {
// No principal — shouldn't happen behind WrapMCPHTTP.
http.Error(w, "unauthorized", 401)
return
}
log.Printf("user=%s scopes=%v", p.Subject, p.Scopes)
if !p.HasAnyScope([]string{"rs-xyz:admin"}) {
http.Error(w, "forbidden", 403)
return
}
// ...
}

Principal exposes Subject, Issuer, Audience, Scopes, Claims (the raw JWT claims map), and Active (true unless introspection said otherwise).

Errors

ErrorMeansHTTP mapping
ErrPolicyUnavailablePolicyModeRemoteRequired is set but AuthSec is unreachable.503
ErrInsufficientScopeToken is valid but lacks the required scope for this tool.403 with insufficient_scope body

Both wrap underlying causes — use errors.Is and errors.Unwrap for detail.

Common patterns

Local fallback policy

For deployments that need to keep serving even when AuthSec is unreachable, use RemoteWithLocalFallback:

cfg := authsec.Config{
// ... remote config as usual ...
PolicyMode: authsec.PolicyModeRemoteWithLocalFallback,
ToolScopes: authsec.ToolScopeMap{
"echo_read": {"rs-xyz:tools:read"},
"echo_write": {"rs-xyz:tools:write"},
},
}

Keep the local map roughly in sync with what the admin UI shows on the Tools tab; it's only used when the SDK can't reach AuthSec, but stale fallbacks are a footgun.

Publish your tool inventory

The PublishManifest: true flag asks the SDK to push your server's tool inventory to AuthSec at startup, so the admin UI's Tools tab is auto-populated rather than depending on the network scan in step 2 of the lifecycle.

cfg := authsec.Config{
// ...
PublishManifest: true,
ToolScopeSuggestions: map[string][]string{
"echo_read": {"rs-xyz:tools:read"},
"echo_write": {"rs-xyz:tools:write"},
},
}

Publish is best-effort — failure logs a warning and never blocks startup. Admins can override your suggestions in the UI; their overrides survive future republishes.

Marking a tool as public

An empty scope list explicitly marks a tool as public — any valid token can call it:

ToolScopes: authsec.ToolScopeMap{
"health_check": {}, // public
"list_repos": {"rs-xyz:tools:read"}, // requires read scope
"create_pr": {"rs-xyz:tools:write"}, // requires write scope
}

A tool that's absent from the map is denied (assuming any policy is in effect). Public is opt-in.

Wiring custom logging

The SDK logs through slog. Inject your own logger to integrate with your observability stack:

cfg := authsec.Config{
// ...
Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
}

Every authorization decision — validate, authorize, deny — emits a structured log line.

Behaviour reference

Specific behaviours worth knowing in advance:

  • Unknown tool — A tool that's not in the policy AND not explicitly public is denied with method_not_found / 403. Open mode is the only exception.
  • Parse-fail-closed — Any malformed JSON-RPC body is denied at 400 without touching your inner handler. The SDK never accidentally allows on parse failure.
  • Batch JSON-RPC — Batch requests are honored: each entry is authorized independently, mixed results return mixed results. A denied entry never silently passes through.
  • Streaming (SSE) tools/list — Filtered the same way as HTTP tools/list. Tools the caller can't access don't appear in the stream.
  • Policy refresh — Stale-on-read with a single-flight guard. The first request after the TTL expires kicks off a fetch; concurrent requests wait for it (or get the stale value for a short grace window) rather than stampeding.

What's not in the Go SDK today

Honest list of gaps you might bump into:

  • Per-scope token introspection caching. Each introspection call is independent today. If you're seeing latency, switch to ValidationModeJWTOnly rather than caching introspection responses.
  • Multi-resource servers from one process. You can run multiple Runtime instances in one binary (each does its own manifest publish and scope-matrix fetch), but the MountMCP convenience doesn't help — you'll need to plumb them onto your mux by hand.
  • Client-bound tokens (DPoP, mTLS). AuthSec doesn't issue these yet; the SDK doesn't verify them. Standard bearer is what's in scope.