Skip to main content

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 Unauthorized with a WWW-Authenticate: Bearer resource_metadata="…" header pointing the client at the login flow, or 403 for a scope failure.
  • A token is present and the body is JSON-RPC → the denial is returned in-band: HTTP 200 with a JSON-RPC error (or a tools/call result with isError: true) carrying a _meta.authsec / data.authsec object (error, status, required_scopes, granted_scopes, tool) and a plain-English message. This is deliberate: an MCP client that gets a raw 401 mid-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 -32003 for a 403 and -32001 for a 401.
  • 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 (PolicyModeRemoteRequired and 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; MountMCP registers it.
  • Manifest publishing (when PublishManifest is true). The SDK performs a synthetic tools/list against your un-wrapped handler, then PUTs 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:
    go get github.com/authsec-ai/sdk-authsec/packages/go-sdk
    One dependency (github.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 8000https://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 of 401s.
  • The .env block 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 — see PolicyMode.)
  • 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 both JWKS_URL and INTROSPECTION_URL are set.

FromEnv() reads, it does not validate. It only parses the AUTHSEC_* variables into a Config. MountMCP / NewRuntime run cfg.Validate() and fail loudly with the exact missing field. FromEnv also 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 the AUTHSEC_ 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 the Config from your .env. Prefer environment config for anything containerized; for programmatic control you can build the Config{} struct literal directly instead (every field is documented in the Go SDK reference).
  • cfg.ToolInventoryProvider is the escape hatch for manifest publishing. When set, the SDK publishes exactly the tools you return here, with the SuggestedScopes you specify. Omit it and the SDK enumerates your tools automatically at startup with a synthetic MCP handshake (initializenotifications/initializedtools/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 on initialize, a non-HTTP transport, a static registry) or when you want to attach suggested scopes per tool.
  • ManifestTool fields: Name (the string clients send in tools/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), and SuggestedScopes (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 runs cfg.Validate(), constructs the runtime, registers two routes on your mux — the wrapped /mcp handler 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), so log.Fatal on 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)? MountMCP needs 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:

  1. Run the protection check — Application → SetupRun 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.
  2. 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_nomy_mcp:tools:read, multiply_nomy_mcp:tools:write.
  3. 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:readadd_no runs, returns 5
  • 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 gets 403 insufficient_scope.
  • tools/list is 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 fieldEnv varPurpose
IssuerAUTHSEC_ISSUERAuthSec base URL. Must equal the token's iss.
AuthorizationServerAUTHSEC_AUTHORIZATION_SERVERAPI origin for policy/manifest URLs. Defaults to Issuer.
JWKSURLAUTHSEC_JWKS_URLJWKS endpoint for JWT signature verification.
IntrospectionURLAUTHSEC_INTROSPECTION_URLRFC 7662 endpoint for the live revocation check.
IntrospectionClientIDAUTHSEC_INTROSPECTION_CLIENT_IDBasic-auth user for introspection + manifest publish (the RS UUID).
IntrospectionClientSecretAUTHSEC_INTROSPECTION_CLIENT_SECRETThe one-time secret from registration. Store in a secret manager.
ResourceURIAUTHSEC_RESOURCE_URIThis server's identity; must equal the token aud. Must be an absolute URL.
ResourceNameAUTHSEC_RESOURCE_NAMEHuman label in the WWW-Authenticate realm and PRM.
ResourceServerIDAUTHSEC_RESOURCE_SERVER_IDRS UUID. When set, the SDK fetches the authoritative scope matrix.
SupportedScopesAUTHSEC_SUPPORTED_SCOPESScopes advertised in PRM as a fallback — the live matrix wins when reachable.
PublishManifestAUTHSEC_PUBLISH_MANIFESTPush the tool inventory to the dashboard at startup. true recommended.
PolicyModeAUTHSEC_POLICY_MODEremote_required (default with an RS ID), remote_with_local_fallback, local_only, open.
ValidationModeAUTHSEC_VALIDATION_MODEjwt_and_introspect (default with both URLs), jwt_only, introspection_only, jwt_or_introspect.
ToolScopesAUTHSEC_TOOL_SCOPES_JSONLocal tool→scope map. Required for remote_with_local_fallback; authoritative for local_only.
ToolScopeSuggestionsAUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSONPer-tool scope hints sent in the manifest (admin-facing; no runtime effect).
ToolInventoryProviderfunc() ([]ManifestTool, error) — hand-curated manifest instead of synthetic enumeration.
ScopeMatrixTTLHow long fetched policy is cached. Default 5 min.
LoggerYour *slog.Logger. Every validate / authorize / deny emits a structured line.
HTTPClientYour *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 to ToolScopes on failure (so ToolScopes is required).
  • local_only — ignore AuthSec; use ToolScopes only. 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 validated Principal to 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

SymptomCauseFix
MountMCP returns "invalid config" at startupA required field is empty or ResourceURI has no scheme/hostcfg.Validate() names the field — check the reference above
Startup fails with policy fetch failedremote_required + the application isn't launched yetFinish launch, or use remote_with_local_fallback during onboarding
Every tool call returns 403 / in-band deny, tools show deniedTools aren't mapped, or no default access policyStep 5 — set a default access policy, then map each tool
A tool stays denied after launch"No label assigned" — unmapped tools fail closedMap the tool to an access label — see Map tools to scopes
401 even with a fresh tokenAUTHSEC_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 tabAUTHSEC_PUBLISH_MANIFEST not true, or the startup PUT failedCheck boot logs; verify introspection credentials
Scope change not taking effectWithin the 5-minute cache windowWait ~5 min, or restart; confirm the server can reach mcpauthz.com
Everything breaks when AuthSec is briefly unreachableremote_required failing closed — by designDev only: relax AUTHSEC_POLICY_MODE; keep fail-closed in production
Metadata path returns 404Wrong PRM path for a path-based resourceUse BuildResourceMetadataPath(cfg.ResourceURI); /mcp/.well-known/oauth-protected-resource/mcp
Send Authorization: Bearer garbage, get 200Your middleware isn't actually runningConfirm MountMCP wraps the same route the client hits

Implementation reference

The files that implement this behavior:

  • Token validationvalidator.go. In jwt_and_introspect, a JWT-shaped token is (1) RSA-signature-verified against AuthSec's JWKS with iss/exp checks, then (2) introspected live so revoked tokens die immediately; introspection is authoritative for active/scopes/aud. Opaque tokens skip straight to introspection.
  • Scope matrixscope_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.
  • Manifestmanifest_publisher.go. At startup the SDK builds the inventory (synthetic tools/list or your ToolInventoryProvider) and PUTs it to AuthSec.
  • In-band denialsmcp_inband.go. The MCP-client-aware error behavior described above.