diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index e090f9f6e8c6938a438e8649a69c042d929d4d75..c720476f3bf8e784caeb0a55ebcfe98f0170aebe 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link';
 import { BatchHttpLink } from 'apollo-link-batch-http';
 import { createHttpLink } from 'apollo-link-http';
 import { createUploadLink } from 'apollo-upload-client';
+import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
 import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
 import csrf from '~/lib/utils/csrf';
 import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
@@ -78,6 +79,7 @@ export default (resolvers = {}, config = {}) => {
       requestCounterLink,
       performanceBarLink,
       new StartupJSLink(),
+      apolloCaptchaLink,
       uploadsLink,
     ]),
     cache: new InMemoryCache({
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index bee9d7b8c2a95f934017e7e79384567509709022..c53d0575752496c2cbb6b021f10fccf21662352a 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -33,7 +33,6 @@ export default {
     SnippetBlobActionsEdit,
     TitleField,
     FormFooterActions,
-    CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
     GlButton,
     GlLoadingIcon,
   },
@@ -68,10 +67,6 @@ export default {
         description: '',
         visibilityLevel: this.selectedLevel,
       },
-      captchaResponse: '',
-      needsCaptchaResponse: false,
-      captchaSiteKey: '',
-      spamLogId: '',
     };
   },
   computed: {
@@ -103,8 +98,6 @@ export default {
         description: this.snippet.description,
         visibilityLevel: this.snippet.visibilityLevel,
         blobActions: this.actions,
-        ...(this.spamLogId && { spamLogId: this.spamLogId }),
-        ...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
       };
     },
     saveButtonLabel() {
@@ -171,20 +164,14 @@ export default {
     },
     handleFormSubmit() {
       this.isUpdating = true;
+
       this.$apollo
         .mutate(this.newSnippet ? this.createMutation() : this.updateMutation())
         .then(({ data }) => {
           const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
 
-          if (baseObj.needsCaptchaResponse) {
-            // If we need a captcha response, start process for receiving captcha response.
-            // We will resubmit after the response is obtained.
-            this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId);
-            return;
-          }
-
           const errors = baseObj?.errors;
-          if (errors.length) {
+          if (errors?.length) {
             this.flashAPIFailure(errors[0]);
           } else {
             redirectTo(baseObj.snippet.webUrl);
@@ -200,38 +187,6 @@ export default {
     updateActions(actions) {
       this.actions = actions;
     },
-    /**
-     * Start process for getting captcha response from user
-     *
-     * @param captchaSiteKey Stored in data and used to display the captcha.
-     * @param spamLogId Stored in data and included when the form is re-submitted.
-     */
-    requestCaptchaResponse(captchaSiteKey, spamLogId) {
-      this.captchaSiteKey = captchaSiteKey;
-      this.spamLogId = spamLogId;
-      this.needsCaptchaResponse = true;
-    },
-    /**
-     * Handle the captcha response from the user
-     *
-     * @param captchaResponse The captchaResponse value emitted from the modal.
-     */
-    receivedCaptchaResponse(captchaResponse) {
-      this.needsCaptchaResponse = false;
-      this.captchaResponse = captchaResponse;
-
-      if (this.captchaResponse) {
-        // If the user solved the captcha, resubmit the form.
-        // NOTE: we do not need to clear out the captchaResponse and spamLogId
-        // data values after submit, because this component always does a full page reload.
-        // Otherwise, we would need to.
-        this.handleFormSubmit();
-      } else {
-        // If the user didn't solve the captcha (e.g. they just closed the modal),
-        // finish the update and allow them to continue editing or manually resubmit the form.
-        this.isUpdating = false;
-      }
-    },
   },
 };
 </script>
@@ -249,11 +204,6 @@ export default {
       class="loading-animation prepend-top-20 gl-mb-6"
     />
     <template v-else>
-      <captcha-modal
-        :captcha-site-key="captchaSiteKey"
-        :needs-captcha-response="needsCaptchaResponse"
-        @receivedCaptchaResponse="receivedCaptchaResponse"
-      />
       <title-field
         id="snippet-title"
         v-model="snippet.title"
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
index 64d5d7c30faa88fb935f7bf9a6b25d56a1cac56a..f688868d1b9e6e7373fc3f2b7dadb548c4c3dfe8 100644
--- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
@@ -4,7 +4,5 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
     snippet {
       webUrl
     }
-    needsCaptchaResponse
-    captchaSiteKey
   }
 }
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
index 0a72f71b7c9a5b7eb35848a7c6b49dd8e3ee93bb..548725f735770822a8c3ba756c0f7e8b00a9bbaf 100644
--- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
@@ -4,8 +4,5 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
     snippet {
       webUrl
     }
-    needsCaptchaResponse
-    captchaSiteKey
-    spamLogId
   }
 }
diff --git a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
index ba644eff36cdc03ff5523fa35d9f422a7fc0e625..3c5f077110cf16b498221d537aac051af9391e7c 100644
--- a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
+++ b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
@@ -1,64 +1,51 @@
 # frozen_string_literal: true
 
 module Mutations
-  # This concern can be mixed into a mutation to provide support for spam checking,
-  # and optionally support the workflow to allow clients to display and solve CAPTCHAs.
+  # This concern is deprecated and will be deleted in 14.6
+  #
+  # Use the SpamProtection concern instead.
   module CanMutateSpammable
     extend ActiveSupport::Concern
-    include Spam::Concerns::HasSpamActionResponseFields
 
-    # NOTE: The arguments and fields are intentionally named with 'captcha' instead of 'recaptcha',
-    # so that they can be applied to future alternative CAPTCHA implementations other than
-    # reCAPTCHA (e.g. FriendlyCaptcha) without having to change the names and descriptions in the API.
+    DEPRECATION_NOTICE = {
+      reason: 'Use spam protection with HTTP headers instead',
+      milestone: '13.11'
+    }.freeze
+
     included do
       argument :captcha_response, GraphQL::STRING_TYPE,
                required: false,
+               deprecated: DEPRECATION_NOTICE,
                description: 'A valid CAPTCHA response value obtained by using the provided captchaSiteKey with a CAPTCHA API to present a challenge to be solved on the client. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
 
       argument :spam_log_id, GraphQL::INT_TYPE,
                required: false,
+               deprecated: DEPRECATION_NOTICE,
                description: 'The spam log ID which must be passed along with a valid CAPTCHA response for the operation to be completed. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
 
       field :spam,
             GraphQL::BOOLEAN_TYPE,
             null: true,
+            deprecated: DEPRECATION_NOTICE,
             description: 'Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response.'
 
       field :needs_captcha_response,
             GraphQL::BOOLEAN_TYPE,
             null: true,
+            deprecated: DEPRECATION_NOTICE,
             description: 'Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
 
       field :spam_log_id,
             GraphQL::INT_TYPE,
             null: true,
+            deprecated: DEPRECATION_NOTICE,
             description: 'The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
 
       field :captcha_site_key,
             GraphQL::STRING_TYPE,
             null: true,
+            deprecated: DEPRECATION_NOTICE,
             description: 'The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
     end
-
-    private
-
-    # additional_spam_params    -> hash
-    #
-    # Used from a spammable mutation's #resolve method to generate
-    # the required additional spam/recaptcha params which must be merged into the params
-    # passed to the constructor of a service, where they can then be used in the service
-    # to perform spam checking via SpamActionService.
-    #
-    # Also accesses the #context of the mutation's Resolver superclass to obtain the request.
-    #
-    # Example:
-    #
-    # existing_args.merge!(additional_spam_params)
-    def additional_spam_params
-      {
-        api: true,
-        request: context[:request]
-      }
-    end
   end
 end
diff --git a/app/graphql/mutations/concerns/mutations/spam_protection.rb b/app/graphql/mutations/concerns/mutations/spam_protection.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d765da23a4b2537bc3fb53e6d6f532a9d86f821e
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/spam_protection.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Mutations
+  # This concern can be mixed into a mutation to provide support for spam checking,
+  # and optionally support the workflow to allow clients to display and solve CAPTCHAs.
+  module SpamProtection
+    extend ActiveSupport::Concern
+    include Spam::Concerns::HasSpamActionResponseFields
+
+    SpamActionError = Class.new(GraphQL::ExecutionError)
+    NeedsCaptchaResponseError = Class.new(SpamActionError)
+    SpamDisallowedError = Class.new(SpamActionError)
+
+    NEEDS_CAPTCHA_RESPONSE_MESSAGE = "Request denied. Solve CAPTCHA challenge and retry"
+    SPAM_DISALLOWED_MESSAGE = "Request denied. Spam detected"
+
+    private
+
+    # additional_spam_params    -> hash
+    #
+    # Used from a spammable mutation's #resolve method to generate
+    # the required additional spam/CAPTCHA params which must be merged into the params
+    # passed to the constructor of a service, where they can then be used in the service
+    # to perform spam checking via SpamActionService.
+    #
+    # Also accesses the #context of the mutation's Resolver superclass to obtain the request.
+    #
+    # Example:
+    #
+    # existing_args.merge!(additional_spam_params)
+    def additional_spam_params
+      {
+        api: true,
+        request: context[:request]
+      }
+    end
+
+    def spam_action_response(object)
+      fields = spam_action_response_fields(object)
+
+      # If the SpamActionService detected something as spam,
+      # this is non-recoverable and the needs_captcha_response
+      # should not be considered
+      kind = if fields[:spam]
+               :spam
+             elsif fields[:needs_captcha_response]
+               :needs_captcha_response
+             end
+
+      [kind, fields]
+    end
+
+    def check_spam_action_response!(object)
+      kind, fields = spam_action_response(object)
+
+      case kind
+      when :needs_captcha_response
+        fields.delete :spam
+        raise NeedsCaptchaResponseError.new(NEEDS_CAPTCHA_RESPONSE_MESSAGE, extensions: fields)
+      when :spam
+        raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true })
+      else
+        nil
+      end
+    end
+  end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 7f2dd448b8b14c45fd94d97503cb0a213f91ce5c..e9b452946599aa7025b102d1b721e586188a7a5f 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -5,6 +5,7 @@ module Snippets
     class Create < BaseMutation
       include ServiceCompatibility
       include CanMutateSpammable
