diff --git a/config/install/field.storage.group_content.group_requires_approval.yml b/config/install/field.storage.group_content.group_requires_approval.yml
new file mode 100644
index 0000000..cfdc8c1
--- /dev/null
+++ b/config/install/field.storage.group_content.group_requires_approval.yml
@@ -0,0 +1,17 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - group
+id: group_content.group_requires_approval
+field_name: group_requires_approval
+entity_type: group_content
+type: boolean
+settings: {  }
+module: core
+locked: true
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: true
+custom_storage: false
diff --git a/config/optional/views.view.group_members.yml b/config/optional/views.view.group_members.yml
index be48c75..9c4b6a6 100644
--- a/config/optional/views.view.group_members.yml
+++ b/config/optional/views.view.group_members.yml
@@ -625,7 +625,43 @@ display:
             created: '0'
           destination: true
           plugin_id: dropbutton
-      filters: {  }
+      filters:
+        group_requires_approval_value:
+          id: group_requires_approval_value
+          table: group_content__group_requires_approval
+          field: group_requires_approval_value
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: '0'
+          group: 1
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          plugin_id: boolean
       sorts: {  }
       header: {  }
       footer: {  }
diff --git a/config/optional/views.view.group_members.yml b/config/optional/views.view.group_pending_members.yml
similarity index 66%
copy from config/optional/views.view.group_members.yml
copy to config/optional/views.view.group_pending_members.yml
index be48c75..14d7f66 100644
--- a/config/optional/views.view.group_members.yml
+++ b/config/optional/views.view.group_pending_members.yml
@@ -1,13 +1,11 @@
 langcode: en
 status: true
 dependencies:
-  config:
-    - field.storage.group_content.group_roles
   module:
     - group
     - user
-id: group_members
-label: 'Group members'
+id: group_pending_members
+label: 'Pending Group members'
 module: group
 description: ''
 tag: ''
@@ -217,69 +215,6 @@ display:
           entity_type: user
           entity_field: name
           plugin_id: field
-        group_roles:
-          id: group_roles
-          table: group_content__group_roles
-          field: group_roles
-          relationship: none
-          group_type: group
-          admin_label: ''
-          label: Roles
-          exclude: false
-          alter:
-            alter_text: false
-            text: ''
-            make_link: false
-            path: ''
-            absolute: false
-            external: false
-            replace_spaces: false
-            path_case: none
-            trim_whitespace: false
-            alt: ''
-            rel: ''
-            link_class: ''
-            prefix: ''
-            suffix: ''
-            target: ''
-            nl2br: false
-            max_length: 0
-            word_boundary: true
-            ellipsis: true
-            more_link: false
-            more_link_text: ''
-            more_link_path: ''
-            strip_tags: false
-            trim: false
-            preserve_tags: ''
-            html: false
-          element_type: ''
-          element_class: ''
-          element_label_type: ''
-          element_label_class: ''
-          element_label_colon: true
-          element_wrapper_type: ''
-          element_wrapper_class: ''
-          element_default_classes: true
-          empty: '<div class="item-list"><ul><li>&lt;none&gt;</li></ul></div>'
-          hide_empty: false
-          empty_zero: false
-          hide_alter_empty: true
-          click_sort_column: target_id
-          type: entity_reference_label
-          settings:
-            link: false
-          group_column: target_id
-          group_columns: {  }
-          group_rows: true
-          delta_limit: 0
-          delta_offset: 0
-          delta_reversed: false
-          delta_first_last: false
-          multi_type: ul
-          separator: ', '
-          field_api_classes: false
-          plugin_id: field
         changed:
           id: changed
           table: group_content_field_data
@@ -354,7 +289,7 @@ display:
           relationship: none
           group_type: group
           admin_label: ''
-          label: Joined
+          label: 'Requested Membership'
           exclude: false
           alter:
             alter_text: false
@@ -414,167 +349,14 @@ display:
           entity_type: group_content
           entity_field: created
           plugin_id: field
