Protect your MCP server
An MCP server exposes tools — functions an AI client can call. The moment those tools do anything that matters (read a customer record, open a pull request, charge a card) you have an authorization problem: any client that can reach the endpoint can call any tool. MCP itself has no notion of who is calling or what they're allowed to do.
This guide puts that missing layer in front of a Go MCP server. By the end,
every tool call is authenticated with an OAuth token, authorized against a
per-tool scope policy you control from a dashboard, and observable — and you
will have written no token-parsing, JWKS-fetching, or policy code. One
function call, MountMCP, installs all of it.
Time: ~15 minutes to a launched, protected server.
What you have before and after
BEFORE AFTER
────── ─────
POST /mcp tools/call ──▶ runs POST /mcp (no token) ──▶ 401 + how-to-auth
POST /mcp (bad token) ──▶ 401 (or in-band error)
anyone on the network POST /mcp (missing scope) ──▶ 403 for that tool
can call every tool POST /mcp (valid + scoped)──▶ tool runs ✅
+ your tools listed in the dashboard
+ admins assign per-tool permissions there
+ policy changes live in ~5 min, no redeploy
Nothing about your MCP handler changes. The SDK wraps it; your tool code stays a plain MCP handler that only ever runs for authorized calls.
Architecture: where the SDK sits
AuthSec splits into two roles that map onto the OAuth model:
- The authorization server (AuthSec). Issues tokens, runs the login and consent flow, exposes JWKS and introspection, and holds the authoritative scope matrix (which tool needs which scope). You never run this — it's the AuthSec service.
- The resource server (your MCP server). Holds the tools and validates incoming tokens. This is where the SDK runs, as a thin wrapper around your handler.
AI client ──POST /mcp (Bearer token)──▶ your MCP server
(Claude, Cursor, ┌────────────────────────────┐
Codex, an agent) │ go-sdk (MountMCP wrapper) │
▲ │ validate → authorize → run │
│ 401 points here └─────────────┬──────────────┘
│ │ validate token,
▼ │ fetch scope policy
AuthSec (authorization server) ◀──────────────────────┘
token endpoint · JWKS · introspection · scope matrix · consent
The wrapper talks to AuthSec over HTTPS to verify tokens and fetch policy; your handler never does.
The request lifecycle, in detail
This is what actually happens on every call once the SDK is mounted. Reading
it once makes the rest of the guide — and every error you might hit —
obvious. The code path is runtime.go's
Wrap, backed by validator.go.
POST /mcp { "jsonrpc":"2.0", "method":"tools/call", "params":{"name":"add_no"} }
Authorization: Bearer eyJ...
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ 0. Metadata short-circuit │
│ If the path is the RFC 9728 metadata path, serve the PRM doc and │
│ stop. (MountMCP registers this route for you.) │
├───────────────────────────────────────────────────────────────────────┤
│ 1. Parse the bearer token │
│ Missing header → empty token (flows to the denial path). │
│ Malformed "Authorization" value → 400. │
├───────────────────────────────────────────────────────────────────────┤
│ 2. Validate the token → Principal │
│ (a) JWT signature: RS256, verified against AuthSec's JWKS (keys │
│ cached by kid). iss must equal cfg.Issuer; exp is enforced. │
│ (b) Revocation: live introspection (POST /oauth/introspect, Basic │
│ auth with the introspection credentials). Catches tokens │
│ revoked seconds ago that still look valid. │
│ (c) Audience: the token's aud (or resource claim) must include │
│ cfg.ResourceURI — a token minted for another server is rejected.│
│ Any failure → nil principal → denial path (step 5). │
├───────────────────────────────────────────────────────────────────────┤
│ 3. Parse the MCP body (fail-closed) │
│ A non-empty body that isn't valid JSON-RPC → 400, handler never │
│ runs. Handshake/empty bodies pass straight through. │
├───────────────────────────────────────────────────────────────────────┤
│ 4. Authorize the tool (tools/call only) │
│ Look the tool up in the scope matrix (fetched from AuthSec, cached │
│ ~5 min): │
│ • tool not in the matrix → deny (fail-closed) │
│ • tool public (empty scopes) → allow │
│ • tool scoped → allow iff the token has ANY of │
│ the required scopes │
│ tools/list is handled differently: the response is filtered so the │
│ caller only sees tools its scopes permit. │
├───────────────────────────────────────────────────────────────────────┤
│ 5. Allow → your handler runs. Deny → structured error (next section).│
└───────────────────────────────────────────────────────────────────────┘
What a denial looks like (and why it's not always a raw 401)
The SDK is MCP-client-aware (ported from the Python/TS runtimes in
mcp_inband.go).
How a denial is delivered depends on the caller:
- No token, or a non-JSON-RPC body → a classic HTTP challenge:
401 Unauthorizedwith aWWW-Authenticate: Bearer resource_metadata="…"header pointing the client at the login flow, or403for a scope failure. - A token is present and the body is JSON-RPC → the denial is returned
in-band: HTTP
200with a JSON-RPC error (or atools/callresult withisError: true) carrying a_meta.authsec/data.authsecobject (error,status,required_scopes,granted_scopes,tool) and a plain-English message. This is deliberate: an MCP client that gets a raw401mid-session often drops the whole session, whereas an in-band error lets the model read "tool X needs scope Y; ask an admin" and react. The JSON-RPC error codes are-32003for a403and-32001for a401. - MCP handshake methods (
initialize,notifications/initialized,ping) pass through even with an invalid token, so a session survives a mid-session token expiry instead of breaking on the next keep-alive. - Policy backend unreachable (
PolicyModeRemoteRequiredand AuthSec is down) →503, never a silent allow. Failing closed is the point.
You don't choose between these — the SDK picks the right one per request. What
matters is knowing that a 200 with isError:true is a denial.
Two things that happen once, at startup
MountMCP / NewRuntime also kick off two background actions the first time
they run:
- PRM publishing. The SDK serves an RFC 9728
protected-resource metadata document at
/.well-known/oauth-protected-resource/mcp. Clients fetch it to discover which authorization server to get a token from. You never write this endpoint;MountMCPregisters it. - Manifest publishing (when
PublishManifestis true). The SDK performs a synthetictools/listagainst your un-wrapped handler, thenPUTs the resulting tool inventory to/authsec/resource-servers/<ResourceServerID>/sdk-manifest(authenticated with your introspection credentials). This is a one-way push so the dashboard can show your tools by name and let admins map scopes to them. It is best-effort: a publish failure is logged and never blocks startup or request handling.
Prerequisites
- An AuthSec workspace on https://mcpauthz.com.
- Go ≥ 1.24 and the SDK:
One dependency (
go get github.com/authsec-ai/sdk-authsec/packages/go-sdkgithub.com/golang-jwt/jwt/v5); no CGO. - A public URL for your server — the AI client and AuthSec must both reach
it. For local development use an ngrok tunnel
(
ngrok http 8000→https://xxxx.ngrok-free.app).
Step 1 — Create the Application in the dashboard
This guide covers the Go-specific integration. The dashboard workflow it builds on — sign in, create the Application, pick a scope preset — is identical for every language and is covered once in From zero to launched and Register an application. Create your Application there, then bring two things back here:
- The Application's Resource URI —
<public base URL><protected path>, e.g.https://xxxx.ngrok-free.app/mcp. It becomes your token audience and must exactly match the URL clients call (scheme, host, path), because tokens are bound to it (RFC 8707). A mismatch is the most common cause of401s. - The
.envblock from the Application's Setup tab — choose Go and .env, and the dashboard renders copy-paste-ready values, including the one-time introspection secret. That block is the input for the next step.
Step 2 — Configure the environment
Paste the Setup-tab values into a .env next to your server. The SDK reads
them with FromEnv():
# Who issues tokens (the authorization server)
AUTHSEC_ISSUER=https://mcpauthz.com
AUTHSEC_AUTHORIZATION_SERVER=https://mcpauthz.com
AUTHSEC_JWKS_URL=https://mcpauthz.com/oauth/jwks
AUTHSEC_INTROSPECTION_URL=https://mcpauthz.com/oauth/introspect
# This application's identity (unique per application — from the Setup tab)
AUTHSEC_RESOURCE_SERVER_ID=<resource-server-uuid>
AUTHSEC_INTROSPECTION_CLIENT_ID=<resource-server-uuid>
AUTHSEC_INTROSPECTION_CLIENT_SECRET=<sec_...>
# Your server's public identity — must match the Resource URI from step 2a
AUTHSEC_RESOURCE_URI=https://xxxx.ngrok-free.app/mcp
AUTHSEC_RESOURCE_NAME=my-mcp-server
# Recommended production posture
AUTHSEC_POLICY_MODE=remote_required # fail CLOSED if policy is unavailable
AUTHSEC_VALIDATION_MODE=jwt_and_introspect # signature check + live revocation check
AUTHSEC_PUBLISH_MANIFEST=true # keep the tool inventory synced to the dashboard
Why these two settings are the production default:
remote_required— if the server can't fetch the tool→scope policy from AuthSec, it denies every tool call (503) rather than guessing. The alternative, failing open, means a network blip silently disables your authorization. (For local onboarding you can relax this — seePolicyMode.)jwt_and_introspect— a JWT signature proves the token was minted by AuthSec and hasn't expired, but it cannot tell you the token was revoked five minutes ago. Introspection asks AuthSec live. Belt and suspenders; it's the default when bothJWKS_URLandINTROSPECTION_URLare set.
FromEnv()reads, it does not validate. It only parses theAUTHSEC_*variables into aConfig.MountMCP/NewRuntimeruncfg.Validate()and fail loudly with the exact missing field.FromEnvalso accepts the legacy aliases the dashboard has emitted over time (AUTHSEC_JWKS_URI,AUTHSEC_INTROSPECTION_ENDPOINT,AUTHSEC_RESOURCE, …); prefer the canonical names above. Pass a prefix argument —FromEnv("MYAPP_")— to change theAUTHSEC_prefix.
Step 3 — Write the server
A complete, runnable server: MountMCP in front of a minimal MCP handler with
two tools, fully protected.
// server.go — go run .
package main
import (
"encoding/json"
"log"
"net/http"
authsec "github.com/authsec-ai/sdk-authsec/packages/go-sdk"
)
func main() {
cfg := authsec.FromEnv() // reads all AUTHSEC_* vars from step 2
// Optional: a hand-curated manifest with suggested scopes. Omit it and the
// SDK enumerates your tools automatically at startup (see the note below).
cfg.ToolInventoryProvider = func() ([]authsec.ManifestTool, error) {
return []authsec.ManifestTool{
{
Name: "add_no",
Description: "Add two numbers",
InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`),
SuggestedScopes: []string{"my_mcp:tools:read"},
},
{
Name: "multiply_no",
Description: "Multiply two numbers",
InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`),
SuggestedScopes: []string{"my_mcp:tools:write"},
},
}, nil
}
mux := http.NewServeMux()
if err := authsec.MountMCP(mux, "/mcp", http.HandlerFunc(mcpHandler), cfg); err != nil {
log.Fatal(err) // returns an error only on an invalid config
}
log.Printf("listening on :8000 — resource_uri=%s", cfg.ResourceURI)
log.Fatal(http.ListenAndServe(":8000", mux))
}
// mcpHandler is a minimal MCP JSON-RPC handler. Real servers use an MCP
// framework; this shows the SDK wraps any plain http.Handler.
func mcpHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments map[string]float64 `json:"arguments"`
} `json:"params"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
reply := func(result any) {
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}
switch req.Method {
case "tools/list":
reply(map[string]any{"tools": []map[string]any{
{"name": "add_no", "description": "Add two numbers"},
{"name": "multiply_no", "description": "Multiply two numbers"},
}})
case "tools/call":
a, b := req.Params.Arguments["a"], req.Params.Arguments["b"]
out := a + b
if req.Params.Name == "multiply_no" {
out = a * b
}
reply(map[string]any{"content": []map[string]any{{"type": "text", "text": jsonNumber(out)}}})
default: // initialize, notifications/*, ping, …
reply(map[string]any{"ok": true})
}
}
func jsonNumber(f float64) string { b, _ := json.Marshal(f); return string(b) }
Run it: go run ..
What each piece does:
cfg := authsec.FromEnv()builds theConfigfrom your.env. Prefer environment config for anything containerized; for programmatic control you can build theConfig{}struct literal directly instead (every field is documented in the Go SDK reference).cfg.ToolInventoryProvideris the escape hatch for manifest publishing. When set, the SDK publishes exactly the tools you return here, with theSuggestedScopesyou specify. Omit it and the SDK enumerates your tools automatically at startup with a synthetic MCP handshake (initialize→notifications/initialized→tools/list) against your un-wrapped handler — the schemas then come straight from your real tool definitions. Set it explicitly when synthetic enumeration doesn't fit (custom auth oninitialize, a non-HTTP transport, a static registry) or when you want to attach suggested scopes per tool.ManifestToolfields:Name(the string clients send intools/call— the join key, so don't rename it lightly),Description(shown to operators and, for high-risk tools, on the consent screen),InputSchema(recorded for the UI; reserved for future per-argument policy), andSuggestedScopes(admin-facing hints — the admin can accept or override them; they do not enforce anything at runtime).MountMCP(mux, "/mcp", handler, cfg)is the whole integration. It runscfg.Validate(), constructs the runtime, registers two routes on your mux — the wrapped/mcphandler and the RFC 9728 metadata endpoint — and kicks off the background manifest publish. It returns an error only on a bad config (a missing/invalid field), solog.Fatalon it and you're done.
Notice what you did not write: no token parsing, no JWKS fetching, no scope checks, no metadata endpoint, no per-tool guards. Your handler stays a plain MCP handler and only runs for calls that already passed every gate.
Behind another router (chi, gin, gorilla)?
MountMCPneeds an*http.ServeMux. For other routers, build the runtime yourself and wire both routes:rt, err := authsec.NewRuntime(cfg)
// ...
router.Handle("/mcp", rt.Wrap(yourHandler))
router.Handle(authsec.BuildResourceMetadataPath(cfg.ResourceURI), rt.ProtectedResourceHandler())Omitting the metadata route breaks OAuth discovery for clients. See the Go SDK overview.
Step 4 — Run and verify protection
With the server running (and an ngrok http 8000 tunnel if local):
Check 1 — the PRM document is served (this is how clients discover where to authenticate):
curl https://xxxx.ngrok-free.app/.well-known/oauth-protected-resource/mcp
{
"resource": "https://xxxx.ngrok-free.app/mcp",
"authorization_servers": ["https://mcpauthz.com"],
"scopes_supported": ["my_mcp:read", "my_mcp:tools:read", "..."]
}
scopes_supported is served live from the scope matrix, so scopes you add in
the dashboard show up here without a redeploy.
Check 2 — unauthenticated calls are rejected:
curl -i -X POST https://xxxx.ngrok-free.app/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata=
"https://xxxx.ngrok-free.app/.well-known/oauth-protected-resource/mcp"
The 401 isn't a dead end — the WWW-Authenticate header tells a
well-behaved client exactly where to authenticate.
Check 3 — your tools appeared in the dashboard. Because
AUTHSEC_PUBLISH_MANIFEST=true, the SDK published your inventory at startup.
Open the Application's Tools tab and confirm your tools are listed — that
proves the startup manifest publish worked.
Step 5 — Review access and launch
Three dashboard steps stand between your running server and a launched one — all of it the standard Application lifecycle, documented once. Follow those pages, then come back:
- Run the protection check — Application → Setup → Run protection check. It probes your live server (PRM, the 401 challenge, the published manifest, the scope matrix). On a fresh Application the only failing check is Default access policy, fixed by the next step.
- Set a default access policy and map your tools to scopes — see
Access policy and
Map tools to scopes. This is the step
that matters most for Go: a tool with no scope mapping is denied by
default (the fail-closed behavior from
the request lifecycle), so nothing is
callable until you map it — e.g.
add_no→my_mcp:tools:read,multiply_no→my_mcp:tools:write. - Launch — see Launch. Once live, your running server picks up policy changes within ~5 minutes (the scope-matrix cache) — no restart, no redeploy.
Who gets the roles you configure — service accounts, agents, users — is the subject of the M2M and ID-JAG delegation guides.
Step 6 — Call a tool end-to-end
Use Run test on the Overview tab, or curl with a real token (from the M2M or delegation guide):
curl -X POST https://xxxx.ngrok-free.app/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":2,
"params":{"name":"add_no","arguments":{"a":2,"b":3}}}'
- Token has
my_mcp:tools:read→add_noruns, returns5✅ - Same token calls
multiply_no(needs:write) → denied. A JSON-RPC caller with a token gets an in-band error (200,isError:true,_meta.authsec.required_scopes: ["my_mcp:tools:write"]); a plain caller gets403 insufficient_scope. tools/listis filtered — a caller only sees the tools its scopes allow.
The Monitor tab shows each request as it lands — token subject, tool, allow/deny.
Configuration reference
Every field the SDK reads, and what it does. Set them via FromEnv() (the
AUTHSEC_* column) or directly on the Config struct.
Config field | Env var | Purpose |
|---|---|---|
Issuer | AUTHSEC_ISSUER | AuthSec base URL. Must equal the token's iss. |
AuthorizationServer | AUTHSEC_AUTHORIZATION_SERVER | API origin for policy/manifest URLs. Defaults to Issuer. |
JWKSURL | AUTHSEC_JWKS_URL | JWKS endpoint for JWT signature verification. |
IntrospectionURL | AUTHSEC_INTROSPECTION_URL | RFC 7662 endpoint for the live revocation check. |
IntrospectionClientID | AUTHSEC_INTROSPECTION_CLIENT_ID | Basic-auth user for introspection + manifest publish (the RS UUID). |
IntrospectionClientSecret | AUTHSEC_INTROSPECTION_CLIENT_SECRET | The one-time secret from registration. Store in a secret manager. |
ResourceURI | AUTHSEC_RESOURCE_URI | This server's identity; must equal the token aud. Must be an absolute URL. |
ResourceName | AUTHSEC_RESOURCE_NAME | Human label in the WWW-Authenticate realm and PRM. |
ResourceServerID | AUTHSEC_RESOURCE_SERVER_ID | RS UUID. When set, the SDK fetches the authoritative scope matrix. |
SupportedScopes | AUTHSEC_SUPPORTED_SCOPES | Scopes advertised in PRM as a fallback — the live matrix wins when reachable. |
PublishManifest | AUTHSEC_PUBLISH_MANIFEST | Push the tool inventory to the dashboard at startup. true recommended. |
PolicyMode | AUTHSEC_POLICY_MODE | remote_required (default with an RS ID), remote_with_local_fallback, local_only, open. |
ValidationMode | AUTHSEC_VALIDATION_MODE | jwt_and_introspect (default with both URLs), jwt_only, introspection_only, jwt_or_introspect. |
ToolScopes | AUTHSEC_TOOL_SCOPES_JSON | Local tool→scope map. Required for remote_with_local_fallback; authoritative for local_only. |
ToolScopeSuggestions | AUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSON | Per-tool scope hints sent in the manifest (admin-facing; no runtime effect). |
ToolInventoryProvider | — | func() ([]ManifestTool, error) — hand-curated manifest instead of synthetic enumeration. |
ScopeMatrixTTL | — | How long fetched policy is cached. Default 5 min. |
Logger | — | Your *slog.Logger. Every validate / authorize / deny emits a structured line. |
HTTPClient | — | Your *http.Client (default 10 s timeout). |
PolicyMode decides where per-tool policy comes from and what happens when AuthSec is unreachable:
remote_required— fetch from AuthSec; deny (503) if unreachable. The production default.remote_with_local_fallback— fetch from AuthSec, fall back toToolScopeson failure (soToolScopesis required).local_only— ignore AuthSec; useToolScopesonly. Good for air-gapped tests.open— no per-tool policy; any valid token may call any tool. An "observe" mode for finishing your scope mapping — the SDK still attaches the validatedPrincipalto the context, it just doesn't gate.
ValidationMode decides how a token is checked (see step 2 of the request
lifecycle). jwt_and_introspect is strict: a JWT must pass local verification
and introspection; introspection cannot rescue a JWT that failed locally.
jwt_or_introspect is the permissive legacy mode kept for migration.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
MountMCP returns "invalid config" at startup | A required field is empty or ResourceURI has no scheme/host | cfg.Validate() names the field — check the reference above |
Startup fails with policy fetch failed | remote_required + the application isn't launched yet | Finish launch, or use remote_with_local_fallback during onboarding |
Every tool call returns 403 / in-band deny, tools show denied | Tools aren't mapped, or no default access policy | Step 5 — set a default access policy, then map each tool |
| A tool stays denied after launch | "No label assigned" — unmapped tools fail closed | Map the tool to an access label — see Map tools to scopes |
401 even with a fresh token | AUTHSEC_RESOURCE_URI doesn't exactly match the URL the client calls (scheme/host/path) | Make them identical; re-check after an ngrok URL change |
| Tools don't appear in the Tools tab | AUTHSEC_PUBLISH_MANIFEST not true, or the startup PUT failed | Check boot logs; verify introspection credentials |
| Scope change not taking effect | Within the 5-minute cache window | Wait ~5 min, or restart; confirm the server can reach mcpauthz.com |
| Everything breaks when AuthSec is briefly unreachable | remote_required failing closed — by design | Dev only: relax AUTHSEC_POLICY_MODE; keep fail-closed in production |
| Metadata path returns 404 | Wrong PRM path for a path-based resource | Use BuildResourceMetadataPath(cfg.ResourceURI); /mcp → /.well-known/oauth-protected-resource/mcp |
Send Authorization: Bearer garbage, get 200 | Your middleware isn't actually running | Confirm MountMCP wraps the same route the client hits |
Implementation reference
The files that implement this behavior:
- Token validation —
validator.go. Injwt_and_introspect, a JWT-shaped token is (1) RSA-signature-verified against AuthSec's JWKS withiss/expchecks, then (2) introspected live so revoked tokens die immediately; introspection is authoritative foractive/scopes/aud. Opaque tokens skip straight to introspection. - Scope matrix —
scope_matrix_client.go. The tool→scope mapping you configure in the dashboard is fetched from AuthSec and cached for 5 minutes — that's the "propagates in ~5 minutes" guarantee. - Manifest —
manifest_publisher.go. At startup the SDK builds the inventory (synthetictools/listor yourToolInventoryProvider) andPUTs it to AuthSec. - In-band denials —
mcp_inband.go. The MCP-client-aware error behavior described above.
Related
- Go SDK overview & configuration reference — every
Configfield and env var - Machine-to-machine auth — get a token to call this server
- ID-JAG delegation — call it on behalf of a user
- What is a tool manifest? — the inventory the SDK publishes
- How AuthSec protects your MCP server — the conceptual model