+      include Mutations::SpamProtection
 
       authorize :create_snippet
 
@@ -56,12 +57,12 @@ def resolve(project_path: nil, **args)
         end
 
         snippet = service_response.payload[:snippet]
-        with_spam_action_response_fields(snippet) do
-          {
-            snippet: service_response.success? ? snippet : nil,
-            errors: errors_on_object(snippet)
-          }
-        end
+        check_spam_action_response!(snippet)
+
+        {
+          snippet: service_response.success? ? snippet : nil,
+          errors: errors_on_object(snippet)
+        }
       end
 
       private
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 9f9f8bca8485b965bd41ab34216b54532da778c5..b9b9b13eebb800dca4ab590de633803b15311c91 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -5,6 +5,7 @@ module Snippets
     class Update < Base
       include ServiceCompatibility
       include CanMutateSpammable
+      include Mutations::SpamProtection
 
       graphql_name 'UpdateSnippet'
 
@@ -45,12 +46,12 @@ def resolve(id:, **args)
         end
 
         snippet = service_response.payload[:snippet]
-        with_spam_action_response_fields(snippet) do
-          {
-            snippet: service_response.success? ? snippet : snippet.reset,
-            errors: errors_on_object(snippet)
-          }
-        end
+        check_spam_action_response!(snippet)
+
+        {
+          snippet: service_response.success? ? snippet : snippet.reset,
+          errors: errors_on_object(snippet)
+        }
       end
 
       private
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 91f3492ad0adb1b2ee670ed46cf1b19062017563..b58ff4ae958d5a7e6b3ef8d5ebadd207cbdb48d9 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -6,7 +6,7 @@ class CreateService < Issues::BaseService
 
     def execute(skip_system_notes: false)
       @request = params.delete(:request)