-        view_group_content:
-          id: view_group_content
-          table: group_content
-          field: view_group_content
-          relationship: none
-          group_type: group
-          admin_label: 'View member link'
-          label: ''
-          exclude: true
-          alter:
-            alter_text: false
-            text: ''
-            make_link: false
-            path: ''
-            absolute: false
-            external: false
-            replace_spaces: false
-            path_case: none
-            trim_whitespace: false
-            alt: ''
-            rel: ''
-            link_class: ''
-            prefix: ''
-            suffix: ''
-            target: ''
-            nl2br: false
-            max_length: 0
-            word_boundary: true
-            ellipsis: true
-            more_link: false
-            more_link_text: ''
-            more_link_path: ''
-            strip_tags: false
-            trim: false
-            preserve_tags: ''
-            html: false
-          element_type: ''
-          element_class: ''
-          element_label_type: ''
-          element_label_class: ''
-          element_label_colon: false
-          element_wrapper_type: ''
-          element_wrapper_class: ''
-          element_default_classes: true
-          empty: ''
-          hide_empty: false
-          empty_zero: false
-          hide_alter_empty: true
-          text: 'View member'
-          entity_type: group_content
-          plugin_id: entity_link
-        edit_group_content:
-          id: edit_group_content
+        operations:
+          id: operations
           table: group_content
-          field: edit_group_content
-          relationship: none
-          group_type: group
-          admin_label: 'Edit member link'
-          label: ''
-          exclude: true
-          alter:
-            alter_text: false
-            text: ''
-            make_link: false
-            path: ''
-            absolute: false
-            external: false
-            replace_spaces: false
-            path_case: none
-            trim_whitespace: false
-            alt: ''
-            rel: ''
-            link_class: ''
-            prefix: ''
-            suffix: ''
-            target: ''
-            nl2br: false
-            max_length: 0
-            word_boundary: true
-            ellipsis: true
-            more_link: false
-            more_link_text: ''
-            more_link_path: ''
-            strip_tags: false
-            trim: false
-            preserve_tags: ''
-            html: false
-          element_type: ''
-          element_class: ''
-          element_label_type: ''
-          element_label_class: ''
-          element_label_colon: false
-          element_wrapper_type: ''
-          element_wrapper_class: ''
-          element_default_classes: true
-          empty: ''
-          hide_empty: false
-          empty_zero: false
-          hide_alter_empty: true
-          text: 'Edit member'
-          entity_type: group_content
-          plugin_id: entity_link_edit
-        delete_group_content:
-          id: delete_group_content
-          table: group_content
-          field: delete_group_content
-          relationship: none
-          group_type: group
-          admin_label: 'Remove member link'
-          label: ''
-          exclude: true
-          alter:
-            alter_text: false
-            text: ''
-            make_link: false
-            path: ''
-            absolute: false
-            external: false
-            replace_spaces: false
-            path_case: none
-            trim_whitespace: false
-            alt: ''
-            rel: ''
-            link_class: ''
-            prefix: ''
-            suffix: ''
-            target: ''
-            nl2br: false
-            max_length: 0
-            word_boundary: true
-            ellipsis: true
-            more_link: false
-            more_link_text: ''
-            more_link_path: ''
-            strip_tags: false
-            trim: false
-            preserve_tags: ''
-            html: false
-          element_type: ''
-          element_class: ''
-          element_label_type: ''
-          element_label_class: ''
-          element_label_colon: false
-          element_wrapper_type: ''
-          element_wrapper_class: ''
-          element_default_classes: true
-          empty: ''
-          hide_empty: false
-          empty_zero: false
-          hide_alter_empty: true
-          text: 'Remove member'
-          entity_type: group_content
-          plugin_id: entity_link_delete
-        dropbutton:
-          id: dropbutton
-          table: views
-          field: dropbutton
+          field: operations
           relationship: none
           group_type: group
           admin_label: ''
-          label: Operations
+          label: 'Operations links'
           exclude: false
           alter:
             alter_text: false
@@ -615,17 +397,46 @@ display:
           hide_empty: false
           empty_zero: false
           hide_alter_empty: true
-          fields:
-            view_group_content: view_group_content
-            edit_group_content: edit_group_content
-            delete_group_content: delete_group_content
-            name: '0'
-            group_roles: '0'
-            changed: '0'
-            created: '0'
           destination: true
-          plugin_id: dropbutton
-      filters: {  }
+          entity_type: group_content
+          plugin_id: entity_operations
+      filters:
+        group_requires_approval_value:
+          id: group_requires_approval_value
+          table: group_content__group_requires_approval
+          field: group_requires_approval_value
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: '1'
+          group: 1
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          plugin_id: boolean
       sorts: {  }
       header: {  }
       footer: {  }
@@ -668,7 +479,7 @@ display:
             title_enable: false
             title: All
           title_enable: true
-          title: '{{ arguments.gid|placeholder }} members'
+          title: '{{ arguments.gid|placeholder }} pending members'
           default_argument_type: fixed
           default_argument_options:
             argument: ''
@@ -693,7 +504,7 @@ display:
           entity_field: gid
           plugin_id: numeric
       display_extenders: {  }
