Skip to main content

Go SDK

One module, two sides of the MCP auth protocol:

You are buildingYou needStart here
An MCP server that must be protectedToken validation + per-tool RBACProtect your MCP server
An agent/service that calls a protected serverA token — as itself (M2M) or on behalf of a userM2M auth · ID-JAG delegation

Install

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

Requires Go 1.24+. One dependency (github.com/golang-jwt/jwt/v5); no CGO. The package lives in the sdk-authsec monorepo on GitHub.

Package layout

Everything is in the root package authsec; import it with an alias:

import authsec "github.com/authsec-ai/sdk-authsec/packages/go-sdk"
packages/go-sdk/
├── (package authsec) # SERVER SIDE — MountMCP, WrapMCPHTTP, NewRuntime, Config,
│ # FromEnv, ManifestTool, Principal, RFC 9728 metadata
│ # AGENT SIDE — AgentIdentity, ClientSecretAuth,
│ # PrivateKeyJwtAuth, SpiffeSvidAuth, SpiffeWorkloadIdentity,
│ # BrowserLogin, PollUntilApproved
└── client/ # typed errors for parsing AuthSec 401/403 responses

Typed errors for reading a protected server's responses live in the sub-package:

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

Protect a server: drop in the wrapper

import (
"log"
"net/http"

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

func main() {
cfg := authsec.FromEnv() // reads AUTHSEC_* env vars into a Config

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

MountMCP registers two routes on your mux:

  • /mcp — your handler, wrapped: every request has its bearer token validated and tools/call checked against the scope policy you configured in AuthSec.
  • /.well-known/oauth-protected-resource/mcp — the RFC 9728 metadata that lets OAuth clients discover where to authenticate.

handler is any http.Handler that speaks MCP JSON-RPC — hand-rolled or from an MCP framework. Not using *http.ServeMux? See protecting a server behind another router.

Full walkthrough with dashboard screenshots: Protect your MCP server — from go get to a launched, protected application.

Call a server: get a token

Machine-to-machine (no user) — three interchangeable credential types:

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

// shared secret
agent := authsec.NewAgentIdentity(authsec.AgentIdentityConfig{
Issuer: ISSUER, ClientID: CLIENT_ID, Auth: authsec.NewClientSecretAuth("sec_..."),
})
// RFC 7523 signed assertion
pk, _ := authsec.NewPrivateKeyJwtAuth("key.pem", "key-1")
agent = authsec.NewAgentIdentity(authsec.AgentIdentityConfig{Issuer: ISSUER, ClientID: CLIENT_ID, Auth: pk})
// pre-held SPIFFE SVID
agent = authsec.NewAgentIdentity(authsec.AgentIdentityConfig{
Issuer: ISSUER, ClientID: CLIENT_ID, Auth: authsec.NewSpiffeSvidAuth(svid),
})

token, err := agent.AccessFor(ctx, MCP_URL, authsec.WithRequestedScopes("myapp:read"))

On behalf of a signed-in user (XAA / ID-JAG):

idToken, _ := authsec.BrowserLogin(ctx, ISSUER, CLIENT_ID, &authsec.BrowserLoginOptions{Resource: MCP_URL})

agent := authsec.NewAgentIdentity(authsec.AgentIdentityConfig{
Issuer: ISSUER, ClientID: CLIENT_ID,
Auth: authsec.NewClientSecretAuth(SECRET), IDPIssuer: ISSUER,
})
token, err := agent.AccessFor(ctx, MCP_URL,
authsec.WithUserSession(idToken),
authsec.WithRequestedScopes("myapp:read"))
// token: sub = the user, act.client_id = this agent — auditable delegation

Guides: M2M auth (all three methods) · ID-JAG delegation

Configuration (server side)

The required fields for a production deploy:

FieldSourceNotes
IssuerAuthSec base URLe.g. https://api.authsec.dev
AuthorizationServerAuthSec API originDefaults to Issuer when omitted. Used for the sdk-policy / sdk-manifest URLs.
JWKSURLJWKS endpointRequired for JWT validation modes
IntrospectionURLRFC 7662 endpointRequired for introspection validation modes
ResourceServerIDApplication detail panel, field idUUID
IntrospectionClientIDSame value as ResourceServerIDUsername for Basic auth
IntrospectionClientSecretOne-time secret from registrationStore in a secret manager
ResourceURIApplication detail panel, field resource_urlMust match the aud claim of issued tokens
PublishManifesttrue recommendedPushes tool inventory at startup

Optional but useful:

FieldPurpose
PolicyModePolicyModeRemoteRequired (default), PolicyModeRemoteWithLocalFallback, PolicyModeLocalOnly, PolicyModeOpen.
ValidationModeValidationModeJWTAndIntrospect (default), ValidationModeJWTOnly, ValidationModeIntrospectionOnly, ValidationModeJWTOrIntrospect.
ToolScopesLocal fallback policy (ToolScopeMap). Required if PolicyMode = PolicyModeRemoteWithLocalFallback.
ToolScopeSuggestionsPer-tool scope hints sent in the manifest payload.
ToolInventoryProviderfunc() ([]ManifestTool, error) — hand-curated manifest instead of synthetic enumeration.
SupportedScopesThe OAuth scopes this application advertises in PRM (fallback for the live scope matrix).
ScopeMatrixTTLHow long fetched policy is cached. Default 5 min — dashboard changes reach a running server within that window.
LoggerInject your own *slog.Logger. Every validate / authorize / deny emits a structured line.
HTTPClientInject your own *http.Client (default 10 s timeout).

MountMCP / NewRuntime call cfg.Validate(), which requires Issuer, an absolute ResourceURI, at least one of JWKSURL/IntrospectionURL, and introspection credentials when introspection is enabled.

Environment-variable config

For container deployments, use FromEnv() — it reads the AUTHSEC_* variables into a Config (parity with the Python SDK's from_env()):

cfg := authsec.FromEnv()             // reads AUTHSEC_* env vars (default prefix AUTHSEC_)
// FromEnv only parses; MountMCP/NewRuntime run cfg.Validate() and fail loudly.

mux := http.NewServeMux()
_ = authsec.MountMCP(mux, "/mcp", yourHandler, cfg)

Pass a prefix to override AUTHSEC_ — e.g. authsec.FromEnv("MYAPP_"). The variables it reads (all prefixed AUTHSEC_):

AUTHSEC_ISSUER
AUTHSEC_AUTHORIZATION_SERVER
AUTHSEC_JWKS_URL
AUTHSEC_INTROSPECTION_URL
AUTHSEC_INTROSPECTION_CLIENT_ID
AUTHSEC_INTROSPECTION_CLIENT_SECRET
AUTHSEC_RESOURCE_URI
AUTHSEC_RESOURCE_NAME
AUTHSEC_RESOURCE_SERVER_ID
AUTHSEC_SUPPORTED_SCOPES # JSON array or comma/space-separated
AUTHSEC_TOOL_SCOPES_JSON # {"tool": ["scope", ...]} — local fallback policy
AUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSON # {"tool": ["scope", ...]} — manifest hints
AUTHSEC_POLICY_MODE # remote_required | remote_with_local_fallback | local_only | open
AUTHSEC_VALIDATION_MODE # jwt_and_introspect | jwt_only | introspection_only | jwt_or_introspect
AUTHSEC_PUBLISH_MANIFEST # 1 | true | yes

FromEnv also accepts the legacy aliases the dashboard has emitted over time — AUTHSEC_JWKS_URI, AUTHSEC_INTROSPECTION_ENDPOINT, AUTHSEC_INTROSPECTION_ID, AUTHSEC_INTROSPECTION_SECRET, and AUTHSEC_RESOURCE — but new deploys should use the canonical names above. AUTHSEC_POLICY_MODE additionally accepts enforce (→ remote_required) and observe (→ open). Fields that have no env var (ToolInventoryProvider, HTTPClient, Logger, ScopeMatrixTTL, BearerMethodsSupported) are set on the struct after FromEnv().

Common scenarios

Protect a stdlib net/http server

The snippet at the top of this page. MountMCP is the easiest path when you use http.ServeMux.

Protect an application behind chi / gin / gorilla

MountMCP requires *http.ServeMux. For other routers, wrap the handler yourself:

rt, err := authsec.NewRuntime(cfg)
if err != nil {
log.Fatal(err)
}

protected := rt.Wrap(yourMCPHandler)
router.Handle("/mcp", protected)

// Also expose the metadata endpoint at the RFC 9728 path:
router.Handle(authsec.BuildResourceMetadataPath(cfg.ResourceURI), rt.ProtectedResourceHandler())

Omitting the metadata route breaks OAuth discovery for clients.

Run in observe-only mode

Validate tokens but skip scope checks while you finish mapping tools:

cfg.PolicyMode = authsec.PolicyModeOpen

The SDK attaches the validated Principal to the request context but allows every tool call. Switch back to PolicyModeRemoteRequired once mappings are in place.

Keep serving when AuthSec is briefly unreachable

cfg.PolicyMode = authsec.PolicyModeRemoteWithLocalFallback
cfg.ToolScopes = authsec.ToolScopeMap{
"list_repos": {"github.repo:read"},
"create_issue": {"github.issue:write"},
"health_check": {}, // empty slice = public
}

The local map is consulted only when the remote scope-matrix fetch fails. Keep it roughly in sync with the admin UI.

Read the authenticated principal

func myHandler(w http.ResponseWriter, r *http.Request) {
p, ok := authsec.PrincipalFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
log.Printf("subject=%s scopes=%v", p.Subject, p.Scopes)
}

Principal exposes Subject, Issuer, Audience (list), Scopes (list), Claims (raw JWT claim map), and Active.

Troubleshooting

SymptomLikely causeFix
MountMCP returns "invalid config"A required field is empty or ResourceURI is missing a schemeCheck the table above; cfg.Validate() returns specific field names.
Startup fails with policy fetch failedPolicyModeRemoteRequired + the application isn't activatedFinish launch, or use PolicyModeRemoteWithLocalFallback during onboarding.
401 invalid_token with audience mismatchToken's aud doesn't match cfg.ResourceURIOAuth client must request resource=<your ResourceURI>. MCP clients with proper discovery do this automatically.
403 insufficient_scope on a tool you mappedStale scope matrix or tool name doesn't match the manifestWait ScopeMatrixTTL (default 5 min) or restart. Confirm tool names in the admin UI.
Metadata path returns 404BuildResourceMetadataPath derives the path from ResourceURI/mcp resource → /.well-known/oauth-protected-resource/mcp; root resource → /.well-known/oauth-protected-resource.

Agent-side errors (*PendingApprovalError, *CredentialInvalidError, …) are covered in the M2M and delegation guides.

Behaviour notes

  • Unknown tool — denied when any policy is in effect. Only PolicyModeOpen allows unknown tools.
  • Parse-fail-closed — malformed JSON-RPC bodies are rejected without touching your handler.
  • Batch JSON-RPC — each entry is authorized independently.
  • In-band JSON-RPC denials — for JSON-RPC callers with a token present, a scope denial is returned in-band (HTTP 200 with result.isError + _meta.authsec) so MCP clients render a readable error; non-JSON-RPC callers get the classic 403.
  • Streaming tools/list — filtered the same way as the HTTP response.
  • Policy refresh — stale-on-read with a single-flight guard; the first request after the TTL kicks off a fetch.

Reference