-      @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+      @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
       @issue = BuildService.new(project, current_user, params).execute
 
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index f39655a6b07106aa1a90e778d1ffd4ee49350cba..702527d80a70c77822d9e5b84217807900317e75 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -8,7 +8,7 @@ def execute(issue)
       handle_move_between_ids(issue)
 
       @request = params.delete(:request)
-      @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+      @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
       change_issue_duplicate(issue)
       move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 802bfd813dcd616e1acdf409f81570b3bd6dc08f..c95b459cd2a236a5d5d09e9e29212804793d572f 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -6,7 +6,7 @@ def execute
       # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
       disable_spam_action_service = params.delete(:disable_spam_action_service) == true
       @request = params.delete(:request)
-      @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+      @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
       @snippet = build_from_params
 
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 5b427817a02d6143e321fe3c7578db353caca414..aedb6a4819d8507799b03317a8a1f824ca99b04b 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -10,7 +10,7 @@ def execute(snippet)
       # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
       disable_spam_action_service = params.delete(:disable_spam_action_service) == true
       @request = params.delete(:request)
-      @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+      @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
       return invalid_params_error(snippet) unless valid_params?
 
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 185b9e39070a448a4ed3b28b77c6accd63c2c9a4..2220198583ca607b8df8dc2e2e639504bdf547fb 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -11,22 +11,30 @@ class SpamActionService
     # Takes a hash of parameters from an incoming request to modify a model (via a controller,
     # service, or GraphQL mutation). The parameters will either be camelCase (if they are
     # received directly via controller params) or underscore_case (if they have come from
-    # a GraphQL mutation which has converted them to underscore)
+    # a GraphQL mutation which has converted them to underscore), or in the
+    # headers when using the header based flow.
     #
     # Deletes the parameters which are related to spam and captcha processing, and returns
     # them in a SpamParams parameters object. See:
     # https://refactoring.com/catalog/introduceParameterObject.html
-    def self.filter_spam_params!(params)
+    def self.filter_spam_params!(params, request)
       # NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future
       # alternative captcha implementations such as FriendlyCaptcha. See
       # https://gitlab.com/gitlab-org/gitlab/-/issues/273480
-      captcha_response = params.delete(:captcha_response) || params.delete(:captchaResponse)
+      headers = request&.headers || {}
+      api = params.delete(:api)
+      captcha_response = read_parameter(:captcha_response, params, headers)
+      spam_log_id      = read_parameter(:spam_log_id, params, headers)&.to_i
 
-      SpamParams.new(
-        api: params.delete(:api),
-        captcha_response: captcha_response,
-        spam_log_id: params.delete(:spam_log_id) || params.delete(:spamLogId)
-      )
+      SpamParams.new(api: api, captcha_response: captcha_response, spam_log_id: spam_log_id)
+    end
+
+    def self.read_parameter(name, params, headers)
+      [
+        params.delete(name),
+        params.delete(name.to_s.camelize(:lower).to_sym),
+        headers["X-GitLab-#{name.to_s.titlecase(keep_id_suffix: true).tr(' ', '-')}"]
+      ].compact.first
     end
 
     attr_accessor :target, :request, :options
@@ -40,6 +48,7 @@ def initialize(spammable:, request:, user:, action:)
       @options = {}
     end
 
+    # rubocop:disable Metrics/AbcSize
     def execute(spam_params:)
       if request
         options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s
@@ -58,19 +67,20 @@ def execute(spam_params:)
       )
 
       if recaptcha_verified
-        # If it's a request which is already verified through captcha,
+        # If it's a request which is already verified through CAPTCHA,
         # update the spam log accordingly.
         SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id)
-        ServiceResponse.success(message: "Captcha was successfully verified")
+        ServiceResponse.success(message: "CAPTCHA successfully verified")
       else
         return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user)
         return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request
         return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?
 
         perform_spam_service_check(spam_params.api)
-        ServiceResponse.success(message: "Spam check performed, check #{target.class.name} spammable model for any errors or captcha requirement")
+        ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement")
       end
     end
+    # rubocop:enable Metrics/AbcSize
 
     delegate :check_for_spam?, to: :target
 
diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb
index fef5355c7f313e40b30cbd3643579b52142c0c9a..3420748822d1b1255f01a92fe3d4bf319f13f8b6 100644
--- a/app/services/spam/spam_params.rb
+++ b/app/services/spam/spam_params.rb
@@ -23,10 +23,10 @@ def initialize(api:, captcha_response:, spam_log_id:)
     end
 
     def ==(other)