-      title: Members
+      title: 'Pending Members'
     cache_metadata:
       max-age: 0
       contexts:
@@ -702,8 +513,7 @@ display:
         - 'languages:language_interface'
         - url
         - url.query_args
-      tags:
-        - 'config:field.storage.group_content.group_roles'
+      tags: {  }
   page_1:
     display_plugin: page
     id: page_1
@@ -711,17 +521,16 @@ display:
     position: 1
     display_options:
       display_extenders: {  }
-      path: group/%group/members
+      path: group/%group/pending-members
       menu:
         type: tab
-        title: Members
+        title: 'Pending Members'
         description: ''
         expanded: false
         parent: ''
         weight: 20
         context: '0'
         menu_name: main
-        enabled: true
     cache_metadata:
       max-age: 0
       contexts:
@@ -730,5 +539,4 @@ display:
         - 'languages:language_interface'
         - url
         - url.query_args
-      tags:
-        - 'config:field.storage.group_content.group_roles'
+      tags: {  }
diff --git a/group.install b/group.install
index ac8c95e..b7961af 100644
--- a/group.install
+++ b/group.install
@@ -5,11 +5,17 @@
  * Install, update and uninstall functions for the group module.
  */
 
+use Drupal\Core\Config\FileStorage;
 use Drupal\group\Entity\GroupContent;
 use Drupal\Core\Config\ExtensionInstallStorage;
 use Drupal\Core\Config\InstallStorage;
 use Drupal\Core\Entity\EntityTypeListenerInterface;
