Go SDK
One module, two sides of the MCP auth protocol:
| You are building | You need | Start here |
|---|---|---|
| An MCP server that must be protected | Token validation + per-tool RBAC | Protect your MCP server |
| An agent/service that calls a protected server | A token — as itself (M2M) or on behalf of a user | M2M 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 andtools/callchecked 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:
| Field | Source | Notes |
|---|---|---|
Issuer | AuthSec base URL | e.g. https://api.authsec.dev |
AuthorizationServer | AuthSec API origin | Defaults to Issuer when omitted. Used for the sdk-policy / sdk-manifest URLs. |
JWKSURL | JWKS endpoint | Required for JWT validation modes |
IntrospectionURL | RFC 7662 endpoint | Required for introspection validation modes |
ResourceServerID | Application detail panel, field id | UUID |
IntrospectionClientID | Same value as ResourceServerID | Username for Basic auth |
IntrospectionClientSecret | One-time secret from registration | Store in a secret manager |
ResourceURI | Application detail panel, field resource_url | Must match the aud claim of issued tokens |
PublishManifest | true recommended | Pushes tool inventory at startup |
Optional but useful:
| Field | Purpose |
|---|---|
PolicyMode | PolicyModeRemoteRequired (default), PolicyModeRemoteWithLocalFallback, PolicyModeLocalOnly, PolicyModeOpen. |
ValidationMode | ValidationModeJWTAndIntrospect (default), ValidationModeJWTOnly, ValidationModeIntrospectionOnly, ValidationModeJWTOrIntrospect. |
ToolScopes | Local fallback policy (ToolScopeMap). Required if PolicyMode = PolicyModeRemoteWithLocalFallback. |
ToolScopeSuggestions | Per-tool scope hints sent in the manifest payload. |
ToolInventoryProvider | func() ([]ManifestTool, error) — hand-curated manifest instead of synthetic enumeration. |
SupportedScopes | The OAuth scopes this application advertises in PRM (fallback for the live scope matrix). |
ScopeMatrixTTL | How long fetched policy is cached. Default 5 min — dashboard changes reach a running server within that window. |
Logger | Inject your own *slog.Logger. Every validate / authorize / deny emits a structured line. |
HTTPClient | Inject 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
| Symptom | Likely cause | Fix |
|---|---|---|
MountMCP returns "invalid config" | A required field is empty or ResourceURI is missing a scheme | Check the table above; cfg.Validate() returns specific field names. |
Startup fails with policy fetch failed | PolicyModeRemoteRequired + the application isn't activated | Finish launch, or use PolicyModeRemoteWithLocalFallback during onboarding. |
401 invalid_token with audience mismatch | Token's aud doesn't match cfg.ResourceURI | OAuth client must request resource=<your ResourceURI>. MCP clients with proper discovery do this automatically. |
403 insufficient_scope on a tool you mapped | Stale scope matrix or tool name doesn't match the manifest | Wait ScopeMatrixTTL (default 5 min) or restart. Confirm tool names in the admin UI. |
| Metadata path returns 404 | BuildResourceMetadataPath 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
PolicyModeOpenallows 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 classic403. - 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
- Module:
github.com/authsec-ai/sdk-authsec/packages/go-sdk - Source on GitHub:
sdk-authsec/packages/go-sdk - Quickstart binary:
examples/quickstart/main.go - Firstrun harness (CI-tested against a live backend):
examples/firstrun/ - Full operator README:
README.md - API docs on pkg.go.dev:
github.com/authsec-ai/sdk-authsec/packages/go-sdk
Related
- From zero to launched — the surrounding setup
- Register an application — where the Config values come from
- Map tools to scopes — populating the scope matrix the SDK fetches
- JWT vs token introspection — picking a
ValidationMode - Troubleshoot OAuth tokens — when 401 or 403 doesn't match expectations