-      other.class == self.class &&
-        other.api == self.api &&
-        other.captcha_response == self.captcha_response &&
-        other.spam_log_id == self.spam_log_id
+      other.class <= self.class &&
+        other.api == api &&
+        other.captcha_response == captcha_response &&
+        other.spam_log_id == spam_log_id
     end
   end
 end
diff --git a/doc/api/graphql/index.md b/doc/api/graphql/index.md
index 7bbc2029d96351b51684b3e01487f8468f4660ea..ace41e0e92dfec53e13fe4fb3ff1f34678792cc5 100644
--- a/doc/api/graphql/index.md
+++ b/doc/api/graphql/index.md
@@ -70,7 +70,7 @@ possible.
 The GitLab GraphQL API is [versionless](https://graphql.org/learn/best-practices/#versioning) and
 changes are made to the API in a way that maintains backwards-compatibility.
 
-Occassionally GitLab needs to change the GraphQL API in a way that is not backwards-compatible.
+Occasionally GitLab needs to change the GraphQL API in a way that is not backwards-compatible.
 These changes include the removal or renaming of fields, arguments or other parts of the schema.
 
 In these situations, GitLab follows a [Deprecation and removal process](#deprecation-and-removal-process)
@@ -177,6 +177,59 @@ of a query may be altered.
 
 Requests time out at 30 seconds.
 
+### Spam
+
+GraphQL mutations can be detected as spam. If this happens, a
+[GraphQL top-level error](https://spec.graphql.org/June2018/#sec-Errors) is raised. For example:
+
+```json
+{
+  "errors": [
+    {
+      "message": "Request denied. Spam detected",
+      "locations": [ { "line": 6, "column": 7 } ],
+      "path": [ "updateSnippet" ],
+      "extensions": {
+        "spam": true
+      }
+    }
+  ],
+  "data": {
+    "updateSnippet": {
+      "snippet": null
+    }
+  }
+}
+```
+
+If mutation is detected as potential spam and a CAPTCHA service is configured:
+
+- The `captchaSiteKey` should be used to obtain a CAPTCHA response value using the appropriate CAPTCHA API.
+  Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
+- The request can be resubmitted with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
+
+```json
+{
+  "errors": [
+    {
+      "message": "Request denied. Solve CAPTCHA challenge and retry",
+      "locations": [ { "line": 6, "column": 7 } ],
+      "path": [ "updateSnippet" ],
+      "extensions": {
+        "needsCaptchaResponse": true,
+        "captchaSiteKey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
+        "spamLogId": 67
+      }
+    }
+  ],
+  "data": {
+    "updateSnippet": {
+      "snippet": null,
+    }
+  }
+}
+```
+
 ## Reference
 
 The GitLab GraphQL reference [is available](reference/index.md).
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 56b5bcce3f1ab0b38512a34c537e78505625e27e..39440e0cbfcef27021957c742f8b4af98729e255 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1824,13 +1824,13 @@ Autogenerated return type of CreateSnippet.
 
 | Field | Type | Description |
 | ----- | ---- | ----------- |
-| `captchaSiteKey` | [`String`](#string) | The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
+| `captchaSiteKey` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
-| `needsCaptchaResponse` | [`Boolean`](#boolean) | Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
+| `needsCaptchaResponse` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 | `snippet` | [`Snippet`](#snippet) | The snippet after mutation. |
-| `spam` | [`Boolean`](#boolean) | Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response. |
-| `spamLogId` | [`Int`](#int) | The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
+| `spam` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
+| `spamLogId` **{warning-solid}** | [`Int`](#int) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 
 ### `CreateTestCasePayload`
 
@@ -6622,13 +6622,13 @@ Autogenerated return type of UpdateSnippet.
 
 | Field | Type | Description |
 | ----- | ---- | ----------- |
-| `captchaSiteKey` | [`String`](#string) | The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
+| `captchaSiteKey` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
-| `needsCaptchaResponse` | [`Boolean`](#boolean) | Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
+| `needsCaptchaResponse` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 | `snippet` | [`Snippet`](#snippet) | The snippet after mutation. |
-| `spam` | [`Boolean`](#boolean) | Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response. |
-| `spamLogId` | [`Int`](#int) | The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
+| `spam` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
+| `spamLogId` **{warning-solid}** | [`Int`](#int) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 
 ### `UsageTrendsMeasurement`
 
diff --git a/lib/spam/concerns/has_spam_action_response_fields.rb b/lib/spam/concerns/has_spam_action_response_fields.rb
index d49f5cd6454c11ae40d9057198ee94e13e344c9f..6688ae56cb0abaaad46b568a6b2c41ffde5820bd 100644
--- a/lib/spam/concerns/has_spam_action_response_fields.rb
+++ b/lib/spam/concerns/has_spam_action_response_fields.rb
@@ -23,15 +23,6 @@ def spam_action_response_fields(spammable)
           captcha_site_key: Gitlab::CurrentSettings.recaptcha_site_key
         }
       end
-
-      # with_spam_action_response_fields(spammable) { {other_fields: true} }    -> hash
-      #
-      # Takes a Spammable and a block as arguments.
-      #
-      # The block passed should be a hash, which the spam_action_fields will be merged into.
-      def with_spam_action_response_fields(spammable)
-        yield.merge(spam_action_response_fields(spammable))
-      end
     end
   end
 end
diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js
index b4863d7bc1939679c9415a46bfaa34efdd4e5857..e7ff4812ee772111afe5590e75411036d6552f8f 100644
--- a/spec/frontend/captcha/apollo_captcha_link_spec.js
+++ b/spec/frontend/captcha/apollo_captcha_link_spec.js
@@ -44,7 +44,7 @@ describe('apolloCaptchaLink', () => {
     },
     errors: [
       {
-        message: 'Your Query was detected to be SPAM.',
+        message: 'Your Query was detected to be spam.',
         path: ['user'],
         locations: [{ line: 2, column: 3 }],
         extensions: {
@@ -116,7 +116,7 @@ describe('apolloCaptchaLink', () => {
     });
   });
 
-  it('unresolvable SPAM errors are passed through', (done) => {
+  it('unresolvable spam errors are passed through', (done) => {
     setupLink(SPAM_ERROR_RESPONSE);
     link.request(mockOperation()).subscribe((result) => {
       expect(result).toEqual(SPAM_ERROR_RESPONSE);
@@ -127,8 +127,8 @@ describe('apolloCaptchaLink', () => {
     });
   });
 
-  describe('resolvable SPAM errors', () => {
-    it('re-submits request with SPAM headers if the captcha modal was solved correctly', (done) => {
+  describe('resolvable spam errors', () => {
+    it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => {
       waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE);
       setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
       link.request(mockOperation()).subscribe((result) => {
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 2b6d3ca8c2a97691351fd0e7e8a4092f3af302ae..efdb52cfcd92ab23a7d6ac2f6564fe54bc5b1ead 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -5,10 +5,9 @@ import { nextTick } from 'vue';
 import VueApollo, { ApolloMutation } from 'vue-apollo';
 import { useFakeDate } from 'helpers/fake_date';
 import createMockApollo from 'helpers/mock_apollo_helper';
-import { stubComponent } from 'helpers/stub_component';
 import waitForPromises from 'helpers/wait_for_promises';
 import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
-import CaptchaModal from '~/captcha/captcha_modal.vue';
+import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error';
 import { deprecatedCreateFlash as Flash } from '~/flash';
 import * as urlUtils from '~/lib/utils/url_utility';
 import SnippetEditApp from '~/snippets/components/edit.vue';
@@ -30,9 +29,8 @@ jest.mock('~/flash');
 
 const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js'];
 const TEST_API_ERROR = new Error('TEST_API_ERROR');
+const TEST_CAPTCHA_ERROR = new UnsolvedCaptchaError();
 const TEST_MUTATION_ERROR = 'Test mutation error';
-const TEST_CAPTCHA_RESPONSE = 'i-got-a-captcha';
-const TEST_CAPTCHA_SITE_KEY = 'abc123';
 const TEST_ACTIONS = {
   NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }),
   NO_PATH: merge({}, testEntries.created.diff, { filePath: '' }),
@@ -59,9 +57,6 @@ const createMutationResponse = (key, obj = {}) => ({
           __typename: 'Snippet',
           webUrl: TEST_WEB_URL,
         },
-        spamLogId: null,
-        needsCaptchaResponse: false,
-        captchaSiteKey: null,
       },
       obj,
     ),
@@ -71,13 +66,6 @@ const createMutationResponse = (key, obj = {}) => ({
 const createMutationResponseWithErrors = (key) =>
   createMutationResponse(key, { errors: [TEST_MUTATION_ERROR] });
 
-const createMutationResponseWithRecaptcha = (key) =>
-  createMutationResponse(key, {
-    errors: ['ignored captcha error message'],
-    needsCaptchaResponse: true,
-    captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
-  });
-
 const getApiData = ({
   id,
   title = '',
@@ -126,7 +114,6 @@ describe('Snippet Edit app', () => {
   });
 
   const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
-  const findCaptchaModal = () => wrapper.find(CaptchaModal);
   const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
   const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
   const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
@@ -159,7 +146,6 @@ describe('Snippet Edit app', () => {
       stubs: {
         ApolloMutation,
         FormFooterActions,
-        CaptchaModal: stubComponent(CaptchaModal),
       },
       provide: {
         selectedLevel,
@@ -209,7 +195,6 @@ describe('Snippet Edit app', () => {
     });
 
     it('should render components', () => {
-      expect(wrapper.find(CaptchaModal).exists()).toBe(true);
       expect(wrapper.find(TitleField).exists()).toBe(true);
       expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
       expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
@@ -338,10 +323,10 @@ describe('Snippet Edit app', () => {
         },
       );
 
-      describe('with apollo network error', () => {
+      describe.each([TEST_API_ERROR, TEST_CAPTCHA_ERROR])('with apollo network error', (error) => {
         beforeEach(async () => {
           jest.spyOn(console, 'error').mockImplementation();
-          mutateSpy.mockRejectedValue(TEST_API_ERROR);
+          mutateSpy.mockRejectedValue(error);
 
           await createComponentAndSubmit();
         });
@@ -353,7 +338,7 @@ describe('Snippet Edit app', () => {
         it('should flash', () => {
           // Apollo automatically wraps the resolver's error in a NetworkError
           expect(Flash).toHaveBeenCalledWith(
-            `Can't update snippet: Network error: ${TEST_API_ERROR.message}`,
+            `Can't update snippet: Network error: ${error.message}`,
           );
         });
 
@@ -363,54 +348,10 @@ describe('Snippet Edit app', () => {
           // eslint-disable-next-line no-console
           expect(console.error).toHaveBeenCalledWith(
             '[gitlab] unexpected error while updating snippet',
-            expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }),
+            expect.objectContaining({ message: `Network error: ${error.message}` }),
           );
         });
       });
-
-      describe('when needsCaptchaResponse is true', () => {
-        let modal;
-
-        beforeEach(async () => {
-          mutateSpy
-            .mockResolvedValueOnce(createMutationResponseWithRecaptcha('updateSnippet'))
-            .mockResolvedValueOnce(createMutationResponseWithErrors('updateSnippet'));
-
-          await createComponentAndSubmit();
-
-          modal = findCaptchaModal();
-
-          mutateSpy.mockClear();
-        });
-
-        it('should display captcha modal', () => {
-          expect(urlUtils.redirectTo).not.toHaveBeenCalled();
-          expect(modal.props()).toEqual({
-            needsCaptchaResponse: true,
-            captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
-          });
-        });
-
-        describe.each`
-          response                 | expectedCalls
-          ${null}                  | ${[]}
-          ${TEST_CAPTCHA_RESPONSE} | ${[['updateSnippet', { input: { ...getApiData(createSnippet()), captchaResponse: TEST_CAPTCHA_RESPONSE } }]]}
-        `('when captcha response is $response', ({ response, expectedCalls }) => {
-          beforeEach(async () => {
-            modal.vm.$emit('receivedCaptchaResponse', response);
-
-            await nextTick();
-          });
-
-          it('sets needsCaptchaResponse to false', () => {
-            expect(modal.props('needsCaptchaResponse')).toEqual(false);
-          });
-
-          it(`expected to call times = ${expectedCalls.length}`, () => {
-            expect(mutateSpy.mock.calls).toEqual(expectedCalls);
-          });
-        });
-      });
     });
   });
 
diff --git a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
deleted file mode 100644
index 8d1fce406fa6664d02a740d7a0d4440678cce5ea..0000000000000000000000000000000000000000
--- a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Mutations::CanMutateSpammable do
-  let(:mutation_class) do
-    Class.new(Mutations::BaseMutation) do
-      include Mutations::CanMutateSpammable
-    end
-  end
-
-  let(:request) { double(:request) }
-  let(:query) { double(:query, schema: GitlabSchema) }
-  let(:context) { GraphQL::Query::Context.new(query: query, object: nil, values: { request: request }) }
-
-  subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
-
-  describe '#additional_spam_params' do
-    it 'returns additional spam-related params' do
-      expect(subject.send(:additional_spam_params)).to eq({ api: true, request: request })
-    end
-  end
-
-  describe '#with_spam_action_fields' do
-    let(:spam_log) { double(:spam_log, id: 1) }
-    let(:spammable) { double(:spammable, spam?: true, render_recaptcha?: true, spam_log: spam_log) }
-
-    before do
-      allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { 'abc123' }
-    end
-
-    it 'merges in spam action fields from spammable' do
-      result = subject.send(:with_spam_action_response_fields, spammable) do
-        { other_field: true }
-      end
-      expect(result)
-        .to eq({
-                 spam: true,
-                 needs_captcha_response: true,
-                 spam_log_id: 1,
-                 captcha_site_key: 'abc123',
-                 other_field: true
-               })
-    end
-  end
-end
diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
index 1c2260070ec6ac91f6d215bb2dcc19da67dff24d..d944c9e9e57bbd3abef9348505e8c5cf0d678013 100644
--- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
@@ -211,5 +211,9 @@ def mutation_response
         end
       end
     end
+
+    it_behaves_like 'has spam protection' do
+      let(:mutation_class) { ::Mutations::Snippets::Create }
+    end
   end
 end
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 43dc8d8bc4479a5191254be9a1431b1d7c36c5cf..28ab593526a8301dbdf842676abc91bc9e9ae44b 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -157,6 +157,9 @@ def blob_at(filename)
     it_behaves_like 'graphql update actions'
     it_behaves_like 'when the snippet is not found'
     it_behaves_like 'snippet edit usage data counters'
+    it_behaves_like 'has spam protection' do
+      let(:mutation_class) { ::Mutations::Snippets::Update }
+    end
   end
 
   describe 'ProjectSnippet' do
@@ -201,6 +204,10 @@ def blob_at(filename)
       end
 
       it_behaves_like 'snippet edit usage data counters'
+
+      it_behaves_like 'has spam protection' do
+        let(:mutation_class) { ::Mutations::Snippets::Update }
+      end
     end
 
     it_behaves_like 'when the snippet is not found'
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index a1ca5ad3c2f790fdd1ee21399318b0a608fd2513..d362f4efb7cd2c8b894ef2e71dd5b779aa74e943 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -461,7 +461,7 @@
     end
 
     context 'checking spam' do
-      let(:request) { double(:request) }
+      let(:request) { double(:request, headers: nil) }
       let(:api) { true }
       let(:captcha_response) { 'abc123' }
       let(:spam_log_id) { 1 }
diff --git a/spec/services/spam/spam_action_service_spec.rb b/spec/services/spam/spam_action_service_spec.rb
index 371923f151811f483f5ceb141defed18f9764b66..e8ac826df1ca7fae3d9a91f8b69ef381a0db84f5 100644
--- a/spec/services/spam/spam_action_service_spec.rb
+++ b/spec/services/spam/spam_action_service_spec.rb
@@ -5,6 +5,8 @@
 RSpec.describe Spam::SpamActionService do
   include_context 'includes Spam constants'
 
+  let(:request) { double(:request, env: env, headers: {}) }
+  let(:issue) { create(:issue, project: project, author: user) }
   let(:fake_ip) { '1.2.3.4' }
   let(:fake_user_agent) { 'fake-user-agent' }
   let(:fake_referrer) { 'fake-http-referrer' }
@@ -14,11 +16,8 @@
       'HTTP_REFERRER' => fake_referrer }
   end
 
-  let(:request) { double(:request, env: env) }
-
   let_it_be(:project) { create(:project, :public) }
   let_it_be(:user) { create(:user) }
-  let(:issue) { create(:issue, project: project, author: user) }
 
   before do
     issue.spam = false
@@ -48,7 +47,7 @@
 
   shared_examples 'creates a spam log' do
     it do
-      expect { subject }.to change { SpamLog.count }.by(1)
+      expect { subject }.to change(SpamLog, :count).by(1)
 
       new_spam_log = SpamLog.last
       expect(new_spam_log.user_id).to eq(user.id)
@@ -62,7 +61,7 @@
   end
 
   describe '#execute' do
-    let(:request) { double(:request, env: env) }
+    let(:request) { double(:request, env: env, headers: nil) }
     let(:fake_captcha_verification_service) { double(:captcha_verification_service) }
     let(:fake_verdict_service) { double(:spam_verdict_service) }
     let(:allowlisted) { false }
@@ -70,7 +69,7 @@
     let(:captcha_response) { 'abc123' }
     let(:spam_log_id) { existing_spam_log.id }
     let(:spam_params) do
-      Spam::SpamActionService.filter_spam_params!(
+      ::Spam::SpamParams.new(
         api: api,
         captcha_response: captcha_response,
         spam_log_id: spam_log_id
@@ -111,10 +110,30 @@
       allow(Spam::SpamVerdictService).to receive(:new).with(verdict_service_args).and_return(fake_verdict_service)
     end
 
+    context 'when the captcha params are passed in the headers' do
+      let(:request) { double(:request, env: env, headers: headers) }
+      let(:spam_params) { Spam::SpamActionService.filter_spam_params!({ api: api }, request) }
+      let(:headers) do
+        {
+          'X-GitLab-Captcha-Response' => captcha_response,
+          'X-GitLab-Spam-Log-Id' => spam_log_id
+        }
+      end
+
+      it 'extracts the headers correctly' do
+        expect(fake_captcha_verification_service)
+          .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(true)
+        expect(SpamLog)
+          .to receive(:verify_recaptcha!).with(user_id: user.id, id: spam_log_id)
+
+        subject
+      end
+    end
+
     context 'when captcha response verification returns true' do
       before do
-        expect(fake_captcha_verification_service)
-          .to receive(:execute).with(captcha_response: captcha_response, request: request) { true }
+        allow(fake_captcha_verification_service)
+          .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(true)
       end
 
       it "doesn't check with the SpamVerdictService" do
@@ -136,8 +155,8 @@
 
     context 'when captcha response verification returns false' do
       before do
-        expect(fake_captcha_verification_service)
-          .to receive(:execute).with(captcha_response: captcha_response, request: request) { false }
+        allow(fake_captcha_verification_service)
+          .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(false)
       end
 
       context 'when spammable attributes have not changed' do
@@ -146,21 +165,20 @@
         end
 
         it 'does not create a spam log' do
-          expect { subject }
-            .not_to change { SpamLog.count }
+          expect { subject }.not_to change(SpamLog, :count)
         end
       end
 
       context 'when spammable attributes have changed' do
         let(:expected_service_check_response_message) do
-          /check Issue spammable model for any errors or captcha requirement/
+          /Check Issue spammable model for any errors or CAPTCHA requirement/
         end
 
         before do
-          issue.description = 'SPAM!'
+          issue.description = 'Lovely Spam! Wonderful Spam!'
         end
 
-        context 'if allowlisted' do
+        context 'when allowlisted' do
           let(:allowlisted) { true }
 
           it 'does not perform spam check' do
@@ -229,7 +247,7 @@
               response = subject
 
               expect(response.message).to match(expected_service_check_response_message)
-              expect(issue.needs_recaptcha?).to be_truthy
+              expect(issue).to be_needs_recaptcha
             end
           end
 
@@ -253,8 +271,7 @@
           end
 
           it 'does not create a spam log' do
-            expect { subject }
-              .not_to change { SpamLog.count }
+            expect { subject }.not_to change(SpamLog, :count)
           end
 
           it 'clears spam flags' do
@@ -264,9 +281,9 @@
           end
         end
 
-        context 'spam verdict service options' do
+        context 'with spam verdict service options' do
           before do
-            allow(fake_verdict_service).to receive(:execute) { ALLOW }
+            allow(fake_verdict_service).to receive(:execute).and_return(ALLOW)
           end
 
           context 'when the request is nil' do
diff --git a/spec/spam/concerns/has_spam_action_response_fields_spec.rb b/spec/spam/concerns/has_spam_action_response_fields_spec.rb
index 4d5f8d9d4311d7a7c07d65027d72713c946bee8a..9752f6a0b697db24139ae6557ffc3dc3d9ea34ea 100644
--- a/spec/spam/concerns/has_spam_action_response_fields_spec.rb
+++ b/spec/spam/concerns/has_spam_action_response_fields_spec.rb
@@ -19,16 +19,12 @@
     end
 
     it 'merges in spam action fields from spammable' do
-      result = subject.send(:with_spam_action_response_fields, spammable) do
-        { other_field: true }
-      end
-      expect(result)
+      expect(subject.spam_action_response_fields(spammable))
         .to eq({
                  spam: true,
                  needs_captcha_response: true,
                  spam_log_id: 1,
-                 captcha_site_key: recaptcha_site_key,
-                 other_field: true
+                 captcha_site_key: recaptcha_site_key
                })
     end
   end
diff --git a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
index bb4270d7db6559722500582e0d867fbc7815041e..fc795012ce7d72efa337961bd5386f9aab85696e 100644
--- a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
@@ -21,13 +21,13 @@
     end
   end
 
-  describe "#with_spam_action_response_fields" do
+  describe "#spam_action_response_fields" do
     it 'resolves with spam action fields' do
       subject
 
       # NOTE: We do not need to assert on the specific values of spam action fields here, we only need
-      # to verify that #with_spam_action_response_fields was invoked and that the fields are present in the
-      # response. The specific behavior of #with_spam_action_response_fields is covered in the
+      # to verify that #spam_action_response_fields was invoked and that the fields are present in the
+      # response. The specific behavior of #spam_action_response_fields is covered in the
       # HasSpamActionResponseFields unit tests.
       expect(mutation_response.keys)
         .to include('spam', 'spamLogId', 'needsCaptchaResponse', 'captchaSiteKey')
diff --git a/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb b/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8fb89a4f80ea81800bb897502fe0aab2a5384687
--- /dev/null
+++ b/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'has spam protection' do
+  include AfterNextHelpers
+
+  describe '#check_spam_action_response!' do
+    let(:variables) { nil }
+    let(:headers) { {} }
+    let(:spam_log_id) { 123 }
+    let(:captcha_site_key) { 'abc123' }
+
+    def send_request
+      post_graphql_mutation(mutation, current_user: current_user)
+    end
+
+    before do
+      allow_next(mutation_class).to receive(:spam_action_response_fields).and_return(
+        spam: spam,
+        needs_captcha_response: render_captcha,
+        spam_log_id: spam_log_id,
+        captcha_site_key: captcha_site_key
+      )
+    end
+
+    context 'when the object is spam (DISALLOW)' do
+      shared_examples 'disallow response' do
+        it 'informs the client that the request was denied as spam' do
+          send_request
+
+          expect(graphql_errors)
+            .to contain_exactly a_hash_including('message' => ::Mutations::SpamProtection::SPAM_DISALLOWED_MESSAGE)
+          expect(graphql_errors)
+            .to contain_exactly a_hash_including('extensions' => { "spam" => true })
+        end
+      end
+
+      let(:spam) { true }
+
+      context 'and no CAPTCHA is available' do
+        let(:render_captcha) { false }
+
+        it_behaves_like 'disallow response'
+      end
+
+      context 'and a CAPTCHA is required' do
+        let(:render_captcha) { true }
+
+        it_behaves_like 'disallow response'
+      end
+    end
+
+    context 'when the object is not spam (CONDITIONAL ALLOW)' do
+      let(:spam) { false }
+
+      context 'and no CAPTCHA is required' do
+        let(:render_captcha) { false }
+
+        it 'does not return a to-level error' do
+          send_request
+
+          expect(graphql_errors).to be_blank
+        end
+      end
+
+      context 'and a CAPTCHA is required' do
+        let(:render_captcha) { true }
+
+        it 'informs the client that the request may be retried after solving the CAPTCHA' do
+          send_request
+
+          expect(graphql_errors)
+            .to contain_exactly a_hash_including('message' => ::Mutations::SpamProtection::NEEDS_CAPTCHA_RESPONSE_MESSAGE)
+          expect(graphql_errors)
+            .to contain_exactly a_hash_including('extensions' => {
+              "captcha_site_key" => captcha_site_key,
+              "needs_captcha_response" => true,
+              "spam_log_id" => spam_log_id
+            })
+        end
+      end
+    end
+  end
+end
diff --git a/spec/support/shared_examples/services/snippets_shared_examples.rb b/spec/support/shared_examples/services/snippets_shared_examples.rb
index 10add3a729913407421daf2691dd1d5ebdacdfc9..0c4db7ded69caed71fc2a6abc2a422c693892a1d 100644
--- a/spec/support/shared_examples/services/snippets_shared_examples.rb
+++ b/spec/support/shared_examples/services/snippets_shared_examples.rb
@@ -1,7 +1,8 @@
 # frozen_string_literal: true
 
 RSpec.shared_examples 'checking spam' do
-  let(:request) { double(:request) }
+  let(:request) { double(:request, headers: headers) }
+  let(:headers) { nil }
   let(:api) { true }
   let(:captcha_response) { 'abc123' }
   let(:spam_log_id) { 1 }
@@ -44,6 +45,44 @@
     subject
   end
 
+  context 'when CAPTCHA arguments are passed in the headers' do
+    let(:headers) do
+      {
+        'X-GitLab-Spam-Log-Id' => spam_log_id,
+        'X-GitLab-Captcha-Response' => captcha_response
+      }
+    end
+
+    let(:extra_opts) do
+      {
+        request: request,
+        api: api,
+        disable_spam_action_service: disable_spam_action_service
+      }
+    end
+
+    it 'executes the SpamActionService correctly' do
+      spam_params = Spam::SpamParams.new(
+        api: api,
+        captcha_response: captcha_response,
+        spam_log_id: spam_log_id
+      )
+      expect_next_instance_of(
+        Spam::SpamActionService,
+        {
+          spammable: kind_of(Snippet),
+          request: request,
+          user: an_instance_of(User),
+          action: action
+        }
+      ) do |instance|
+        expect(instance).to receive(:execute).with(spam_params: spam_params)
+      end
+
+      subject
+    end
+  end
+
   context 'when spam action service is disabled' do
     let(:disable_spam_action_service) { true }