diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index b40373ecc37ad24d8065e68e9a73d9fc73488258..96ba06a8323f6bb741240577569ff560b43bb075 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -1,7 +1,7 @@
-= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f|
+= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-sign-in-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
   .form-group
     = f.label _('Username or email'), for: 'user_login', class: 'label-bold'
-    = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' }
+    = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
   .form-group
     = f.label :password, class: 'label-bold'
     = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
@@ -16,8 +16,10 @@
         - else
           = link_to _('Forgot your password?'), new_password_path(:user)
     %div
-    - if captcha_enabled? || captcha_on_login_required?
+    - if Feature.enabled?(:arkose_labs_login_challenge)
+      = render_if_exists 'devise/sessions/arkose_labs'
+    - elsif captcha_enabled? || captcha_on_login_required?
       = recaptcha_tags nonce: content_security_policy_nonce
 
   .submit-container.move-submit-down
-    = f.submit _('Sign in'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'sign_in_button' }
+    = f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' }
diff --git a/ee/app/assets/javascripts/api/arkose_labs_api.js b/ee/app/assets/javascripts/api/arkose_labs_api.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f276639c03937accfd4394a9bb069d558150fe6
--- /dev/null
+++ b/ee/app/assets/javascripts/api/arkose_labs_api.js
@@ -0,0 +1,8 @@
+import axios from '~/lib/utils/axios_utils';
+import { buildApiUrl } from '~/api/api_utils';
+
+const USERNAME_PLACEHOLDER = ':username';
+const ENDPOINT = `/api/:version/users/${USERNAME_PLACEHOLDER}/captcha_check`;
+
+export const needsArkoseLabsChallenge = (username = '') =>
+  axios.get(buildApiUrl(ENDPOINT).replace(USERNAME_PLACEHOLDER, encodeURIComponent(username)));
diff --git a/ee/app/assets/javascripts/arkose_labs/components/sign_in_arkose_app.vue b/ee/app/assets/javascripts/arkose_labs/components/sign_in_arkose_app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0e3a063488ec1e26d73248a6ba543d3efbd9a028
--- /dev/null
+++ b/ee/app/assets/javascripts/arkose_labs/components/sign_in_arkose_app.vue
@@ -0,0 +1,203 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlAlert } from '@gitlab/ui';
+import { needsArkoseLabsChallenge } from 'ee/rest_api';
+import { logError } from '~/lib/logger';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
+import { __ } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import { initArkoseLabsScript } from '../init_arkose_labs_script';
+
+const LOADING_ICON = loadingIconForLegacyJS({ classes: ['gl-mr-2'] });
+
+const MSG_ARKOSE_NEEDED = __('Complete verification to sign in.');
+const MSG_ARKOSE_FAILURE_TITLE = __('Unable to verify the user');
+const MSG_ARKOSE_FAILURE_BODY = __(
+  'An error occurred when loading the user verification challenge. Refresh to try again.',
+);
+
+const ARKOSE_CONTAINER_CLASS = 'js-arkose-labs-container-';
+
+const VERIFICATION_TOKEN_INPUT_NAME = 'arkose_labs_token';
+
+export default {
+  components: {
+    DomElementListener,
+    GlAlert,
+  },
+  props: {
+    publicKey: {
+      type: String,
+      required: true,
+    },
+    formSelector: {
+      type: String,
+      required: true,
+    },
+    usernameSelector: {
+      type: String,
+      required: true,
+    },
+    submitSelector: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      arkoseLabsIframeShown: false,
+      showArkoseNeededError: false,
+      showArkoseFailure: false,
+      username: '',
+      isLoading: false,
+      arkoseInitialized: false,
+      arkoseToken: '',
+      arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS),
+      arkoseChallengePassed: false,
+    };
+  },
+  computed: {
+    isVisible() {
+      return this.arkoseLabsIframeShown || this.showErrorContainer;
+    },
+    showErrorContainer() {
+      return this.showArkoseNeededError || this.showArkoseFailure;
+    },
+  },
+  watch: {
+    username() {
+      this.checkIfNeedsChallenge();
+    },
+    isLoading(val) {
+      this.updateSubmitButtonLoading(val);
+    },
+  },
+  mounted() {
+    this.username = this.getUsernameValue();
+  },
+  methods: {
+    onArkoseLabsIframeShown() {
+      this.arkoseLabsIframeShown = true;
+    },
+    hideErrors() {
+      this.showArkoseNeededError = false;
+      this.showArkoseFailure = false;
+    },
+    getUsernameValue() {
+      return document.querySelector(this.usernameSelector)?.value || '';
+    },
+    onUsernameBlur() {
+      this.username = this.getUsernameValue();
+    },
+    onSubmit(e) {
+      if (!this.arkoseInitialized || this.arkoseChallengePassed) {
+        return;
+      }
+      e.preventDefault();
+      this.showArkoseNeededError = true;
+    },
+    async checkIfNeedsChallenge() {
+      if (!this.username || this.arkoseInitialized) {
+        return;
+      }
+
+      this.isLoading = true;
+
+      try {
+        const {
+          data: { result },
+        } = await needsArkoseLabsChallenge(this.username);
+
+        if (result) {
+          await this.initArkoseLabs();
+        }
+      } catch (e) {
+        if (e.response?.status === 404) {
+          // We ignore 404 errors as it just means the username does not exist.
+        } else if (e.response?.status) {
+          // If the request failed with any other error code, we initialize the challenge to make
+          // sure it isn't being bypassed by purposefully making the endpoint fail.
+          this.initArkoseLabs();
+        } else {
+          // For any other failure, we show the initialization error message.
+          this.handleArkoseLabsFailure(e);
+        }
+      } finally {
+        this.isLoading = false;
+      }
+    },
+    async initArkoseLabs() {
+      this.arkoseInitialized = true;
+
+      const enforcement = await initArkoseLabsScript({ publicKey: this.publicKey });
+
+      enforcement.setConfig({
+        mode: 'inline',
+        selector: `.${this.arkoseContainerClass}`,
+        onShown: this.onArkoseLabsIframeShown,
+        onCompleted: this.passArkoseLabsChallenge,
+        onError: this.handleArkoseLabsFailure,
+      });
+    },
+    passArkoseLabsChallenge(response) {
+      this.arkoseChallengePassed = true;
+      this.arkoseToken = response.token;
+      this.hideErrors();
+    },
+    handleArkoseLabsFailure(e) {
+      logError('ArkoseLabs initialization error', e);
+      this.showArkoseFailure = true;
+    },
+    updateSubmitButtonLoading(val) {
+      const button = document.querySelector(this.submitSelector);
+
+      if (val) {
+        const label = __('Loading');
+        button.innerHTML = `
+          ${LOADING_ICON.outerHTML}
+          ${label}
+        `;
+        button.setAttribute('disabled', true);
+      } else {
+        button.innerText = __('Sign in');
+        button.removeAttribute('disabled');
+      }
+    },
+  },
+  MSG_ARKOSE_NEEDED,
+  MSG_ARKOSE_FAILURE_TITLE,
+  MSG_ARKOSE_FAILURE_BODY,
+  VERIFICATION_TOKEN_INPUT_NAME,
+};
+</script>
+
+<template>
+  <div v-show="isVisible">
+    <input
+      v-if="arkoseInitialized"
+      :name="$options.VERIFICATION_TOKEN_INPUT_NAME"
+      type="hidden"
+      :value="arkoseToken"
+    />
+    <dom-element-listener :selector="usernameSelector" @blur="onUsernameBlur" />
+    <dom-element-listener :selector="formSelector" @submit="onSubmit" />
+    <div
+      class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3"
+      :class="arkoseContainerClass"
+      data-testid="arkose-labs-challenge"
+    ></div>
+    <div v-if="showErrorContainer" class="gl-mb-3" data-testid="arkose-labs-error-message">
+      <gl-alert
+        v-if="showArkoseFailure"
+        :title="$options.MSG_ARKOSE_FAILURE_TITLE"
+        variant="danger"
+        :dismissible="false"
+      >
+        {{ $options.MSG_ARKOSE_FAILURE_BODY }}
+      </gl-alert>
+      <span v-else-if="showArkoseNeededError" class="gl-text-red-500">
+        {{ $options.MSG_ARKOSE_NEEDED }}
+      </span>
+    </div>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/arkose_labs/index.js b/ee/app/assets/javascripts/arkose_labs/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..0dab272df9c4e7d8b80ac695762065715b450d43
--- /dev/null
+++ b/ee/app/assets/javascripts/arkose_labs/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import SignInArkoseApp from './components/sign_in_arkose_app.vue';
+
+const FORM_SELECTOR = '.js-sign-in-form';
+const USERNAME_SELECTOR = `${FORM_SELECTOR} .js-username-field`;
+const SUBMIT_SELECTOR = `${FORM_SELECTOR} .js-sign-in-button`;
+
+export const setupArkoseLabs = () => {
+  const signInForm = document.querySelector(FORM_SELECTOR);
+  const el = signInForm?.querySelector('.js-arkose-labs-challenge');
+
+  if (!el) {
+    return null;
+  }
+
+  const publicKey = el.dataset.apiKey;
+
+  return new Vue({
+    el,
+    render(h) {
+      return h(SignInArkoseApp, {
+        props: {
+          publicKey,
+          formSelector: FORM_SELECTOR,
+          usernameSelector: USERNAME_SELECTOR,
+          submitSelector: SUBMIT_SELECTOR,
+        },
+      });
+    },
+  });
+};
diff --git a/ee/app/assets/javascripts/arkose_labs/init_arkose_labs_script.js b/ee/app/assets/javascripts/arkose_labs/init_arkose_labs_script.js
new file mode 100644
index 0000000000000000000000000000000000000000..9beb9c3ce244cca595aa9eb0da1676350cacec0f
--- /dev/null
+++ b/ee/app/assets/javascripts/arkose_labs/init_arkose_labs_script.js
@@ -0,0 +1,26 @@
+import { uniqueId } from 'lodash';
+
+const CALLBACK_NAME = '_initArkoseLabsScript_callback_';
+
+const getCallbackName = () => uniqueId(CALLBACK_NAME);
+
+export const initArkoseLabsScript = ({ publicKey }) => {
+  const callbackFunctionName = getCallbackName();
+
+  return new Promise((resolve) => {
+    window[callbackFunctionName] = (enforcement) => {
+      delete window[callbackFunctionName];
+      resolve(enforcement);
+    };
+
+    const tag = document.createElement('script');
+    [
+      ['type', 'text/javascript'],
+      ['src', `https://client-api.arkoselabs.com/v2/${publicKey}/api.js`],
+      ['data-callback', callbackFunctionName],
+    ].forEach(([attr, value]) => {
+      tag.setAttribute(attr, value);
+    });
+    document.head.appendChild(tag);
+  });
+};
diff --git a/ee/app/assets/javascripts/pages/sessions/new/index.js b/ee/app/assets/javascripts/pages/sessions/new/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c3dfc113cc6eaf22dae59ce0478cd2c8db4119ed
--- /dev/null
+++ b/ee/app/assets/javascripts/pages/sessions/new/index.js
@@ -0,0 +1,11 @@
+import '~/pages/sessions/new/index';
+
+if (gon.features.arkoseLabsLoginChallenge) {
+  import('ee/arkose_labs')
+    .then(({ setupArkoseLabs }) => {
+      setupArkoseLabs();
+    })
+    .catch((e) => {
+      throw e;
+    });
+}
diff --git a/ee/app/assets/javascripts/rest_api.js b/ee/app/assets/javascripts/rest_api.js
index 9f2f956617ea9813528982097182b76cb36d05f0..fc5f856e2bd10bbf85aa22ca956ab668344ba819 100644
--- a/ee/app/assets/javascripts/rest_api.js
+++ b/ee/app/assets/javascripts/rest_api.js
@@ -1,3 +1,4 @@
 export * from './api/groups_api';
 export * from './api/subscriptions_api';
 export * from './api/dora_api';
