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/listresponses are filtered to the subset your caller has scopes for.tools/callis 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 field | Response field | What it is |
|---|---|---|
Issuer | issuer_url | The AuthSec backend's OAuth issuer URL. |
AuthorizationServer | issuer_url | Same value — the SDK uses this to construct the sdk-policy URL. |
JWKSURL | jwks_uri | Public keys for local JWT verification. |
IntrospectionURL | introspection_endpoint | The token introspection endpoint (Basic auth). |
ResourceServerID | id | The UUID of this resource server. |
IntrospectionClientID | id | The username half of the Basic auth pair. Same value as ResourceServerID. |
IntrospectionClientSecret | introspection_secret | The password half. Only shown once; rotate via the admin UI. |
ResourceURI | resource_url | Your 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.
| Mode | When to use | What happens |
|---|---|---|
PolicyModeRemoteRequired | Production. 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. |
PolicyModeRemoteWithLocalFallback | Resilient deployments. | Try remote first; fall back to Config.ToolScopes if AuthSec is unreachable. Both ResourceServerID and ToolScopes must be set. |
PolicyModeLocalOnly | Air-gapped or test environments. | Never call AuthSec. Use Config.ToolScopes only. |
PolicyModeOpen | Test 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:
RemoteRequiredifResourceServerIDis setLocalOnlyifToolScopesis set butResourceServerIDisn'tOpenif 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.
| Mode | What it does | Tradeoff |
|---|---|---|
ValidationModeJWTOnly | Verify signature locally against JWKS. | Lowest latency. Revocations not detected until token expires. |
ValidationModeIntrospectionOnly | Call /oauth/introspect on every request. | Highest fidelity (catches revocations). Adds a network call per request. |
ValidationModeJWTOrIntrospect | Try JWT first; fall back to introspection on parse failure. | Tolerant. Used during gradual rollouts. |
ValidationModeJWTAndIntrospect | Verify both; either failure denies. | Strictest. Recommended default when both URLs are set. |
When unset, the SDK picks:
JWTAndIntrospectif bothJWKSURLandIntrospectionURLare setJWTOnlyif onlyJWKSURLis setIntrospectionOnlyif onlyIntrospectionURLis 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:
- Your handler at
pattern(e.g./mcp), wrapped with token + scope enforcement. - 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 asWrapMCPHTTPbut without re-constructing the Runtime.rt.AuthMiddleware() func(http.Handler) http.Handler— standard Go middleware that validates the bearer token and injects the*Principalinto the request context. No tool-level enforcement; just authentication.rt.AuthorizeTool(ctx, principal, toolName) error— explicit per-tool authorization check. ReturnsErrInsufficientScopeorErrPolicyUnavailable.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
| Error | Means | HTTP mapping |
|---|---|---|
ErrPolicyUnavailable | PolicyModeRemoteRequired is set but AuthSec is unreachable. | 503 |
ErrInsufficientScope | Token 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 HTTPtools/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
ValidationModeJWTOnlyrather than caching introspection responses. - Multi-resource servers from one process. You can run multiple
Runtimeinstances in one binary (each does its own manifest publish and scope-matrix fetch), but theMountMCPconvenience 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.
Related
- From zero to a launched MCP server — the surrounding setup
- Register an application — where the Config values come from
- Map tools to scopes — how the scope matrix that the SDK fetches gets populated
- JWT vs token introspection for MCP servers — picking a
ValidationMode - Troubleshoot OAuth tokens on MCP servers — when 401 or 403 doesn't match expectations