Protecting an MCP server in Go with OAuth 2.1
Protect an MCP server in Go with OAuth 2.1: publish RFC 9728 metadata, verify bearer tokens and enforce per-tool scopes, with code you can copy and adapt.

title: "Protecting an MCP server in Go with OAuth 2.1" published: true description: "Protect an MCP server in Go with OAuth 2.1: publish RFC 9728 metadata, verify bearer tokens and enforce per-tool scopes, with code you can copy and adapt." tags: [go, mcp, oauth, authentication] cover_image: https://raw.githubusercontent.com/glincker/theauth-go/051c32ae8d6fee4cf584e23a5eb400756ca61af7/docs/images/cover-mcp-authorization-go.png canonical_url:#
Protecting an MCP server in Go with OAuth 2.1#
By the end of this post you will know how to put OAuth in front of an MCP server written in Go. You will publish the metadata clients need, reject calls without a valid token, and allow each tool only for the scopes you choose.
Disclosure: I maintain theauth-go, the library used here. The project is about four months old and has 6 GitHub stars, and I point out where that matters. Everything below comes from its repo docs and code. For the normative text, read the MCP authorization spec (I cite the 2025-11-25 revision) and the RFCs linked below.
Repo: glincker/theauth-go (MIT licensed Go library). Docs: docs.theauth.dev/go.
My question is narrow. You wrote an MCP server in Go. A client, perhaps an AI agent acting for a person, connects and asks to run a tool. How does your server know who is asking, and whether this call may run?
TL;DR: the flow in five steps#
- The client calls your MCP server with no token and gets a 401.
- The
WWW-Authenticateheader in that 401 carries aresource_metadataURL. - The client reads your protected resource metadata (RFC 9728) and learns which authorization server to use.
- The client gets an access token from that server (authorization code with PKCE S256).
- The client retries with
Authorization: Bearer <token>. Your server checks the token, then checks the scope for the tool you are running.
How does MCP authorization work?#
A normal API has a known login page. An MCP client does not know your server in advance. It might be a desktop app connecting to a URL a user pasted in. Your server has to say, in a machine readable way, "here is who can give you a token for me."
That is what RFC 9728 protected resource metadata is for. Your MCP server publishes a small document at a well-known path. In theauth-go's docs, a resource identifier of mcp.example.com/api/mcp (served over HTTPS) gets this URL:
mcp.example.com/.well-known/oauth-protected-resource/api/mcpNote where /.well-known/ goes: between the host and the path, not at the end. A resource with no path uses the bare /.well-known/oauth-protected-resource. This is the detail that bites people who build the URL by appending.
The authorization server has its own document under RFC 8414. For an issuer of auth.example.com/tenant1, clients fetch /.well-known/oauth-authorization-server/tenant1. theauth-go also serves /.well-known/openid-configuration/tenant1 with the same document.
Here is the whole exchange:
MCP client MCP server (resource) Authorization server
| POST /api/mcp (no token) | |
|-------------------------------->| |
| 401 WWW-Authenticate: Bearer | |
| resource_metadata=".../.well-known/oauth-protected-resource/api/mcp"
|<--------------------------------| |
| GET resource metadata | |
|-------------------------------->| |
| GET authorization server metadata (RFC 8414) ----------------->|
| authorization code + PKCE (S256), ask for scopes ------------->|
|<----------------------------------------------- access token |
| POST /api/mcp Authorization: Bearer <token> |
|-------------------------------->| check token and scope |The first step is the one to remember. The 401 points the client at your metadata, so a client that knows nothing about you can find everything else. In theauth-go's mcpresource module every challenge includes that URL. The host in it comes from the incoming request, so it stays correct behind a proxy that sets the standard forwarding headers.
Which authorization server can issue the tokens?#
Any compliant one. If you use theauth-go, the same library can host it. Its authorization server speaks OAuth 2.1 with PKCE S256, and also supports DPoP (RFC 9449), token exchange (RFC 8693) and the device grant (RFC 8628). You need none of those for a first deployment.
The snippet below comes from the README quick start. I left out imports and the agent parts, and encryptionKey is a 32 byte []byte you load yourself. It declares your MCP server as a protected resource with its scopes:
a, err := theauth.New(theauth.Config{
Storage: memory.New(),
BaseURL: os.Getenv("AS_BASE_URL"),
EncryptionKey: encryptionKey, // 32 bytes, load from your secrets manager
AuthorizationServer: &theauth.AuthorizationServerConfig{
Issuer: os.Getenv("AS_BASE_URL"),
Resources: []theauth.ProtectedResource{
{Identifier: os.Getenv("MCP_RESOURCE"), Scopes: []string{"read", "write"}},
},
},
})Use a persistent storage backend (the README lists SQLite, Postgres and MySQL) instead of memory for anything beyond a demo. The resource identifier and the scopes are the contract between the two sides.
How do I verify tokens in a Go MCP server?#
mcpresource is a separate Go module with zero dependencies outside the standard library, so a resource server does not import the whole library. The snippet is the repo's examples/mcp-server, abridged. I dropped imports and main, and replaced the example's small envOr helper with plain os.Getenv calls.
v := mcpresource.New(
os.Getenv("MCP_RESOURCE"),
mcpresource.WithJWKS(os.Getenv("MCP_JWKS_URI")),
mcpresource.WithIntrospection(os.Getenv("MCP_INTROSPECT_URI"), os.Getenv("MCP_CLIENT_ID"), os.Getenv("MCP_CLIENT_SECRET")),
)
for _, d := range v.Diagnostics() {
log.Printf("[mcpresource %s] %s (code: %s)", d.Severity, d.Message, d.Code)
}
r := chi.NewRouter()
r.Use(v.Middleware)
r.Get("/tools", func(w http.ResponseWriter, r *http.Request) {
p, ok := v.Principal(r.Context())
if !ok {
http.Error(w, "no principal", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"subject": p.Subject,
"actor": p.Actor,
"actor_chain": p.ActorChain,
"scope": p.Scope,
})
})Three details matter here.
mcpresource.New takes your resource URI first. The options say how to verify tokens: a JWKS URL for signed tokens, and an introspection endpoint with the resource's client credentials.
Diagnostics() reports configuration problems at startup. Log them there rather than discover them in production.
v.Middleware is the line that makes the router protected. A missing or bad token gets a 401 with the metadata pointer.
The Principal has Subject (the human), Actor (who is calling) and ActorChain. The package docs say ActorChain lists the agents between the human and the caller, so log both. A tool call from an agent is never just "user 42". It is "agent X acting for user 42".
How do I protect an MCP tool with scopes in Go?#
Authentication answers who. Scopes answer what. The mcpresource source documents this guard, mounted after the middleware:
r.With(v.RequireScope("files:write")).Post("/tools/save", save)A principal missing the scope gets a 403. The response carries WWW-Authenticate: Bearer error="insufficient_scope", a scope parameter listing what is required, and the resource_metadata URL again. Because the response names the scopes, a client can ask for exactly those on re-authorization. That suits an agent that should request more access only when a call needs it.
If you prefer one table for all tools, the package example checks it by hand. I abridged it to the handler body, after reading the principal p:
toolScope := map[string]string{"logs.read": "logs:read", "deploy.create": "deploy:create"}
need, known := toolScope[tool]
if !known || !slices.Contains(p.Scope, need) {
http.Error(w, "tool not permitted", http.StatusForbidden)
return
}My rule either way: an unknown tool gets denied. Default to no. The Protect MCP tools guide covers this in more depth.
Limits, stated plainly#
The first limit is age. theauth-go's first commit was in June 2026, and mcpresource ships in the same repo. Six stars means little outside use. Run it against your own client before you trust it, and pin the version.
The second is the scope of my claim. I have not run a conformance test of every MCP client against it, so I will not say every client works. I can only point to the RFC behaviors the code and docs describe.
Third, you can do this without my library. The pieces are standard: serve the metadata document, return the right challenge, and check bearer tokens with a JWT library and your authorization server's keys. If you already run an authorization server such as Hydra or Dex, you may only need the resource side, written by hand or with a small module like mcpresource. Wanting less code to own is a reason to use a library, not a requirement.
Fourth, the example checks tokens but does not decide your policy. Which scopes exist, which agent may get which, and when a human must approve are design decisions the library cannot make for you.
The bottom line#
MCP authorization comes down to three moves. Publish protected resource metadata so clients can find your authorization server. Check the bearer token on every request. Check a scope per tool, and answer 403 with the scopes you need when it is missing. The mcpresource module covers the second and third with one middleware line and one guard per tool.
If you try it, start with examples/mcp-server in the repo, point it at a throwaway authorization server, and tell me what broke. What would you want a resource server library to check that this one does not?
If you want to read the code or try it, the repo is here:
GDS K S · thegdsks.com · building Glincker · follow on X @thegdsks
An MCP server that cannot say who is calling and what they may do is just an open port with a nice name.