Before you start
- A Go HTTP server for your MCP endpoint, on a public HTTPS address.
- A sign in page for your users, because the authorize endpoint sends anyone without a session there.
- A secrets manager or environment that can hold a 32 byte encryption key.
What an MCP client does
- It calls your MCP endpoint with no token and gets a
401whoseWWW-Authenticateheader points at the protected resource metadata. - It reads that document to find your authorization server, then the server's own metadata to find its endpoints.
- It registers itself as a public client, with no secret and a loopback redirect.
- It sends the user through the authorization code flow with PKCE, then calls your endpoint with the access token.
Every step here makes one of those work, with the authorization server and the MCP endpoint in one process on one origin.
Set it up
Step 1Add the modules
The authorization server is in the main module. The resource server middleware is a separate module, so an MCP server on its own can use it without the rest.
bash go get github.com/glincker/theauth-go/v2 github.com/glincker/theauth-go/mcpresourceStep 2Configure the authorization server
Declare the MCP endpoint as a protected resource with the scopes it understands.
AllowAnonymousRegistrationlets MCP clients register themselves, which is what they expect.RegistrationTokensare operator issued tokens for clients you register by hand, andAccessTokenRevocationkeeps a denylist entry for each revoked token until it expires.auth.go const ( issuer = "https://mcp.example.com" resource = "https://mcp.example.com/mcp" ) a, err := theauth.New(theauth.Config{ Storage: store, BaseURL: issuer, EncryptionKey: loadEncryptionKey(), // 32 bytes from your secrets manager AuthorizationServer: &theauth.AuthorizationServerConfig{ Issuer: issuer, Resources: []theauth.ProtectedResource{{ Identifier: resource, Scopes: []string{"files.read", "files.write"}, }}, AllowAnonymousRegistration: true, RegistrationTokens: []string{loadRegistrationToken()}, // never an empty string AccessTokenRevocation: true, }, }) if err != nil { log.Fatal(err) } defer a.Close()Step 3Mount the server and guard the endpoint
Mountserves the OAuth endpoints under/oauth/and the discovery documents under/.well-known/.mcpresource.Newchecks each token's signature against the server's key set, its audience against your resource, and its expiry.go r := chi.NewRouter() a.Mount(r) v := mcpresource.New(resource, mcpresource.WithJWKS(issuer+"/oauth/jwks")) r.Handle("/mcp", v.Middleware(mcpHandler)) log.Fatal(http.ListenAndServe(":8080", r))Step 4Check scopes per tool
When your server routes tools to their own handlers, wrap each one in
RequireScopeafter the middleware. A token without the scope gets403with aninsufficient_scopechallenge naming the scope, so the client can ask for exactly that.go r.With(v.Middleware, v.RequireScope("files.read")).Post("/tools/read_file", readFile) r.With(v.Middleware, v.RequireScope("files.write")).Post("/tools/write_file", writeFile)Step 5Check discovery from the outside
The authorization server metadata lists the endpoints under
/oauth/andcode_challenge_methods_supportedasS256. A call to the endpoint with no token answers401, and itsWWW-Authenticateheader points at/.well-known/oauth-protected-resource/mcp, which is how a client finds the server.bash curl -s https://mcp.example.com/.well-known/oauth-authorization-server curl -si -X POST https://mcp.example.com/mcp | grep -i www-authenticateStep 6Register the MCP server for introspection
To ask the authorization server about a token, the MCP server needs a confidential client of its own. Register it with an operator token; the response holds its
client_idandclient_secret.bash curl -s https://mcp.example.com/oauth/register \ -H "Authorization: Bearer $REGISTRATION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"client_name":"mcp-server","redirect_uris":["https://mcp.example.com/cb"],"grant_types":["authorization_code"],"token_endpoint_auth_method":"client_secret_basic"}'Step 7Recheck revoked tokens on risky tools
The key set check works offline, so it cannot see a token revoked a minute ago. For tools that can do damage, use a second validator with a revocation checker that asks introspection, and guard those routes with it. Tokens that carry an actor chain or a delegation grant are always rechecked through introspection.
go revocation := mcpresource.RevocationCheckerFunc( func(ctx context.Context, token string, _ *mcpresource.Principal) (bool, error) { form := url.Values{"token": {token}}.Encode() req, _ := http.NewRequestWithContext(ctx, http.MethodPost, issuer+"/oauth/introspect", strings.NewReader(form)) req.SetBasicAuth(rsClientID, rsClientSecret) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := http.DefaultClient.Do(req) if err != nil { return false, err // refused unless you opt in to fail open } defer res.Body.Close() var out struct { Active bool `json:"active"` } if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return false, err } return !out.Active, nil }) strict := mcpresource.New(resource, mcpresource.WithJWKS(issuer+"/oauth/jwks"), mcpresource.WithIntrospection(issuer+"/oauth/introspect", rsClientID, rsClientSecret), mcpresource.WithRevocationChecker(revocation), )Step 8Connect an MCP client
Point the client at
https://mcp.example.com/mcpand let it discover the rest. It registers itself, opens your sign in page in the browser, and comes back with a token scoped to your resource. Loopback redirects such ashttp://127.0.0.1:8976/callbackare accepted; a plainhttpredirect on any other host is refused.
Before you open it up
- Open registration lets anyone create a client record. Rate limit it, or turn it off and hand out registration tokens.
- Revocation through the denylist is only as fresh as the introspection cache, set by
IntrospectionCacheTTL. - Keep the encryption key out of source control and out of logs, and load it at start up.