diff --git a/app/models/members/members/member_approval.rb b/app/models/members/members/member_approval.rb
index c1f37e40b6280ef248fe50bed0fde41e5cd95d2c..a94260634f9c8d2d714538327e9db2c52b9db6ae 100644
--- a/app/models/members/members/member_approval.rb
+++ b/app/models/members/members/member_approval.rb
@@ -17,6 +17,7 @@ class MemberApproval < ApplicationRecord
     validates :new_access_level, presence: true
     validates :user, presence: true
     validates :member_namespace, presence: true
+    validates :metadata, json_schema: { filename: "members_approval_request_metadata" }
   end
 end
 
diff --git a/app/validators/json_schemas/members_approval_request_metadata.json b/app/validators/json_schemas/members_approval_request_metadata.json
new file mode 100644
index 0000000000000000000000000000000000000000..3c9ca4cf7b895909a944890585e3b486f22986c0
--- /dev/null
+++ b/app/validators/json_schemas/members_approval_request_metadata.json
@@ -0,0 +1,24 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "type": "object",
+  "properties": {
+    "expires_at": {
+      "type": "string",
+      "oneOf": [
+        {
+          "format": "date"
+        },
+        {
+          "pattern": "^$"
+        }
+      ]
+    },
+    "member_role_id": {
+      "type": [
+        "number",
+        "null"
+      ]
+    }
+  },
+  "additionalProperties": true
+}
diff --git a/db/migrate/20240519141301_add_metadata_to_member_approvals.rb b/db/migrate/20240519141301_add_metadata_to_member_approvals.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7ed3f83989c9b94825a2682e97c6e3fa2f7b8c94
--- /dev/null
+++ b/db/migrate/20240519141301_add_metadata_to_member_approvals.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddMetadataToMemberApprovals < Gitlab::Database::Migration[2.2]
+  milestone '17.1'
+  enable_lock_retries!
+
+  def change
+    add_column :member_approvals, :metadata, :jsonb, default: {}, null: false
+  end
+end
diff --git a/db/schema_migrations/20240519141301 b/db/schema_migrations/20240519141301
new file mode 100644
index 0000000000000000000000000000000000000000..a6ae2cea4528b9e434e40445ec0fc634cf73d754
--- /dev/null
+++ b/db/schema_migrations/20240519141301
@@ -0,0 +1 @@
+cd61987ea28b86a0c4280c8e0c743ecef2e1c6a45842281b4c7814ebe6e87057
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 20b4d27d84a49d9113d6421d8ea7dd1d541260c0..49b6b30d4d8095c53f23d1aeaf258f13e87f8bf4 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -11247,7 +11247,8 @@ CREATE TABLE member_approvals (
     old_access_level integer,
     status smallint DEFAULT 0 NOT NULL,
     user_id bigint NOT NULL,
-    member_role_id bigint
+    member_role_id bigint,
+    metadata jsonb DEFAULT '{}'::jsonb NOT NULL
 );
 
 CREATE SEQUENCE member_approvals_id_seq
diff --git a/spec/models/members/members/member_approval_spec.rb b/spec/models/members/members/member_approval_spec.rb
index d456c75e2eb3f6c51361e9764eb293fcd3b2813f..1ac1e98c5611f5eecba7eb9e534ec7c19c1613b2 100644
--- a/spec/models/members/members/member_approval_spec.rb
+++ b/spec/models/members/members/member_approval_spec.rb
@@ -15,5 +15,91 @@
     it { is_expected.to validate_presence_of(:new_access_level) }
     it { is_expected.to validate_presence_of(:user) }
     it { is_expected.to validate_presence_of(:member_namespace) }
+
+    context 'with metadata' do
+      subject { build(:member_approval, metadata: attribute_mapping) }
+
+      context 'with valid JSON schemas' do
+        let(:attribute_mapping) do
+          {
+            expires_at: expiry,
+            member_role_id: nil
+          }
+        end
+
+        context 'with empty metadata' do
+          let(:attribute_mapping) { {} }
+
+          it { is_expected.to be_valid }
+        end
+
+        context 'with valid expiry' do
+          let(:expiry) { "1970-01-01" }
+
+          it { is_expected.to be_valid }
+        end
+
+        context 'with empty expiry' do
+          let(:expiry) { "" }
+
+          it { is_expected.to be_valid }
+        end
+
+        context 'with not null member_role_id' do
+          let(:attribute_mapping) do
+            {
+              member_role_id: 3
+            }
+          end
+
+          it { is_expected.to be_valid }
+        end
+
+        context 'when property has extra attributes' do
+          let(:attribute_mapping) do
+            { access_level: 20 }
+          end
+
+          it { is_expected.to be_valid }
+        end
+      end
+
+      context 'with invalid JSON schemas' do
+        shared_examples 'is invalid record' do
+          it do
+            expect(subject).to be_invalid
+            expect(subject.errors.messages[:metadata]).to eq(['must be a valid json schema'])
+          end
+        end
+
+        context 'when property is not an object' do
+          let(:attribute_mapping) do
+            "That is not a valid schema"
+          end
+
+          it_behaves_like 'is invalid record'
+        end
+
+        context 'with invalid expiry' do
+          let(:attribute_mapping) do
+            {
+              expires_at: "1242"
+            }
+          end
+
+          it_behaves_like 'is invalid record'
+        end
+
+        context 'with member_role_id' do
+          let(:attribute_mapping) do
+            {
+              member_role_id: "some role"
+            }
+          end
+
+          it_behaves_like 'is invalid record'
+        end
+      end
+    end
   end
 end