+export * from './api/arkose_labs_api';
diff --git a/ee/app/controllers/concerns/arkose_labs_csp.rb b/ee/app/controllers/concerns/arkose_labs_csp.rb
new file mode 100644
index 0000000000000000000000000000000000000000..27209283536b26b51283d1db0bb836f8d96cbf01
--- /dev/null
+++ b/ee/app/controllers/concerns/arkose_labs_csp.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module ArkoseLabsCSP
+  extend ActiveSupport::Concern
+
+  included do
+    content_security_policy do |policy|
+      next unless Feature.enabled?(:arkose_labs_login_challenge)
+
+      default_script_src = policy.directives['script-src'] || policy.directives['default-src']
+      script_src_values = Array.wrap(default_script_src) | ["https://client-api.arkoselabs.com"]
+      policy.script_src(*script_src_values)
+
+      default_frame_src = policy.directives['frame-src'] || policy.directives['default-src']
+      frame_src_values = Array.wrap(default_frame_src) | ['https://client-api.arkoselabs.com']
+      policy.frame_src(*frame_src_values)
+    end
+  end
+end
diff --git a/ee/app/controllers/ee/sessions_controller.rb b/ee/app/controllers/ee/sessions_controller.rb
index a49e3bf99afb5922b530877ff96eb129c1aa91ab..4e44ec6755c926f89c90700cb68da2b551c73921 100644
--- a/ee/app/controllers/ee/sessions_controller.rb
+++ b/ee/app/controllers/ee/sessions_controller.rb
@@ -6,7 +6,12 @@ module SessionsController
     extend ::Gitlab::Utils::Override
 
     prepended do