+use Drupal\Core\Entity\Entity\EntityFormDisplay;
+use Drupal\Core\Entity\Entity\EntityViewDisplay;
 use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\views\Entity\View;
 
 /**
  * Resave all GroupContent labels and remove orphaned entities.
@@ -362,3 +368,87 @@ function group_update_8010() {
     }
   }
 }
+
+/**
+ * Add group_requires_approval field to group membership plugin instances.
+ */
+function group_update_8011() {
+  $config_factory = \Drupal::configFactory();
+
+  FieldStorageConfig::create([
+    'entity_type' => 'group_content',
+    'type' => 'boolean',
+    'field_name' => 'group_requires_approval',
+    'settings' => [],
+  ])->save();
+
+  foreach ($config_factory->listAll('group.content_type.') as $group_content_type_config_name) {
+    $group_content_type = $config_factory->getEditable($group_content_type_config_name);
+    $plugin_id = $group_content_type->get('content_plugin');
+
+    // Only group_membership types need the group_requires_approval field.
+    if ($plugin_id == 'group_membership') {
+      $group_content_type_id = $group_content_type->get('id');
+
+      // Add the group_requires_approval field to the group content type.
+      // The field storage for this is defined in the config/install folder.
+      FieldConfig::create([
+        'field_storage' => FieldStorageConfig::loadByName('group_content', 'group_requires_approval'),
+        'bundle' => $group_content_type_id,
+        'label' => t('Requires Approval'),
+        'settings' => [
+          'on_label' => 'Requires Approval',
+          'off_label' => 'Requires Approval',
+        ],
+      ])->save();
+
+      // Build the 'default' display ID for both the entity form and view mode.
+      $default_display_id = "group_content.$group_content_type_id.default";
+
+      // Build or retrieve the 'default' form mode.
+      if (!$form_display = EntityFormDisplay::load($default_display_id)) {
+        $form_display = EntityFormDisplay::create([
+          'targetEntityType' => 'group_content',
+          'bundle' => $group_content_type_id,
+          'mode' => 'default',
+          'status' => TRUE,
+        ]);
+      }
+
+      // Build or retrieve the 'default' view mode.
+      if (!$view_display = EntityViewDisplay::load($default_display_id)) {
+        $view_display = EntityViewDisplay::create([
+          'targetEntityType' => 'group_content',
+          'bundle' => $group_content_type_id,
+          'mode' => 'default',
+          'status' => TRUE,
+        ]);
+      }
+
+      // Assign widget settings for the 'default' form mode.
+      $form_display->setComponent('group_requires_approval', [
+        'type' => 'boolean_checkbox',
+      ])->save();
+
+      // Assign display settings for the 'default' view mode.
+      $view_display->setComponent('group_requires_approval', [
+        'label' => 'above',
+        'type' => 'boolean',
+      ])->save();
+    }
+  }
+
+  // Create the new view if Views is installed.
+  $module_handler = \Drupal::moduleHandler();
+  if ($module_handler->moduleExists('views') && !View::load('group_pending_members')) {
+    $optional_install_path = $module_handler->getModule('group')
+        ->getPath() . '/' . InstallStorage::CONFIG_OPTIONAL_DIRECTORY;
+    $storage = new FileStorage($optional_install_path);
+
+    \Drupal::entityTypeManager()
+      ->getStorage('view')
+      ->create($storage->read('views.view.group_pending_members'))
+      ->save();
+  }
+
+}
diff --git a/group.module b/group.module
index 5fb2ccd..7c878b5 100644
--- a/group.module
+++ b/group.module
@@ -222,8 +222,8 @@ function group_entity_delete(EntityInterface $entity) {
  *       options available to it?
  */
 function group_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {
-  // We only care about the group roles field for now.
-  if ($field_definition->getName() != 'group_roles') {
+  // Only check group_roles and group_requires_approval fields.
+  if (($field_definition->getName() != 'group_roles') && ($field_definition->getName() != 'group_requires_approval')) {
     return AccessResult::neutral();
   }
 
@@ -238,7 +238,7 @@ function group_entity_field_access($operation, FieldDefinitionInterface $field_d
 
   // Just to be safe, let's check the plugin ID.
   if ($group_content->getContentPlugin()->getPluginId() == 'group_membership') {
-    // Only group administrators should be able to change the membership roles.
+    // Only group administrators should be able to change the membership status.
     return AccessResult::forbiddenIf(!$group->hasPermission('administer members', $account));
   }
 
diff --git a/group.routing.yml b/group.routing.yml
index 7e855de..f110944 100644
--- a/group.routing.yml
+++ b/group.routing.yml
@@ -26,6 +26,39 @@ entity.group.leave:
     _group_permission: 'leave group'
     _group_member: 'TRUE'
 
+entity.group.request_membership:
+  path: '/group/{group}/request-membership'
+  defaults:
+    _controller: '\Drupal\group\Controller\GroupMembershipController::requestMembership'
+    _title_callback: '\Drupal\group\Controller\GroupMembershipController::requestMembershipTitle'
+  requirements:
+    _group_permission: 'request group membership'
+    _group_member: 'FALSE'
+
+entity.group_content.approve_membership:
+  path: '/group/{group}/content/{group_content}/approve-membership'
+  defaults:
+    _controller: '\Drupal\group\Controller\GroupMembershipController::approveMembership'
+    _title_callback: '\Drupal\group\Controller\GroupMembershipController::approveMembershipTitle'
+  requirements:
+    _group_permission: 'administer members'
+  options:
+    parameters:
+      group:
+        type: 'entity:group'
+
+entity.group_content.reject_membership:
+  path: '/group/{group}/content/{group_content}/reject-membership'
+  defaults:
+    _controller: '\Drupal\group\Controller\GroupMembershipController::rejectMembership'
+    _title_callback: '\Drupal\group\Controller\GroupMembershipController::rejectMembershipTitle'
+  requirements:
+    _group_permission: 'administer members'
+  options:
+    parameters:
+      group:
+        type: 'entity:group'
+
 # Group type entity routes.
 # Common entity routes are generated by \Drupal\group\Entity\Routing\GroupTypeRouteProvider.
 entity.group_type.permissions_form:
diff --git a/src/Controller/GroupMembershipController.php b/src/Controller/GroupMembershipController.php
index 75ad79f..cf455eb 100644
--- a/src/Controller/GroupMembershipController.php
+++ b/src/Controller/GroupMembershipController.php
@@ -23,14 +23,14 @@ class GroupMembershipController extends ControllerBase {
    * @var \Drupal\Core\Session\AccountInterface
    */
   protected $currentUser;
-  
+
   /**
    * The entity form builder.
    *
    * @var \Drupal\Core\Entity\EntityFormBuilderInterface
    */
   protected $entityFormBuilder;
-  
+
   /**
    * Constructs a new GroupMembershipController.
    *
@@ -72,6 +72,7 @@ class GroupMembershipController extends ControllerBase {
       'type' => $plugin->getContentTypeConfigId(),
       'gid' => $group->id(),
       'entity_id' => $this->currentUser->id(),
+      'group_requires_approval' => 0,
     ]);
 
     return $this->entityFormBuilder->getForm($group_content, 'group-join');
@@ -91,6 +92,99 @@ class GroupMembershipController extends ControllerBase {
   }
 
   /**
+   * Provides the form for requesting a group membership.
+   *
+   * @param \Drupal\group\Entity\GroupInterface $group
+   *   The group to request membership of.
+   *
+   * @return array
+   *   A group membership request form.
+   */
+  public function requestMembership(GroupInterface $group) {
+    /** @var \Drupal\group\Plugin\GroupContentEnablerInterface $plugin */
+    $plugin = $group->getGroupType()->getContentPlugin('group_membership');
+
+    // Pre-populate a group membership with the current user.
+    $group_content = GroupContent::create([
+      'type' => $plugin->getContentTypeConfigId(),
+      'gid' => $group->id(),
+      'entity_id' => $this->currentUser->id(),
+      'group_requires_approval' => 1,
+    ]);
+
+    return $this->entityFormBuilder()->getForm($group_content, 'group-request-membership');
+  }
+
+  /**
+   * The _title_callback for the request membership form route.
+   *
+   * @param \Drupal\group\Entity\GroupInterface $group
+   *   The group to request membership of.
+   *
+   * @return string
+   *   The page title.
+   */
+  public function requestMembershipTitle(GroupInterface $group) {
+    return $this->t('Request membership group %label', ['%label' => $group->label()]);
+  }
+
+  /**
+   * Provides the form for approving a requested group membership.
+   *
+   * @param \Drupal\group\Entity\GroupInterface $group
+   *   The group containing the requested membership.
+   * @param \Drupal\group\Entity\GroupContent $group_content
+   *   The requested group membership.
+   *
+   * @return array
+   *   A group membership approval form.
+   */
+  public function approveMembership(GroupInterface $group, GroupContent $group_content) {
+    return $this->entityFormBuilder()->getForm($group_content, 'group-approve-membership');
+  }
+
+  /**
+   * The _title_callback for the approve requested membership form route.
+   *
+   * @param \Drupal\group\Entity\GroupInterface $group
+   *   The group containing the requested membership.
+   *
+   * @return string
+   *   The page title.
+   */
+  public function approveMembershipTitle(GroupInterface $group) {
+    return $this->t('Approve membership request for group %label', ['%label' => $group->label()]);
+  }
+
+  /**
+   * Provides the form for rejecting a requested group membership.
+   *
+   * @param \Drupal\group\Entity\GroupInterface $group
+   *   The group containing the requested membership.
+   * @param \Drupal\group\Entity\GroupContent $group_content
+   *   The requested group membership.
+   *
+   * @return array
+   *   A group membership rejection form.
+   */
+  public function rejectMembership(GroupInterface $group, GroupContent $group_content) {
+    return $this->entityFormBuilder()->getForm($group_content, 'group-reject-membership');
+  }
+
+  /**
+   * The _title_callback for the reject requested membership form route.
+   *
+   * @param \Drupal\group\Entity\GroupInterface $group
+   *   The group containing the requested membership.
+   *
+   * @return string
+   *   The page title.
+   */
+  public function rejectMembershipTitle(GroupInterface $group) {
+    return $this->t('Reject membership request for group %label', ['%label' => $group->label()]);
+  }
+
+  /**
    * Provides the form for leaving a group.
    *
    * @param \Drupal\group\Entity\GroupInterface $group
diff --git a/src/Entity/Controller/GroupContentListBuilder.php b/src/Entity/Controller/GroupContentListBuilder.php
index 9c219cb..f9c69d5 100644
--- a/src/Entity/Controller/GroupContentListBuilder.php
+++ b/src/Entity/Controller/GroupContentListBuilder.php
@@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Routing\RedirectDestinationInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Url;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -134,6 +135,34 @@ class GroupContentListBuilder extends EntityListBuilder {
     /** @var \Drupal\group\Entity\GroupContentInterface $entity */
     $operations = parent::getDefaultOperations($entity);
 
+    // Display membership approve / reject operation links for group content
+    // with a populated 'group_requires_approval' field.
+    $group_requires_approval = ($entity->hasField('group_requires_approval')) ? $entity->get('group_requires_approval') : NULL;
+
+    if (!empty($group_requires_approval) && !empty($group_requires_approval->getString())) {
+      if ($entity->access('update') && $entity->hasLinkTemplate('edit-form')) {
+        $operations['approve'] = array(
+          'title' => $this->t('Approve Membership'),
+          'weight' => 0,
+          'url' => Url::fromRoute('entity.group_content.approve_membership', array(
+            'group' => $entity->getGroup()->id(),
+            'group_content' => $entity->id(),
+          )),
+        );
+      }
+
+      if ($entity->access('delete') && $entity->hasLinkTemplate('delete-form')) {
+        $operations['reject'] = array(
+          'title' => $this->t('Reject Membership'),
+          'weight' => 1,
+          'url' => Url::fromRoute('entity.group_content.reject_membership', array(
+            'group' => $entity->getGroup()->id(),
+            'group_content' => $entity->id(),
+          )),
+        );
+      }
+    }
+
     // Improve the edit and delete operation labels.
     if (isset($operations['edit'])) {
       $operations['edit']['title'] = $this->t('Edit relation');
diff --git a/src/Entity/Form/GroupMembershipApproveForm.php b/src/Entity/Form/GroupMembershipApproveForm.php
new file mode 100644
index 0000000..80f6722
--- /dev/null
+++ b/src/Entity/Form/GroupMembershipApproveForm.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\group\Entity\Form;
+
+use Drupal\Core\Entity\ContentEntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Provides a form for approving a group membership.
+ */
+class GroupMembershipApproveForm extends ContentEntityConfirmFormBase {
+
+  /**
+   * Returns the plugin responsible for this piece of group content.
+   *
+   * @return \Drupal\group\Plugin\GroupContentEnablerInterface
+   *   The responsible group content enabler plugin.
+   */
+  protected function getContentPlugin() {
+    /** @var \Drupal\group\Entity\GroupContent $group_content */
+    $group_content = $this->getEntity();
+    return $group_content->getContentPlugin();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to approve %name?', ['%name' => $this->entity->label()]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelURL() {
+    /** @var \Drupal\group\Entity\GroupContent $group_content */
+    $group_content = $this->getEntity();
+    $group = $group_content->getGroup();
+    $route_params = [
+      'group' => $group->id(),
+      'group_content' => $group_content->id(),
+    ];
+    return new Url('entity.group_content.canonical', $route_params);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Approve');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    /** @var \Drupal\group\Entity\GroupContent $group_content */
+    $group_content = $this->getEntity();
+    $group_content->group_requires_approval = 0;
+    $group_content->save();
+
+    \Drupal::logger('group_content')->notice('@type: approved %title.', [
+      '@type' => $group_content->bundle(),
+      '%title' => $group_content->label(),
+    ]);
+
+    $route_params = ['group' => $group_content->getGroup()->id(), 'group_content' => $group_content->id()];
+    $form_state->setRedirect('entity.group_content.canonical', $route_params);
+  }
+
+}
diff --git a/src/Entity/Form/GroupMembershipRejectForm.php b/src/Entity/Form/GroupMembershipRejectForm.php
new file mode 100644
index 0000000..247ef94
--- /dev/null
+++ b/src/Entity/Form/GroupMembershipRejectForm.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\group\Entity\Form;
+
+use Drupal\Core\Entity\ContentEntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Provides a form for rejecting a group membership.
+ */
+class GroupMembershipRejectForm extends ContentEntityConfirmFormBase {
+
+  /**
+   * Returns the plugin responsible for this piece of group content.
+   *
+   * @return \Drupal\group\Plugin\GroupContentEnablerInterface
+   *   The responsible group content enabler plugin.
+   */
+  protected function getContentPlugin() {
+    /** @var \Drupal\group\Entity\GroupContent $group_content */
+    $group_content = $this->getEntity();
+    return $group_content->getContentPlugin();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to reject %name?', ['%name' => $this->entity->label()]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelURL() {
+    /** @var \Drupal\group\Entity\GroupContent $group_content */
+    $group_content = $this->getEntity();
+    $group = $group_content->getGroup();
+    $route_params = [
+      'group' => $group->id(),
+      'group_content' => $group_content->id(),
+    ];
+    return new Url('entity.group_content.canonical', $route_params);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Reject');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $group_content = $this->getEntity();
+    $group_content->delete();
+
+    \Drupal::logger('group_content')->notice('@type: rejected %title.', [
+      '@type' => $this->entity->bundle(),
+      '%title' => $this->entity->label(),
+    ]);
+
+    $form_state->setRedirect('entity.group.canonical', ['group' => $group_content->getGroup()->id()]);
+  }
+
+}
diff --git a/src/Entity/Group.php b/src/Entity/Group.php
index d9b4c26..c55056d 100644
--- a/src/Entity/Group.php
+++ b/src/Entity/Group.php
@@ -196,6 +196,11 @@ class Group extends ContentEntityBase implements GroupInterface {
    */
   public function addMember(UserInterface $account, $values = []) {
     if (!$this->getMember($account)) {
+      // Default to no membership approval required if value is missing.
+      if (!isset($values['group_requires_approval'])) {
+        $values['group_requires_approval'] = 0;
+      }
+
       $this->addContent($account, 'group_membership', $values);
     }
   }
diff --git a/src/Entity/GroupContent.php b/src/Entity/GroupContent.php
index 5ca16c6..a381aa0 100644
--- a/src/Entity/GroupContent.php
+++ b/src/Entity/GroupContent.php
@@ -39,6 +39,9 @@ use Drupal\Core\Entity\EntityStorageInterface;
  *       "delete" = "Drupal\group\Entity\Form\GroupContentDeleteForm",
  *       "group-join" = "Drupal\group\Form\GroupJoinForm",
  *       "group-leave" = "Drupal\group\Form\GroupLeaveForm",
+ *       "group-request-membership" = "Drupal\group\Form\GroupRequestMembershipForm",
+ *       "group-approve-membership" = "Drupal\group\Entity\Form\GroupMembershipApproveForm",
+ *       "group-reject-membership" = "Drupal\group\Entity\Form\GroupMembershipRejectForm"
  *     },
  *     "access" = "Drupal\group\Entity\Access\GroupContentAccessControlHandler",
  *   },
diff --git a/src/Entity/Storage/GroupRoleStorage.php b/src/Entity/Storage/GroupRoleStorage.php
index fc846b4..1382ac7 100644
--- a/src/Entity/Storage/GroupRoleStorage.php
+++ b/src/Entity/Storage/GroupRoleStorage.php
@@ -111,7 +111,8 @@ class GroupRoleStorage extends ConfigEntityStorage implements GroupRoleStorageIn
       // Only now do we add the implied group role IDs so there's no way someone
       // could have removed them in their alter hook.
       if ($include_implied) {
-        if ($membership !== FALSE) {
+        // Members who require approval do not receive the member role.
+        if (($membership !== FALSE) && !$membership->requiresApproval()) {
           $ids[] = $group->getGroupType()->getMemberRoleId();
         }
         else {
diff --git a/src/Form/GroupJoinForm.php b/src/Form/GroupJoinForm.php
index 8cf1a11..898c7f1 100644
--- a/src/Form/GroupJoinForm.php
+++ b/src/Form/GroupJoinForm.php
@@ -17,6 +17,9 @@ class GroupJoinForm extends GroupContentForm {
     $form = parent::form($form, $form_state);
     $form['entity_id']['#access'] = FALSE;
     $form['group_roles']['#access'] = FALSE;
+    // Remove group_requires_approval so default value isn't overwritten.
+    // @see Drupal\group\Controller\GroupMembershipController::join()
+    unset($form['group_requires_approval']);
     return $form;
   }
 
diff --git a/src/Form/GroupRequestMembershipForm.php b/src/Form/GroupRequestMembershipForm.php
new file mode 100644
index 0000000..e17ed33
--- /dev/null
+++ b/src/Form/GroupRequestMembershipForm.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\group\Form;
+
+use Drupal\group\Entity\Form\GroupContentForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a form for requesting a group membership.
+ */
+class GroupRequestMembershipForm extends GroupContentForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+    $form['entity_id']['#access'] = FALSE;
+    $form['group_roles']['#access'] = FALSE;
+    $form['group_requires_approval']['#access'] = FALSE;
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function actions(array $form, FormStateInterface $form_state) {
+    $actions = parent::actions($form, $form_state);
+    $actions['submit']['#value'] = $this->t('Request membership');
+    return $actions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $return = parent::save($form, $form_state);
+
+    /** @var \Drupal\group\Entity\GroupContent $group_content */
+    $group_content = $this->getEntity();
+
+    // Redirect back to the group because we've not yet been added.
+    $route_params = ['group' => $group_content->getGroup()->id()];
+    $form_state->setRedirect('entity.group.canonical', $route_params);
+
+    drupal_set_message(t('You membership request for @group has been sent', ['@group' => $group_content->getGroup()->label()]), 'status');
+
+    return $return;
+  }
+
+}
diff --git a/src/GroupMembership.php b/src/GroupMembership.php
index 08c8f26..5e41674 100644
--- a/src/GroupMembership.php
+++ b/src/GroupMembership.php
@@ -79,6 +79,16 @@ class GroupMembership implements CacheableDependencyInterface {
   }
 
   /**
+   * Returns the approval required status for the membership.
+   *
+   * @return bool
+   *   Whether the member requires approval to join the group.
+   */
+  public function requiresApproval() {
+    return (!empty($this->getGroupContent()->group_requires_approval->value));
+  }
+
+  /**
    * Checks whether the member has a permission.
    *
    * @param string $permission
diff --git a/src/Plugin/GroupContentEnabler/GroupMembership.php b/src/Plugin/GroupContentEnabler/GroupMembership.php
index 2040939..7391a8a 100644
--- a/src/Plugin/GroupContentEnabler/GroupMembership.php
+++ b/src/Plugin/GroupContentEnabler/GroupMembership.php
@@ -37,7 +37,9 @@ class GroupMembership extends GroupContentEnablerBase {
     $account = \Drupal::currentUser();
     $operations = [];
 
-    if ($group->getMember($account)) {
+    $member = $group->getMember($account);
+
+    if (!empty($member)) {
       if ($group->hasPermission('leave group', $account)) {
         $operations['group-leave'] = [
           'title' => $this->t('Leave group'),
@@ -46,12 +48,22 @@ class GroupMembership extends GroupContentEnablerBase {
         ];
       }
     }
-    elseif ($group->hasPermission('join group', $account)) {
-      $operations['group-join'] = [
-        'title' => $this->t('Join group'),
-        'url' => new Url('entity.group.join', ['group' => $group->id()]),
-        'weight' => 0,
-      ];
+    else {
+      if ($group->hasPermission('join group', $account)) {
+        $operations['group-join'] = [
+          'title' => $this->t('Join group'),
+          'url' => new Url('entity.group.join', ['group' => $group->id()]),
+          'weight' => 0,
+        ];
+      }
+
+      if ($group->hasPermission('request group membership', $account)) {
+        $operations['group-request-membership'] = [
+          'title' => $this->t('Request group membership'),
+          'url' => new Url('entity.group.request_membership', ['group' => $group->id()]),
+          'weight' => 0,
+        ];
+      }
     }
 
     return $operations;
@@ -78,6 +90,11 @@ class GroupMembership extends GroupContentEnablerBase {
       'allowed for' => ['member'],
     ];
 
+    $permissions['request group membership'] = [
+      'title' => 'Request group membership',
+      'allowed for' => ['outsider'],
+    ];
+
     // Update the labels of the default permissions.
     $permissions['view group_membership content']['title'] = 'View individual group members';
     $permissions['edit own group_membership content'] = [
@@ -201,6 +218,52 @@ class GroupMembership extends GroupContentEnablerBase {
         'link' => 0,
       ],
     ])->save();
+
+    // Add the group_requires_approval field to the new group content type.
+    // The field storage for this is defined in the config/install folder.
+    FieldConfig::create([
+      'field_storage' => FieldStorageConfig::loadByName('group_content', 'group_requires_approval'),
+      'bundle' => $group_content_type_id,
+      'label' => $this->t('Requires Approval'),
+      'settings' => [
+        'on_label' => 'Requires Approval',
+        'off_label' => 'Requires Approval',
+      ],
+    ])->save();
+
+    // Build the 'default' display ID for both the entity form and view mode.
+    $default_display_id = "group_content.$group_content_type_id.default";
+
+    // Build or retrieve the 'default' form mode.
+    if (!$form_display = EntityFormDisplay::load($default_display_id)) {
+      $form_display = EntityFormDisplay::create([
+        'targetEntityType' => 'group_content',
+        'bundle' => $group_content_type_id,
+        'mode' => 'default',
+        'status' => TRUE,
+      ]);
+    }
+
+    // Build or retrieve the 'default' view mode.
+    if (!$view_display = EntityViewDisplay::load($default_display_id)) {
+      $view_display = EntityViewDisplay::create([
+        'targetEntityType' => 'group_content',
+        'bundle' => $group_content_type_id,
+        'mode' => 'default',
+        'status' => TRUE,
+      ]);
+    }
+
+    // Assign widget settings for the 'default' form mode.
+    $form_display->setComponent('group_requires_approval', [
+      'type' => 'boolean_checkbox',
+    ])->save();
+
+    // Assign display settings for the 'default' view mode.
+    $view_display->setComponent('group_requires_approval', [
+      'label' => 'above',
+      'type' => 'boolean',
+    ])->save();
   }
 
   /**
diff --git a/tests/modules/group_test_config/config/install/group.type.default.yml b/tests/modules/group_test_config/config/install/group.type.default.yml
index 64376fe..0ba12c0 100644
--- a/tests/modules/group_test_config/config/install/group.type.default.yml
+++ b/tests/modules/group_test_config/config/install/group.type.default.yml
@@ -4,3 +4,4 @@ dependencies: {  }
 id: default
 label: 'Default label'
 description: 'Default description.'
+creator_roles: { }
