diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..c562e809cbf23ad90e75df94976b804f75733210
--- /dev/null
+++ b/ee/app/assets/javascripts/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql
@@ -0,0 +1,9 @@
+mutation vulnerabilityCreate($input: VulnerabilityCreateInput!) {
+  vulnerabilityCreate(input: $input) {
+    errors
+    vulnerability {
+      id
+      vulnerabilityPath
+    }
+  }
+}
diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/i18n.js b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/i18n.js
new file mode 100644
index 0000000000000000000000000000000000000000..fa52eef7f76a4d9873fee96456472a52d4368c0e
--- /dev/null
+++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/i18n.js
@@ -0,0 +1,3 @@
+import { __ } from '~/locale';
+
+export const REQUIRED_FIELD = __('This field is required.');
diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue
index 896aef742095049cc375e9f5e75c1e6e3e04ab2a..ba054a8778fa695eb3371b81cc346afe12fc763b 100644
--- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue
+++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue
@@ -1,6 +1,11 @@
 <script>
-import { GlForm } from '@gitlab/ui';
+import { GlForm, GlButton, GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
 import { s__ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_PROJECT } from '~/graphql_shared/constants';
+import { redirectTo } from '~/lib/utils/url_utility';
+import createVulnerabilityMutation from 'ee/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql';
 import SectionDetails from './section_details.vue';
 import SectionIdentifiers from './section_identifiers.vue';
 import SectionName from './section_name.vue';
@@ -10,11 +15,14 @@ export default {
   name: 'NewVulnerabilityForm',
   components: {
     GlForm,
+    GlButton,
+    GlAlert,
     SectionDetails,
     SectionIdentifiers,
     SectionName,
     SectionSolution,
   },
+  inject: ['projectId'],
   data() {
     return {
       form: {
@@ -25,18 +33,165 @@ export default {
         detectionMethod: '',
         identifiers: [],
       },
+      validation: {
+        severity: null,
+        status: null,
+        name: null,
+        identifiers: [],
+      },
+      submitting: false,
+      errors: [],
     };
   },
+  computed: {
+    shouldShowAlert() {
+      return this.errors.length > 0;
+    },
+  },
+  watch: {
+    shouldShowAlert(newValue) {
+      if (newValue) {
+        this.scrollTop();
+      }
+    },
+  },
   methods: {
+    scrollTop() {
+      this.$nextTick(() => {
+        window.scrollTo({ top: 0, behavior: 'smooth' });
+      });
+    },
+
+    async submitForm() {
+      this.errors = this.validateFormValues();
+
+      if (this.errors.length > 0) {
+        if (this.shouldShowAlert) {
+          this.scrollTop();
+        }
+
+        return;
+      }
+
+      this.submitting = true;
+
+      try {
+        const { data } = await this.$apollo.mutate({
+          mutation: createVulnerabilityMutation,
+          variables: {
+            input: {
+              project: convertToGraphQLId(TYPE_PROJECT, this.projectId),
+              name: this.form.vulnerabilityName,
+              description: this.form.vulnerabilityDesc,
+              severity: this.form.severity.toUpperCase(),
+              state: this.form.status.toUpperCase(),
+              identifiers: this.form.identifiers,
+              solution: this.form.solution,
+              // The scanner needs to be hardcoded because of two reasons:
+              // 1. It's a required field in the backend.
+              // 2. We expect that the manually created vulnerabilities are the ones that the scanners cannot catch.
+              //    So most likely the scanner will be left out even if we present an option to choose which one.
+              scanner: {
+                id: 'gitlab-manual-vulnerability-report',
+                name: 'manually-created-vulnerability',
+                url: 'https://gitlab.com',
+                version: '1.0',
+                vendor: {
+                  name: 'GitLab',
+                },
+              },
+            },
+          },
+        });
+
+        if (data.vulnerabilityCreate.vulnerability?.vulnerabilityPath) {
+          redirectTo(data.vulnerabilityCreate.vulnerability.vulnerabilityPath);
+          return;
+        }
+
+        if (data.vulnerabilityCreate.errors) {
+          this.errors = data.vulnerabilityCreate.errors;
+        } else {
+          throw new Error(this.$options.i18n.submitError);
+        }
+      } catch (error) {
+        this.errors = [this.$options.i18n.submitError];
+        Sentry.captureException({ error, component: this.$options.name });
+      }
+
+      this.submitting = false;
+    },
+
+    validateFormValues() {
+      const errors = [];
+      const {
+        vulnerabilityName,
+        vulnerabilityState,
+        vulnerabilitySeverity,
+        vulnerabilityIdentifiers,
+      } = this.$options.i18n.errors;
+
+      this.validation = {};
+
+      if (!this.form.vulnerabilityName) {
+        this.validation.name = false;
+        errors.push(vulnerabilityName);
+      }
+
+      if (!this.form.status) {
+        this.validation.status = false;
+        errors.push(vulnerabilityState);
+      }
+
+      if (!this.form.severity) {
+        this.validation.severity = false;
+        errors.push(vulnerabilitySeverity);
+      }
+
+      if (!this.form.identifiers?.length) {
+        this.validation.identifiers = [{ identifierCode: false, identifierUrl: false }];
+        errors.push(vulnerabilityIdentifiers);
+      } else {
+        this.validation.identifiers = [];
+        this.validation.identifiers = this.form.identifiers.map((item) => ({
+          identifierCode: Boolean(item.name),
+          identifierUrl: Boolean(item.url),
+        }));
+
+        if (this.validation.identifiers.find((i) => !i.identifierUrl || !i.identifierCode)) {
+          errors.push(vulnerabilityIdentifiers);
+        }
+      }
+
+      return errors;
+    },
+
     updateFormValues(values) {
       this.form = { ...this.form, ...values };
+
+      // If there are previous errors, revalidate the form.
+      if (this.errors.length) {
+        this.validateFormValues();
+      }
+    },
+
+    dismissAlert() {
+      this.errors = [];
     },
   },
   i18n: {
     title: s__('VulnerabilityManagement|Add vulnerability finding'),
+    submitVulnerability: s__('VulnerabilityManagement|Submit vulnerability'),
+    submitError: s__('VulnerabilityManagement|Something went wrong while creating vulnerability'),
     description: s__(
       'VulnerabilityManagement|Manually add a vulnerability entry into the vulnerability report.',
     ),
+    errors: {
+      vulnerabilityName: s__('VulnerabilityManagement|Name is a required field'),
+      vulnerabilitySeverity: s__('VulnerabilityManagement|Severity is a required field'),
+      vulnerabilityState: s__('VulnerabilityManagement|Status is a required field'),
+      vulnerabilityIdentifiers: s__('VulnerabilityManagement|At least one identifier is required'),
+    },
   },
 };
 </script>
@@ -51,11 +206,28 @@ export default {
         {{ $options.i18n.description }}
       </p>
     </header>
-    <gl-form class="gl-p-4 gl-w-85p" @submit.prevent>
-      <section-name @change="updateFormValues" />
-      <section-details @change="updateFormValues" />
-      <section-identifiers @change="updateFormValues" />
-      <section-solution @change="updateFormValues" />
+    <gl-form @submit.prevent="submitForm">
+      <gl-alert v-if="shouldShowAlert" variant="danger" dismissible @dismiss="dismissAlert">
+        <ul v-if="errors.length > 1" class="gl-mb-0 gl-pl-5">
+          <li v-for="error in errors" :key="error">{{ error }}</li>
+        </ul>
+        <span v-else>{{ errors[0] }}</span>
+      </gl-alert>
+      <div class="gl-p-4 gl-w-85p">
+        <section-name :validation-state="validation" @change="updateFormValues" />
+        <section-details :validation-state="validation" @change="updateFormValues" />
+        <section-identifiers :validation-state="validation" @change="updateFormValues" />
+        <section-solution @change="updateFormValues" />
+      </div>
+      <div class="gl-mt-5 gl-pt-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
+        <gl-button
+          type="submit"
+          variant="confirm"
+          class="js-no-auto-disable"
+          :disabled="submitting"
+          >{{ $options.i18n.submitVulnerability }}</gl-button
+        >
+      </div>
     </gl-form>
   </div>
 </template>
diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue
index 145d35b911848fb25ef8cc75465374175b0edcd6..d3f306671ab06d469055b92fc76dbd3c261e6ef7 100644
--- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue
+++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue
@@ -11,6 +11,7 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba
 import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
 import { SEVERITY_LEVELS, DETECTION_METHODS } from 'ee/security_dashboard/store/constants';
 import { s__, __ } from '~/locale';
+import * as i18n from './i18n';
 
 export default {
   components: {
@@ -22,6 +23,13 @@ export default {
     GlFormRadioGroup,
     SeverityBadge,
   },
+  props: {
+    validationState: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+  },
   data() {
     return {
       // Note: The cvss field is disabled during the MVC because the backend implementation
@@ -33,6 +41,8 @@ export default {
       statusId: '',
       severity: '',
       detectionMethod: -1,
+      severityState: null,
+      statusState: null,
     };
   },
   computed: {
@@ -98,6 +108,7 @@ export default {
     critical: [9.0, 10.0],
   },
   i18n: {
+    requiredField: i18n.REQUIRED_FIELD,
     title: s__('Vulnerability|Details'),
     description: s__(
       'Vulnerability|Information related how the vulnerability was discovered and its impact to the system.',
@@ -152,6 +163,8 @@ export default {
     <div class="gl-display-flex gl-mb-6">
       <gl-form-group
         :label="$options.i18n.severity.label"
+        :state="validationState.severity"
+        :invalid-feedback="$options.i18n.requiredField"
         label-for="form-severity"
         class="gl-mr-6 gl-mb-0"
       >
@@ -175,7 +188,11 @@ export default {
         <gl-form-input id="form-cvss" v-model="cvss" class="gl-mb-2" type="text" />
       </gl-form-group>
     </div>
-    <gl-form-group :label="$options.i18n.status.label">
+    <gl-form-group
+      :label="$options.i18n.status.label"
+      :state="validationState.status"
+      :invalid-feedback="$options.i18n.requiredField"
+    >
       <p>{{ $options.i18n.status.description }}</p>
       <gl-form-radio-group :checked="statusId" @change="emitChanges">
         <label
diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue
index 4eede6ff61b182ce9f54a7f0b23fd60453f34405..7cc4f2c19fa07301332bcdf88b655be0649f8594 100644
--- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue
+++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue
@@ -1,6 +1,7 @@
 <script>
 import { GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui';
 import { s__ } from '~/locale';
+import * as i18n from './i18n';
 
 export default {
   components: {
@@ -8,25 +9,52 @@ export default {
     GlFormInput,
     GlButton,
   },
-  id: 0,
+  props: {
+    validationState: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+  },
   data() {
     return {
-      identifiers: [{ identifierCode: '', identifierUrl: '', id: this.$options.id }],
+      identifiers: [{ identifierCode: '', identifierUrl: '' }],
     };
   },
   methods: {
     emitChanges() {
-      this.$emit('change', { identifiers: this.identifiers });
+      this.$emit('change', {
+        identifiers: this.identifiers.map((i) => ({
+          name: i.identifierCode,
+          url: i.identifierUrl,
+        })),
+      });
     },
     addIdentifier() {
-      this.$options.id += 1;
-      this.identifiers.push({ identifierCode: '', identifierUrl: '', id: this.$options.id });
+      this.identifiers.push({ identifierCode: '', identifierUrl: '' });
+    },
+    removeIdentifier(index) {
+      this.identifiers.splice(index, 1);
+      this.emitChanges();
     },
-    removeIdentifier(id) {
-      this.identifiers = this.identifiers.filter((i) => i.id !== id);
+    // null is when the user didn't input anything yet
+    // false when the user provided invalid input and
+    // true when the validation passes
+    validationStateIdentifierUrl(index) {
+      return this.validationState.identifiers?.[index]?.identifierUrl ?? null;
+    },
+    validationStateIdentifierCode(index) {
+      return this.validationState.identifiers?.[index]?.identifierCode ?? null;
+    },
+    rowHasError(index) {
+      return (
+        this.validationStateIdentifierUrl(index) === false ||
+        this.validationStateIdentifierCode(index) === false
+      );
     },
   },
   i18n: {
+    requiredField: i18n.REQUIRED_FIELD,
     title: s__('Vulnerability|Identifiers'),
     description: s__(
       'Vulnerability|Enter the associated CVE or CWE entries for this vulnerability.',
@@ -50,41 +78,48 @@ export default {
       </p>
     </header>
     <div
-      v-for="identifier in identifiers"
-      :key="identifier.id"
+      v-for="(identifier, index) in identifiers"
+      :key="index"
       data-testid="identifier-row"
       class="gl-display-flex gl-mb-6"
     >
       <gl-form-group
         :label="$options.i18n.identifierCode"
-        :label-for="`form-identifier-code-${identifier.id}`"
+        :label-for="`form-identifier-code-${index}`"
+        :state="validationStateIdentifierCode(index)"
+        :invalid-feedback="$options.i18n.requiredField"
         class="gl-mr-6 gl-mb-0"
       >
         <gl-form-input
-          :id="`form-identifier-code-${identifier.id}`"
-          v-model="identifier.identifierCode"
+          :id="`form-identifier-code-${index}`"
+          v-model.trim="identifier.identifierCode"
+          :state="validationStateIdentifierCode(index)"
           type="text"
           @change="emitChanges"
         />
       </gl-form-group>
       <gl-form-group
         :label="$options.i18n.identifierUrl"
-        :label-for="`form-identifier-url-${identifier.id}`"
+        :state="validationStateIdentifierUrl(index)"
+        :invalid-feedback="$options.i18n.requiredField"
+        :label-for="`form-identifier-url-${index}`"
         class="gl-flex-grow-1 gl-mb-0"
       >
         <gl-form-input
-          :id="`form-identifier-url-${identifier.id}`"
-          v-model="identifier.identifierUrl"
+          :id="`form-identifier-url-${index}`"
+          v-model.trim="identifier.identifierUrl"
+          :state="validationStateIdentifierUrl(index)"
           type="text"
           @change="emitChanges"
         />
       </gl-form-group>
       <gl-button
-        v-if="identifier.id > 0"
-        class="gl-align-self-end gl-ml-4 gl-shadow-none!"
+        v-if="index > 0"
+        class="gl-ml-4 gl-shadow-none!"
+        :class="rowHasError(index) ? 'gl-align-self-center' : 'gl-align-self-end'"
         icon="remove"
         :aria-label="$options.i18n.removeIdentifierRow"
-        @click="removeIdentifier(identifier.id)"
+        @click="removeIdentifier(index)"
       />
       <!-- 
         The first row does not contain a remove button and this creates
@@ -98,12 +133,8 @@ export default {
         icon="remove"
       />
     </div>
-    <gl-button
-      data-testid="add-identifier-row"
-      category="secondary"
-      variant="confirm"
-      @click="addIdentifier"
-      >{{ $options.i18n.addIdentifier }}</gl-button
-    >
+    <gl-button category="secondary" variant="confirm" @click="addIdentifier">{{
+      $options.i18n.addIdentifier
+    }}</gl-button>
   </section>
 </template>
diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue
index 3799691b615d2b55310667c84ac7bb11403c7f1c..d5c04410328f442277786e76bc8d9c9fae6cd71a 100644
--- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue
+++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue
@@ -2,6 +2,7 @@
 import { GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
 import { s__, __ } from '~/locale';
 import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import * as i18n from './i18n';
 
 export default {
   components: {
@@ -11,6 +12,13 @@ export default {
     MarkdownField,
   },
   inject: ['markdownDocsPath', 'markdownPreviewPath'],
+  props: {
+    validationState: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+  },
   data() {
     return {
       isSubmitting: false,
@@ -27,6 +35,7 @@ export default {
     },
   },
   i18n: {
+    requiredField: i18n.REQUIRED_FIELD,
     vulnerabilityName: {
       label: __('Name'),
       description: s__(
@@ -47,12 +56,15 @@ export default {
     <gl-form-group
       :label="$options.i18n.vulnerabilityName.label"
       :description="$options.i18n.vulnerabilityName.description"
+      :state="validationState.name"
+      :invalid-feedback="$options.i18n.requiredField"
       label-for="form-vulnerability-name"
       class="gl-mb-6"
     >
       <gl-form-input
         id="form-vulnerability-name"
-        v-model="vulnerabilityName"
+        v-model.trim="vulnerabilityName"
+        :state="validationState.name"
         type="text"
         @change="emitChanges"
       />
@@ -76,7 +88,7 @@ export default {
           <template #textarea>
             <gl-form-textarea
               id="form-vulnerability-desc"
-              v-model="vulnerabilityDesc"
+              v-model.trim="vulnerabilityDesc"
               rows="8"
               class="gl-shadow-none! gl-px-0! gl-py-4! gl-h-auto!"
               :aria-label="$options.i18n.vulnerabilityDesc.description"
diff --git a/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js b/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js
index e156d6bb7271c36c25dbf4e6110ac84900e20b8b..a7a99d70180204293b013b11313fedeb15b5e420 100644
--- a/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js
+++ b/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js
@@ -13,6 +13,7 @@ export default (el) => {
     provide: {
       markdownDocsPath: el.dataset.markdownDocsPath,
       markdownPreviewPath: el.dataset.markdownPreviewPath,
+      projectId: el.dataset.projectId,
     },
     render: (h) => h(App),
   });
diff --git a/ee/app/views/projects/security/vulnerabilities/new.html.haml b/ee/app/views/projects/security/vulnerabilities/new.html.haml
index 80b404661af21a41e147dc4fac5418c4e734d815..18b35f0f4a1a00d43e6f2480a882d2c331e3d3d3 100644
--- a/ee/app/views/projects/security/vulnerabilities/new.html.haml
+++ b/ee/app/views/projects/security/vulnerabilities/new.html.haml
@@ -4,4 +4,6 @@
 - page_title _("Add vulnerability finding")
 - add_page_specific_style 'page_bundles/security_dashboard'
 
-#js-vulnerability-new{ data: { markdown_docs_path: help_page_path('user/markdown'), markdown_preview_path: preview_markdown_path(@project) } }
+#js-vulnerability-new{ data: { markdown_docs_path: help_page_path('user/markdown'),
+                               markdown_preview_path: preview_markdown_path(@project),
+                               project_id: @project.id } }
diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js
index 0a2068b75b93ac9fce085cbf9b4a7a2d4ea807e0..e41c3cbdf2191fea2f88888e8f6a92e231db401d 100644
--- a/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js
+++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js
@@ -1,54 +1,246 @@
-import { GlForm } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import { GlForm, GlAlert, GlButton } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import * as Sentry from '@sentry/browser';
+import { redirectTo } from '~/lib/utils/url_utility';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createVulnerabilityMutation from 'ee/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql';
 import NewVulnerability from 'ee/vulnerabilities/components/new_vulnerability/new_vulnerability.vue';
 import SectionName from 'ee/vulnerabilities/components/new_vulnerability/section_name.vue';
 import SectionIdentifiers from 'ee/vulnerabilities/components/new_vulnerability/section_identifiers.vue';
 import SectionDetails from 'ee/vulnerabilities/components/new_vulnerability/section_details.vue';
 import SectionSolution from 'ee/vulnerabilities/components/new_vulnerability/section_solution.vue';
 
+Vue.use(VueApollo);
+
+jest.mock('@sentry/browser');
+
+jest.mock('~/lib/utils/url_utility', () => ({
+  redirectTo: jest.fn(),
+}));
+
 describe('New vulnerability component', () => {
+  const projectId = '22';
   let wrapper;
 
+  const inputs = {
+    sectionName: {
+      vulnerabilityName: 'CVE 2050',
+      vulnerabilityDesc: 'Password leak',
+    },
+    sectionDetails: {
+      severity: 'low',
+      detectionMethod: 2,
+      status: 'confirmed',
+    },
+    sectionIdentifiers: {
+      identifiers: [
+        {
+          name: 'CWE-94',
+          url: 'https://cwe.mitre.org/data/definitions/94.html',
+        },
+      ],
+    },
+    sectionSolution: {
+      solution: 'This is the solution of the vulnerability.',
+    },
+  };
+
+  const findForm = () => wrapper.findComponent(GlForm);
+  const findAlert = () => wrapper.findComponent(GlAlert);
   const findSectionName = () => wrapper.findComponent(SectionName);
   const findSectionDetails = () => wrapper.findComponent(SectionDetails);
   const findSectionSolution = () => wrapper.findComponent(SectionSolution);
   const findSectionIdentifiers = () => wrapper.findComponent(SectionIdentifiers);
+  const findSubmitButton = () => wrapper.findComponent(GlButton);
 
-  const createWrapper = () => {
-    return shallowMountExtended(NewVulnerability);
+  const createWrapper = ({ apolloProvider } = {}) => {
+    return shallowMountExtended(NewVulnerability, {
+      apolloProvider,
+      provide: {
+        projectId,
+      },
+    });
   };
 
-  beforeEach(() => {
-    wrapper = createWrapper();
-  });
-
   afterEach(() => {
     wrapper.destroy();
   });
 
-  it('should render the page title and description', () => {
-    expect(wrapper.findByRole('heading', { name: 'Add vulnerability finding' }).exists()).toBe(
-      true,
-    );
-    expect(wrapper.findByTestId('page-description').text()).toBe(
-      'Manually add a vulnerability entry into the vulnerability report.',
-    );
-  });
+  describe('page structure', () => {
+    beforeEach(() => {
+      wrapper = createWrapper();
+    });
+
+    it('should render the page title and description', () => {
+      expect(wrapper.findByRole('heading', { name: 'Add vulnerability finding' }).exists()).toBe(
+        true,
+      );
+      expect(wrapper.findByTestId('page-description').text()).toBe(
+        'Manually add a vulnerability entry into the vulnerability report.',
+      );
+    });
+
+    it('contains a form', () => {
+      expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+    });
+
+    it.each`
+      section                   | selector                  | fields
+      ${'Name and Description'} | ${findSectionName}        | ${inputs.sectionName}
+      ${'Details'}              | ${findSectionDetails}     | ${inputs.sectionDetails}
+      ${'Identifiers'}          | ${findSectionIdentifiers} | ${inputs.sectionIdentifiers}
+      ${'Solution'}             | ${findSectionSolution}    | ${inputs.sectionSolution}
+    `('mounts the section $section and reacts on the change event', ({ selector, fields }) => {
+      const section = selector();
+      expect(section.exists()).toBe(true);
+      section.vm.$emit('change', fields);
+      expect(wrapper.vm.form).toMatchObject(fields);
+    });
 
-  it('contains a form', () => {
-    expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+    it('contains a submit button', () => {
+      expect(findSubmitButton().exists()).toBe(true);
+    });
   });
 
-  it.each`
-    section                   | selector                  | fields
-    ${'Name and Description'} | ${findSectionName}        | ${{ vulnerabilityName: 'CVE 2050', vulnerabilityDesc: 'Password leak' }}
-    ${'Details'}              | ${findSectionDetails}     | ${{ severity: 'low', detectionMethod: 2, status: 'confirmed' }}
-    ${'Identifiers'}          | ${findSectionIdentifiers} | ${{ identifiers: [{ identifierCode: 'CWE-94', IdentifierUrl: 'https://cwe.mitre.org/data/definitions/94.html' }] }}
-    ${'Solution'}             | ${findSectionSolution}    | ${{ solution: 'This is the solution of the vulnerability.' }}
-  `('mounts the section $section and reacts on the change event', ({ selector, fields }) => {
-    const section = selector();
-    expect(section.exists()).toBe(true);
-    section.vm.$emit('change', fields);
-    expect(wrapper.vm.form).toMatchObject(fields);
+  describe('form submission', () => {
+    const updateFormValuesAndSubmitForm = async () => {
+      findSectionName().vm.$emit('change', inputs.sectionName);
+      findSectionIdentifiers().vm.$emit('change', inputs.sectionIdentifiers);
+      findSectionDetails().vm.$emit('change', inputs.sectionDetails);
+      findSectionSolution().vm.$emit('change', inputs.sectionSolution);
+      findForm().vm.$emit('submit', { preventDefault: jest.fn() });
+      await waitForPromises();
+    };
+
+    it('handles form validation', async () => {
+      const validationState = {
+        severity: false,
+        status: false,
+        name: false,
+        identifiers: [
+          {
+            identifierCode: false,
+            identifierUrl: false,
+          },
+        ],
+      };
+
+      const apolloProvider = createMockApollo([
+        [createVulnerabilityMutation, jest.fn().mockResolvedValue()],
+      ]);
+
+      wrapper = createWrapper({ apolloProvider });
+      const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate');
+      findForm().vm.$emit('submit', { preventDefault: jest.fn() });
+      await waitForPromises();
+      expect(mutateSpy).not.toHaveBeenCalled();
+      expect(findSectionDetails().props('validationState')).toEqual(validationState);
+      expect(findSectionName().props('validationState')).toEqual(validationState);
+      expect(findSectionIdentifiers().props('validationState')).toEqual(validationState);
+    });
+
+    it('submits the form successfully', async () => {
+      const apolloProvider = createMockApollo([
+        [
+          createVulnerabilityMutation,
+          jest.fn().mockResolvedValue({
+            data: {
+              vulnerabilityCreate: {
+                vulnerability: {
+                  id: 'gid://gitlab/Vulnerability/20345379',
+                  vulnerabilityPath: '/path/to/vulnerability/20345379',
+                },
+                errors: null,
+              },
+            },
+          }),
+        ],
+      ]);
+
+      wrapper = createWrapper({ apolloProvider });
+      const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate');
+      await updateFormValuesAndSubmitForm();
+
+      expect(mutateSpy).toHaveBeenCalledWith(
+        expect.objectContaining({
+          variables: {
+            input: {
+              description: 'Password leak',
+              identifiers: [
+                {
+                  url: 'https://cwe.mitre.org/data/definitions/94.html',
+                  name: 'CWE-94',
+                },
+              ],
+              scanner: {
+                id: 'gitlab-manual-vulnerability-report',
+                name: 'manually-created-vulnerability',
+                url: 'https://gitlab.com',
+                version: '1.0',
+                vendor: {
+                  name: 'GitLab',
+                },
+              },
+              name: 'CVE 2050',
+              project: 'gid://gitlab/Project/22',
+              severity: 'LOW',
+              solution: 'This is the solution of the vulnerability.',
+              state: 'CONFIRMED',
+            },
+          },
+        }),
+      );
+
+      expect(redirectTo).toHaveBeenCalledWith('/path/to/vulnerability/20345379');
+      expect(findAlert().exists()).toBe(false);
+    });
+
+    it('handles form submission error and displays alert component when there are errors', async () => {
+      const apolloProvider = createMockApollo([
+        [
+          createVulnerabilityMutation,
+          jest.fn().mockResolvedValue({
+            data: {
+              vulnerabilityCreate: {
+                vulnerability: null,
+                errors: [{ message: 'Something went wrong' }],
+              },
+            },
+          }),
+        ],
+      ]);
+
+      wrapper = createWrapper({ apolloProvider });
+      await updateFormValuesAndSubmitForm();
+      expect(redirectTo).not.toHaveBeenCalled();
+      expect(Sentry.captureException).not.toHaveBeenCalled();
+      await nextTick();
+      expect(findAlert().exists()).toBe(true);
+    });
+
+    it('handles form submission error and logs to sentry when the error is unknown', async () => {
+      const apolloProvider = createMockApollo([
+        [
+          createVulnerabilityMutation,
+          jest.fn().mockRejectedValue({
+            data: {
+              vulnerabilityCreate: {
+                vulnerability: null,
+              },
+            },
+          }),
+        ],
+      ]);
+
+      wrapper = createWrapper({ apolloProvider });
+      await updateFormValuesAndSubmitForm();
+      expect(redirectTo).not.toHaveBeenCalled();
+      expect(Sentry.captureException).toHaveBeenCalled();
+      await nextTick();
+      expect(findAlert().exists()).toBe(true);
+    });
   });
 });
diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js
index 0a5957ab58fb6dfaf91284392b180e6f7e69a732..5b1705705fa0c0c85c2cf7c7433d85aeed1009b1 100644
--- a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js
+++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js
@@ -1,5 +1,5 @@
 import { nextTick } from 'vue';
-import { GlDropdown, GlDropdownItem, GlFormRadio } from '@gitlab/ui';
+import { GlFormGroup, GlDropdown, GlDropdownItem, GlFormRadio } from '@gitlab/ui';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import SectionDetails from 'ee/vulnerabilities/components/new_vulnerability/section_details.vue';
 import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
@@ -7,6 +7,8 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba
 describe('New vulnerability - Section Details', () => {
   let wrapper;
 
+  const findFormGroup = (at) => wrapper.findAllComponents(GlFormGroup).at(at);
+
   const findDetectionMethodItem = (at) =>
     wrapper.findAllComponents(GlDropdown).at(0).findAllComponents(GlDropdownItem).at(at);
 
@@ -78,4 +80,28 @@ describe('New vulnerability - Section Details', () => {
       status: value,
     });
   });
+
+  it('does not display invalid state by default', () => {
+    expect(findFormGroup(1).attributes('aria-invalid')).toBeUndefined();
+    expect(findFormGroup(0).attributes('aria-invalid')).toBeUndefined();
+  });
+
+  it('handles form validation', async () => {
+    wrapper.setProps({
+      validationState: {
+        severity: false,
+        status: false,
+      },
+    });
+
+    await nextTick();
+
+    // severity input
+    expect(wrapper.findAllByRole('alert').at(0).text()).toBe('This field is required.');
+    expect(findFormGroup(1).attributes('aria-invalid')).toBe('true');
+
+    // status input
+    expect(wrapper.findAllByRole('alert').at(1).text()).toBe('This field is required.');
+    expect(findFormGroup(2).attributes('aria-invalid')).toBe('true');
+  });
 });
diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js
index 895ddbc8f42468bcff7bbfedcfa9e0e177ebb0d7..a7758233b750d0207058b8d3ac3977e29570e295 100644
--- a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js
+++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js
@@ -1,4 +1,5 @@
 import { nextTick } from 'vue';
+import { GlFormGroup } from '@gitlab/ui';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import SectionIdentifiers from 'ee/vulnerabilities/components/new_vulnerability/section_identifiers.vue';
 
@@ -6,7 +7,11 @@ describe('New vulnerability - Section Identifiers', () => {
   let wrapper;
 
   const createWrapper = () => {
-    return mountExtended(SectionIdentifiers);
+    return mountExtended(SectionIdentifiers, {
+      propsData: {
+        validationState: { identifiers: [{ identifierCode: false }] },
+      },
+    });
   };
 
   beforeEach(() => {
@@ -18,28 +23,56 @@ describe('New vulnerability - Section Identifiers', () => {
   });
 
   const findIdentifierRows = () => wrapper.findAllByTestId('identifier-row');
+  const findFormGroup = (index) => wrapper.findAllComponents(GlFormGroup).at(index);
+  const findIdentifierCodeInput = () => wrapper.findByLabelText('Identifier code');
+  const findIdentifierUrlInput = () => wrapper.findByLabelText('Identifier URL');
+
+  it('does not display a warning when the validation state is emtpy', async () => {
+    wrapper.setProps({
+      validationState: {
+        identifiers: [],
+      },
+    });
+
+    await nextTick();
+
+    expect(findFormGroup(1).attributes('aria-invalid')).toBeUndefined();
+    expect(findFormGroup(0).attributes('aria-invalid')).toBeUndefined();
+  });
 
-  describe.each`
-    labelText
-    ${'Identifier code'}
-    ${'Identifier URL'}
-  `('for input $labelText', ({ labelText }) => {
-    it(`displays the input with the correct label: "${labelText}"`, () => {
-      expect(wrapper.findByLabelText(labelText).exists()).toBe(true);
+  it('displays a warning when the validation fails', async () => {
+    wrapper.setProps({
+      validationState: {
+        identifiers: [{ identifierCode: false, identifierUrl: false }],
+      },
     });
 
-    it('emits change even when input changes', () => {
-      wrapper.findByLabelText(labelText).trigger('change');
-      expect(wrapper.emitted('change')[0][0]).toEqual({
-        identifiers: [{ identifierCode: '', identifierUrl: '', id: 0 }],
-      });
+    await nextTick();
+
+    expect(findFormGroup(0).attributes('aria-invalid')).toBe('true');
+    expect(wrapper.findAllByRole('alert').at(0).text()).toBe('This field is required.');
+
+    expect(findFormGroup(1).attributes('aria-invalid')).toBe('true');
+    expect(wrapper.findAllByRole('alert').at(1).text()).toBe('This field is required.');
+  });
+
+  it('emits change event when input changes', () => {
+    const codeInput = findIdentifierCodeInput();
+    const urlInput = findIdentifierUrlInput();
+
+    codeInput.setValue('cve-23');
+    urlInput.setValue('https://gitlab.com');
+    codeInput.trigger('change');
+
+    expect(wrapper.emitted('change')[0][0]).toEqual({
+      identifiers: [{ name: 'cve-23', url: 'https://gitlab.com' }],
     });
   });
 
   it('adds and removes identifier rows', async () => {
     expect(findIdentifierRows()).toHaveLength(1);
 
-    wrapper.findByTestId('add-identifier-row').trigger('click');
+    wrapper.findByRole('button', { name: 'Add another identifier' }).trigger('click');
     await nextTick();
 
     expect(findIdentifierRows()).toHaveLength(2);
diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js
index f37526b2cee6c5cc9b2da0c2773718ede471f30e..7ba5cd1a9b45a4b9902fe45e5083695409ae3314 100644
--- a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js
+++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js
@@ -1,4 +1,5 @@
-import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import MarkdownField from '~/vue_shared/components/markdown/field.vue';
 import SectionName from 'ee/vulnerabilities/components/new_vulnerability/section_name.vue';
@@ -9,6 +10,8 @@ describe('New vulnerability - Section Name', () => {
 
   let wrapper;
 
+  const findFormGroup = (index) => wrapper.findAllComponents(GlFormGroup).at(index);
+
   const createWrapper = () => {
     return mountExtended(SectionName, {
       provide: {
@@ -50,14 +53,33 @@ describe('New vulnerability - Section Name', () => {
   });
 
   it.each`
-    field            | component         | value
-    ${'Name'}        | ${GlFormInput}    | ${{ vulnerabilityName: 'CVE 2021', vulnerabilityDesc: '' }}
-    ${'Description'} | ${GlFormTextarea} | ${{ vulnerabilityName: '', vulnerabilityDesc: 'Password leak' }}
-  `('emits the changes: $field ', async ({ component, value }) => {
-    // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
-    // eslint-disable-next-line no-restricted-syntax
-    wrapper.setData(value);
-    wrapper.findComponent(component).vm.$emit('change', value);
-    expect(wrapper.emitted('change')[0][0]).toEqual(value);
+    field            | component         | fieldKey               | fieldValue
+    ${'Name'}        | ${GlFormInput}    | ${'vulnerabilityName'} | ${'CVE 2021'}
+    ${'Description'} | ${GlFormTextarea} | ${'vulnerabilityDesc'} | ${'Password leak'}
+  `('emits the changes: $field ', async ({ component, fieldKey, fieldValue }) => {
+    wrapper.findComponent(component).setValue(fieldValue);
+    wrapper.findComponent(component).vm.$emit('change', fieldValue);
+    expect(wrapper.emitted('change')[0][0]).toEqual({
+      vulnerabilityName: '',
+      vulnerabilityDesc: '',
+      [fieldKey]: fieldValue,
+    });
+  });
+
+  it('does not display invalid state by default', () => {
+    expect(findFormGroup(0).attributes('aria-invalid')).toBeUndefined();
+  });
+
+  it('handles form validation', async () => {
+    wrapper.setProps({
+      validationState: {
+        name: false,
+      },
+    });
+
+    await nextTick();
+
+    expect(wrapper.findByRole('alert').text()).toBe('This field is required.');
+    expect(findFormGroup(0).attributes('aria-invalid')).toBe('true');
   });
 });
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3a9358c4e088250a2e9a0d35a9a01c7282620891..9f4ddb47b76ff21b73f1539b5ff50d6196b58150 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -40167,6 +40167,9 @@ msgstr ""
 msgid "VulnerabilityManagement|An unverified non-confirmed finding"
 msgstr ""
 
+msgid "VulnerabilityManagement|At least one identifier is required"
+msgstr ""
+
 msgid "VulnerabilityManagement|Change status"
 msgstr ""
 
@@ -40182,6 +40185,9 @@ msgstr ""
 msgid "VulnerabilityManagement|Manually add a vulnerability entry into the vulnerability report."
 msgstr ""
 
+msgid "VulnerabilityManagement|Name is a required field"
+msgstr ""
+
 msgid "VulnerabilityManagement|Needs triage"
 msgstr ""
 
@@ -40197,6 +40203,12 @@ msgstr ""
 msgid "VulnerabilityManagement|Select a method"
 msgstr ""
 
+msgid "VulnerabilityManagement|Severity is a required field"
+msgstr ""
+
+msgid "VulnerabilityManagement|Something went wrong while creating vulnerability"
+msgstr ""
+
 msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later."
 msgstr ""
 
@@ -40221,6 +40233,12 @@ msgstr ""
 msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state."
 msgstr ""
 
+msgid "VulnerabilityManagement|Status is a required field"
+msgstr ""
+
+msgid "VulnerabilityManagement|Submit vulnerability"
+msgstr ""
+
 msgid "VulnerabilityManagement|Summary, detailed description, steps to reproduce, etc."
 msgstr ""