Skip to content
theAuth

Protect an MCP server with OAuth 2.1 in Go

MCP clients expect an OAuth 2.1 authorization server in front of a remote MCP endpoint: discovery, dynamic client registration and PKCE. This guide runs one inside your Go server, guards the endpoint, scopes each tool, and rechecks revoked tokens where it matters.

  • Practitioner
  • 3 min read
On this page

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

  1. It calls your MCP endpoint with no token and gets a 401 whose WWW-Authenticate header points at the protected resource metadata.
  2. It reads that document to find your authorization server, then the server's own metadata to find its endpoints.
  3. It registers itself as a public client, with no secret and a loopback redirect.
  4. 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

  1. 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/mcpresource
  2. Step 2Configure the authorization server

    Declare the MCP endpoint as a protected resource with the scopes it understands. AllowAnonymousRegistration lets MCP clients register themselves, which is what they expect. RegistrationTokens are operator issued tokens for clients you register by hand, and AccessTokenRevocation keeps 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()
  3. Step 3Mount the server and guard the endpoint

    Mount serves the OAuth endpoints under /oauth/ and the discovery documents under /.well-known/. mcpresource.New checks 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))
  4. Step 4Check scopes per tool

    When your server routes tools to their own handlers, wrap each one in RequireScope after the middleware. A token without the scope gets 403 with an insufficient_scope challenge 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)
  5. Step 5Check discovery from the outside

    The authorization server metadata lists the endpoints under /oauth/ and code_challenge_methods_supported as S256. A call to the endpoint with no token answers 401, and its WWW-Authenticate header 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-authenticate
  6. Step 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_id and client_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"}'
  7. 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),
    )
  8. Step 8Connect an MCP client

    Point the client at https://mcp.example.com/mcp and 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 as http://127.0.0.1:8976/callback are accepted; a plain http redirect 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.
Questions

Frequently asked questions

Do MCP clients need a client ID from me?

No. With anonymous registration on, a client registers itself through dynamic client registration and gets its own client ID. Turn it off and use registration tokens if you want to approve every client.

Which PKCE methods are accepted?

Only S256. The authorization server metadata says so in code_challenge_methods_supported.

Does the key set check notice a revoked token?

No. It verifies the token offline, so a revoked token passes until it expires. Add a revocation checker that asks introspection for tools that can do damage.

Which signing algorithms does the validator accept?

EdDSA, ES256 and RS256 with keys of at least 2048 bits. Tokens signed with none or with an HMAC algorithm are always rejected.

Keep going
Next steps

Take it further with theAuth

The product page covers what it does, how to install it and where the docs live.