+      include ArkoseLabsCSP
+
       before_action :gitlab_geo_logout, only: [:destroy]
+      before_action only: [:new] do
+        push_frontend_feature_flag(:arkose_labs_login_challenge, default_enabled: :yaml)
+      end
     end
 
     override :new
@@ -18,6 +23,10 @@ def new
         state = geo_login_state.encode
         redirect_to oauth_geo_auth_url(host: current_node_uri.host, port: current_node_uri.port, state: state)
       else
+        if ::Feature.enabled?(:arkose_labs_login_challenge)
+          @arkose_labs_public_key ||= ENV['ARKOSE_LABS_PUBLIC_KEY'] # rubocop:disable Gitlab/ModuleWithInstanceVariables
+        end
+
         super
       end
     end
diff --git a/ee/app/views/devise/sessions/_arkose_labs.html.haml b/ee/app/views/devise/sessions/_arkose_labs.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..1486452f526f10bf5514df9f049242e2b4c738ab
--- /dev/null
+++ b/ee/app/views/devise/sessions/_arkose_labs.html.haml
@@ -0,0 +1 @@
+.js-arkose-labs-challenge{ data: { api_key: @arkose_labs_public_key } }
diff --git a/ee/spec/features/users/arkose_labs_csp_spec.rb b/ee/spec/features/users/arkose_labs_csp_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..16d23c94953bbf25f1b2b6748f3794c2ac0cc0aa
--- /dev/null
+++ b/ee/spec/features/users/arkose_labs_csp_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ArkoseLabs content security policy' do
+  let(:user) { create(:user) }
+
+  it 'has proper Content Security Policy headers' do
+    visit root_path
+
+    expect(response_headers['Content-Security-Policy']).to include('https://client-api.arkoselabs.com')
+  end
+end
diff --git a/ee/spec/frontend/api/arkose_labs_api_spec.js b/ee/spec/frontend/api/arkose_labs_api_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..1b8635309d0c83d64cdf06133a2646a1ebba554f
--- /dev/null
+++ b/ee/spec/frontend/api/arkose_labs_api_spec.js
@@ -0,0 +1,41 @@
+import MockAdapter from 'axios-mock-adapter';
+import * as arkoseLabsApi from 'ee/api/arkose_labs_api';
+import axios from '~/lib/utils/axios_utils';
+
+describe('ArkoseLabs API', () => {
+  let axiosMock;
+
+  beforeEach(() => {
+    window.gon = { api_version: 'v4' };
+    axiosMock = new MockAdapter(axios);
+  });
+
+  afterEach(() => {
+    axiosMock.restore();
+  });
+
+  describe('needsArkoseLabsChallenge', () => {
+    beforeEach(() => {
+      jest.spyOn(axios, 'get');
+      axiosMock.onGet().reply(200);
+    });
+
+    it.each`
+      username        | expectedUrlFragment
+      ${undefined}    | ${''}
+      ${''}           | ${''}
+      ${'foo'}        | ${'foo'}
+      ${'éøà'}        | ${'%C3%A9%C3%B8%C3%A0'}
+      ${'dot.slash/'} | ${'dot.slash%2F'}
+    `(
+      'calls the API with $expectedUrlFragment in the URL when given $username as the username',
+      ({ username, expectedUrlFragment }) => {
+        arkoseLabsApi.needsArkoseLabsChallenge(username);
+
+        expect(axios.get).toHaveBeenCalledWith(
+          `/api/v4/users/${expectedUrlFragment}/captcha_check`,
+        );
+      },
+    );
+  });
+});
diff --git a/ee/spec/frontend/arkose_labs/components/sign_in_arkose_app_spec.js b/ee/spec/frontend/arkose_labs/components/sign_in_arkose_app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..36de43cf9ac6c4c8ae8950a7954582790a0d2999
--- /dev/null
+++ b/ee/spec/frontend/arkose_labs/components/sign_in_arkose_app_spec.js
@@ -0,0 +1,250 @@
+import { nextTick } from 'vue';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import SignInArkoseApp from 'ee/arkose_labs/components/sign_in_arkose_app.vue';
+import axios from '~/lib/utils/axios_utils';
+import { logError } from '~/lib/logger';
+import waitForPromises from 'helpers/wait_for_promises';
+import { initArkoseLabsScript } from 'ee/arkose_labs/init_arkose_labs_script';
+
+jest.mock('~/lib/logger');
+// ArkoseLabs enforcement mocks
+jest.mock('ee/arkose_labs/init_arkose_labs_script');
+let onShown;
+let onCompleted;
+let onError;
+initArkoseLabsScript.mockImplementation(() => ({
+  setConfig: ({ onShown: shownHandler, onCompleted: completedHandler, onError: errorHandler }) => {
+    onShown = shownHandler;
+    onCompleted = completedHandler;
+    onError = errorHandler;
+  },
+}));
+
+const MOCK_USERNAME = 'cassiopeia';
+const MOCK_PUBLIC_KEY = 'arkose-labs-public-api-key';
+
+describe('SignInArkoseApp', () => {
+  let wrapper;
+  let axiosMock;
+
+  // Finders
+  const makeTestIdSelector = (testId) => `[data-testid="${testId}"]`;
+  const findByTestId = (testId) => document.querySelector(makeTestIdSelector(testId));
+  const findSignInForm = () => findByTestId('sign-in-form');
+  const findUsernameInput = () => findByTestId('username-field');
+  const findSignInButton = () => findByTestId('sign-in-button');
+  const findArkoseLabsErrorMessage = () => wrapper.findByTestId('arkose-labs-error-message');
+  const findArkoseLabsVerificationTokenInput = () =>
+    wrapper.find('input[name="arkose_labs_token"]');
+
+  // Helpers
+  const createForm = (username = '') => {
+    loadFixtures('sessions/new.html');
+    findUsernameInput().value = username;
+  };
+  const initArkoseLabs = (username) => {
+    createForm(username);
+    wrapper = mountExtended(SignInArkoseApp, {
+      propsData: {
+        publicKey: MOCK_PUBLIC_KEY,
+        formSelector: makeTestIdSelector('sign-in-form'),
+        usernameSelector: makeTestIdSelector('username-field'),
+        submitSelector: makeTestIdSelector('sign-in-button'),
+      },
+    });
+  };
+  const setUsername = (username) => {
+    const input = findUsernameInput();
+    input.focus();
+    input.value = username;
+    input.blur();
+  };
+  const submitForm = () => {
+    findSignInForm().dispatchEvent(new Event('submit'));
+  };
+
+  // Assertions
+  const itInitializesArkoseLabs = () => {
+    it("includes ArkoseLabs' script", () => {
+      expect(initArkoseLabsScript).toHaveBeenCalledWith({ publicKey: MOCK_PUBLIC_KEY });
+    });
+
+    it('creates a hidden input for the verification token', () => {
+      const input = findArkoseLabsVerificationTokenInput();
+
+      expect(input.exists()).toBe(true);
+      expect(input.element.value).toBe('');
+    });
+  };
+  const expectHiddenArkoseLabsError = () => {
+    expect(findArkoseLabsErrorMessage().exists()).toBe(false);
+  };
+  const expectArkoseLabsInitError = () => {
+    expect(wrapper.text()).toContain(wrapper.vm.$options.MSG_ARKOSE_FAILURE_BODY);
+  };
+
+  beforeEach(() => {
+    axiosMock = new AxiosMockAdapter(axios);
+  });
+
+  afterEach(() => {
+    axiosMock.restore();
+    wrapper?.destroy();
+  });
+
+  describe('when the username field is pre-filled', () => {
+    it("does not include ArkoseLabs' script initially", () => {
+      expect(initArkoseLabsScript).not.toHaveBeenCalled();
+    });
+
+    it('puts the sign-in button in the loading state', async () => {
+      initArkoseLabs(MOCK_USERNAME);
+      await nextTick();
+      const signInButton = findSignInButton();
+
+      expect(signInButton.innerText).toMatchInterpolatedText('Loading');
+      expect(signInButton.disabled).toBe(true);
+    });
+
+    it('triggers a request to the captcha_check API', async () => {
+      initArkoseLabs(MOCK_USERNAME);
+
+      expect(axiosMock.history.get).toHaveLength(0);
+
+      await waitForPromises();
+
+      expect(axiosMock.history.get).toHaveLength(1);
+      expect(axiosMock.history.get[0].url).toMatch(`/users/${MOCK_USERNAME}/captcha_check`);
+    });
+
+    describe('if the challenge is not needed', () => {
+      beforeEach(async () => {
+        axiosMock.onGet().reply(200, { result: false });
+        initArkoseLabs(MOCK_USERNAME);
+        await waitForPromises();
+      });
+
+      it('resets the loading button', () => {
+        const signInButton = findSignInButton();
+
+        expect(signInButton.innerText).toMatchInterpolatedText('Sign in');
+        expect(signInButton.disabled).toBe(false);
+      });
+
+      it('does not show ArkoseLabs error when submitting the form', async () => {
+        submitForm();
+        await nextTick();
+
+        expect(findArkoseLabsErrorMessage().exists()).toBe(false);
+      });
+
+      describe('if the challenge becomes needed', () => {
+        beforeEach(async () => {
+          axiosMock.onGet().reply(200, { result: true });
+          setUsername(`malicious-${MOCK_USERNAME}`);
+          await waitForPromises();
+        });
+
+        itInitializesArkoseLabs();
+      });
+    });
+
+    describe('if the challenge is needed', () => {
+      beforeEach(async () => {
+        axiosMock.onGet().reply(200, { result: true });
+        initArkoseLabs(MOCK_USERNAME);
+        await waitForPromises();
+      });
+
+      itInitializesArkoseLabs();
+
+      it('shows ArkoseLabs error when submitting the form', async () => {
+        submitForm();
+        await nextTick();
+
+        expect(findArkoseLabsErrorMessage().exists()).toBe(true);
+        expect(wrapper.text()).toContain(wrapper.vm.$options.MSG_ARKOSE_NEEDED);
+      });
+
+      it('un-hides the challenge container once the iframe has been shown', async () => {
+        expect(wrapper.isVisible()).toBe(false);
+
+        onShown();
+        await nextTick();
+
+        expect(wrapper.isVisible()).toBe(true);
+      });
+
+      it('shows an error alert if the challenge fails to load', async () => {
+        expect(wrapper.text()).not.toContain(wrapper.vm.$options.MSG_ARKOSE_FAILURE_BODY);
+
+        const error = new Error();
+        onError(error);
+
+        expect(logError).toHaveBeenCalledWith('ArkoseLabs initialization error', error);
+
+        await nextTick();
+
+        expectArkoseLabsInitError();
+      });
+
+      describe('when ArkoseLabs calls `onCompleted` handler that has been configured', () => {
+        const response = { token: 'verification-token' };
+
+        beforeEach(() => {
+          submitForm();
+
+          onCompleted(response);
+        });
+
+        it('removes ArkoseLabs error', () => {
+          expectHiddenArkoseLabsError();
+        });
+
+        it('does not show again the error when re-submitting the form', () => {
+          submitForm();
+
+          expectHiddenArkoseLabsError();
+        });
+
+        it("sets the verification token input's value", () => {
+          expect(findArkoseLabsVerificationTokenInput().element.value).toBe(response.token);
+        });
+      });
+    });
+  });
+
+  describe('when the username check fails', () => {
+    it('with a 404, nothing happens', async () => {
+      axiosMock.onGet().reply(404);
+      initArkoseLabs(MOCK_USERNAME);
+      await waitForPromises();
+
+      expect(initArkoseLabsScript).not.toHaveBeenCalled();
+      expectHiddenArkoseLabsError();
+    });
+
+    it('with some other HTTP error, the challenge is initialized', async () => {
+      axiosMock.onGet().reply(500);
+      initArkoseLabs(MOCK_USERNAME);
+      await waitForPromises();
+
+      expect(initArkoseLabsScript).toHaveBeenCalled();
+      expectHiddenArkoseLabsError();
+    });
+
+    it('due to the script inclusion, an error is shown', async () => {
+      const error = new Error();
+      initArkoseLabsScript.mockImplementation(() => {
+        throw new Error();
+      });
+      axiosMock.onGet().reply(200, { result: true });
+      initArkoseLabs(MOCK_USERNAME);
+      await waitForPromises();
+
+      expectArkoseLabsInitError();
+      expect(logError).toHaveBeenCalledWith('ArkoseLabs initialization error', error);
+    });
+  });
+});
diff --git a/ee/spec/frontend/arkose_labs/init_arkose_labs_script_spec.js b/ee/spec/frontend/arkose_labs/init_arkose_labs_script_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..eb19448ab1e9ebb222aa9fdb8291357a9dc80844
--- /dev/null
+++ b/ee/spec/frontend/arkose_labs/init_arkose_labs_script_spec.js
@@ -0,0 +1,50 @@
+import { initArkoseLabsScript } from 'ee/arkose_labs/init_arkose_labs_script';
+
+jest.mock('lodash/uniqueId', () => (x) => `${x}7`);
+
+const EXPECTED_CALLBACK_NAME = '_initArkoseLabsScript_callback_7';
+const TEST_PUBLIC_KEY = 'arkose-labs-public-api-key';
+
+describe('initArkoseLabsScript', () => {
+  let subject;
+
+  const initSubject = () => {
+    subject = initArkoseLabsScript({ publicKey: TEST_PUBLIC_KEY });
+  };
+
+  const findScriptTags = () => document.querySelectorAll('script');
+
+  afterEach(() => {
+    subject = null;
+    document.getElementsByTagName('html')[0].innerHTML = '';
+  });
+
+  it('sets a global enforcement callback', () => {
+    initSubject();
+
+    expect(window[EXPECTED_CALLBACK_NAME]).not.toBe(undefined);
+  });
+
+  it('adds ArkoseLabs scripts to the HTML head', () => {
+    expect(findScriptTags()).toHaveLength(0);
+
+    initSubject();
+
+    const scriptTag = findScriptTags().item(0);
+
+    expect(scriptTag.getAttribute('type')).toBe('text/javascript');
+    expect(scriptTag.getAttribute('src')).toBe(
+      `https://client-api.arkoselabs.com/v2/${TEST_PUBLIC_KEY}/api.js`,
+    );
+    expect(scriptTag.getAttribute('data-callback')).toBe(EXPECTED_CALLBACK_NAME);
+  });
+
+  it('when callback is called, cleans up the global object and resolves the Promise', () => {
+    initSubject();
+    const enforcement = 'ArkoseLabsEnforcement';
+    window[EXPECTED_CALLBACK_NAME](enforcement);
+
+    expect(window[EXPECTED_CALLBACK_NAME]).toBe(undefined);
+    return expect(subject).resolves.toBe(enforcement);
+  });
+});
diff --git a/ee/spec/views/devise/sessions/new.html.haml_spec.rb b/ee/spec/views/devise/sessions/new.html.haml_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9d8b03b5ac4f274a69d2d6370f6c94fdaaed6262
--- /dev/null
+++ b/ee/spec/views/devise/sessions/new.html.haml_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'devise/sessions/new' do
+  before do
+    view.instance_variable_set(:@arkose_labs_public_key, "arkose-api-key")
+  end
+
+  describe 'ArkoseLabs challenge' do
+    subject { render(template: 'devise/sessions/new', layout: 'layouts/devise') }
+
+    before do
+      stub_devise
+      disable_captcha
+      allow(Gitlab).to receive(:com?).and_return(true)
+    end
+
+    context 'when the :arkose_labs_login_challenge feature flag is enabled' do
+      before do
+        stub_feature_flags(arkose_labs_login_challenge: true)
+
+        subject
+      end
+
+      it 'renders the challenge container' do
+        expect(rendered).to have_css('.js-arkose-labs-challenge')
+      end
+
+      it 'passes the API key to the challenge container' do
+        expect(rendered).to have_selector('.js-arkose-labs-challenge[data-api-key="arkose-api-key"]')
+      end
+    end
+
+    context 'when the :arkose_labs_login_challenge feature flag is disabled' do
+      before do
+        stub_feature_flags(arkose_labs_login_challenge: false)
+
+        subject
+      end
+
+      it 'does not render challenge container' do
+        expect(rendered).not_to have_css('.js-arkose-labs-challenge')
+      end
+    end
+  end
+
+  def stub_devise
+    allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user])
+    allow(view).to receive(:resource).and_return(spy)
+    allow(view).to receive(:resource_name).and_return(:user)
+  end
+
+  def disable_captcha
+    allow(view).to receive(:captcha_enabled?).and_return(false)
+    allow(view).to receive(:captcha_on_login_required?).and_return(false)
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9762e2b4f12b643d27ab34fe4570a5f02ad5045a..51decc88b248e3ddf88a55e00ddb1451aee9fdc8 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3819,6 +3819,9 @@ msgstr ""
 msgid "An error occurred previewing the blob"
 msgstr ""
 
+msgid "An error occurred when loading the user verification challenge. Refresh to try again."
+msgstr ""
+
 msgid "An error occurred when updating the title"
 msgstr ""
 
@@ -9176,6 +9179,9 @@ msgstr ""
 msgid "Complete"
 msgstr ""
 
+msgid "Complete verification to sign in."
+msgstr ""
+
 msgid "Completed"
 msgstr ""
 
@@ -39801,6 +39807,9 @@ msgstr ""
 msgid "Unable to update this issue at this time."
 msgstr ""
 
+msgid "Unable to verify the user"
+msgstr ""
+
 msgid "Unapprove a merge request"
 msgstr ""