diff --git a/app/assets/javascripts/access_tokens/components/constants.js b/app/assets/javascripts/access_tokens/components/constants.js
index 9cd7cb5bb3a401fbed72ec30b00d63dc9cc6afb1..d5389bbd0efbfa445a85d6a1cc00e91f422273e5 100644
--- a/app/assets/javascripts/access_tokens/components/constants.js
+++ b/app/assets/javascripts/access_tokens/components/constants.js
@@ -7,7 +7,7 @@ export const FORM_SELECTOR = '#js-new-access-token-form';
 export const INITIAL_PAGE = 1;
 export const PAGE_SIZE = 100;
 
-export const FIELDS = [
+const BASE_FIELDS = [
   {
     key: 'name',
     label: __('Token name'),
@@ -31,19 +31,35 @@ export const FIELDS = [
     label: __('Last Used'),
     sortable: true,
   },
+];
+
+const ROLE_FIELD = {
+  key: 'role',
+  label: __('Role'),
+  sortable: true,
+};
+
+export const FIELDS = [
+  ...BASE_FIELDS,
   {
     key: 'expiresAt',
     label: __('Expires'),
     sortable: true,
   },
-  {
-    key: 'role',
-    label: __('Role'),
-    sortable: true,
-  },
+  ROLE_FIELD,
   {
     key: 'action',
     label: __('Action'),
     tdClass: 'gl-py-3!',
   },
 ];
+
+export const INACTIVE_TOKENS_TABLE_FIELDS = [
+  ...BASE_FIELDS,
+  {
+    key: 'expiresAt',
+    label: __('Expired'),
+    sortable: true,
+  },
+  ROLE_FIELD,
+];
diff --git a/app/assets/javascripts/access_tokens/components/inactive_access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/inactive_access_token_table_app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bfb4aac75e8bde63e07bfb0114f496a1b9a00da5
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/inactive_access_token_table_app.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlIcon, GlLink, GlPagination, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
+import { INACTIVE_TOKENS_TABLE_FIELDS, INITIAL_PAGE, PAGE_SIZE } from './constants';
+
+export default {
+  PAGE_SIZE,
+  name: 'InactiveAccessTokenTableApp',
+  components: {
+    GlIcon,
+    GlLink,
+    GlPagination,
+    GlTable,
+    TimeAgoTooltip,
+    UserDate,
+  },
+  directives: {
+    GlTooltip: GlTooltipDirective,
+  },
+  lastUsedHelpLink: helpPagePath('/user/profile/personal_access_tokens.md', {
+    anchor: 'view-the-last-time-a-token-was-used',
+  }),
+  i18n: {
+    emptyField: __('Never'),
+    expired: __('Expired'),
+    revoked: __('Revoked'),
+  },
+  INACTIVE_TOKENS_TABLE_FIELDS,
+  inject: [
+    'accessTokenType',
+    'accessTokenTypePlural',
+    'initialInactiveAccessTokens',
+    'noInactiveTokensMessage',
+  ],
+  data() {
+    return {
+      inactiveAccessTokens: convertObjectPropsToCamelCase(this.initialInactiveAccessTokens, {
+        deep: true,
+      }),
+      currentPage: INITIAL_PAGE,
+    };
+  },
+  computed: {
+    showPagination() {
+      return this.inactiveAccessTokens.length > PAGE_SIZE;
+    },
+  },
+  methods: {
+    sortingChanged(aRow, bRow, key) {
+      if (['createdAt', 'lastUsedAt', 'expiresAt'].includes(key)) {
+        // Transform `null` value to the latest possible date
+        // https://stackoverflow.com/a/11526569/18428169
+        const maxEpoch = 8640000000000000;
+        const a = new Date(aRow[key] ?? maxEpoch).getTime();
+        const b = new Date(bRow[key] ?? maxEpoch).getTime();
+        return a - b;
+      }
+
+      // For other columns the default sorting works OK
+      return false;
+    },
+  },
+};
+</script>
+
+<template>
+  <div>
+    <gl-table
+      data-testid="inactive-access-tokens"
+      :empty-text="noInactiveTokensMessage"
+      :fields="$options.INACTIVE_TOKENS_TABLE_FIELDS"
+      :items="inactiveAccessTokens"
+      :per-page="$options.PAGE_SIZE"
+      :current-page="currentPage"
+      :sort-compare="sortingChanged"
+      show-empty
+      stacked="sm"
+      class="gl-overflow-x-auto"
+    >
+      <template #cell(createdAt)="{ item: { createdAt } }">
+        <user-date :date="createdAt" />
+      </template>
+
+      <template #head(lastUsedAt)="{ label }">
+        <span>{{ label }}</span>
+        <gl-link :href="$options.lastUsedHelpLink"
+          ><gl-icon name="question-o" class="gl-ml-2" /><span class="gl-sr-only">{{
+            s__('AccessTokens|The last time a token was used')
+          }}</span></gl-link
+        >
+      </template>
+
+      <template #cell(lastUsedAt)="{ item: { lastUsedAt } }">
+        <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" />
+        <template v-else> {{ $options.i18n.emptyField }}</template>
+      </template>
+
+      <template #cell(expiresAt)="{ item: { expiresAt, revoked } }">
+        <span v-if="revoked" v-gl-tooltip :title="$options.i18n.tokenValidity">{{
+          $options.i18n.revoked
+        }}</span>
+        <template v-else>
+          <span>{{ $options.i18n.expired }}</span>
+          <time-ago-tooltip :time="expiresAt" />
+        </template>
+      </template>
+    </gl-table>
+    <gl-pagination
+      v-if="showPagination"
+      v-model="currentPage"
+      :per-page="$options.PAGE_SIZE"
+      :total-items="inactiveAccessTokens.length"
+      :prev-text="__('Prev')"
+      :next-text="__('Next')"
+      :label-next-page="__('Go to next page')"
+      :label-prev-page="__('Go to previous page')"
+      align="center"
+      class="gl-mt-5"
+    />
+  </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 3db5ee2cf6e94cfbbdcf8c727fafedc0e9ec536c..0bc02d15d2a6f8c33ce3cfb9b697e856a2cc4230 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -5,6 +5,7 @@ import { parseRailsFormFields } from '~/lib/utils/forms';
 import { __, sprintf } from '~/locale';
 import Translate from '~/vue_shared/translate';
 import AccessTokenTableApp from './components/access_token_table_app.vue';
+import InactiveAccessTokenTableApp from './components/inactive_access_token_table_app.vue';
 import ExpiresAtField from './components/expires_at_field.vue';
 import NewAccessTokenApp from './components/new_access_token_app.vue';
 import TokensApp from './components/tokens_app.vue';
@@ -50,6 +51,44 @@ export const initAccessTokenTableApp = () => {
   });
 };
 
+export const initInactiveAccessTokenTableApp = () => {
+  const el = document.querySelector('#js-inactive-access-token-table-app');
+
+  if (!el) {
+    return null;
+  }
+
+  const {
+    accessTokenType,
+    accessTokenTypePlural,
+    initialInactiveAccessTokens: initialInactiveAccessTokensJson,
+    noInactiveTokensMessage: noTokensMessage,
+  } = el.dataset;
+
+  // Default values
+  const noInactiveTokensMessage =
+    noTokensMessage ||
+    sprintf(__('This resource has no inactive %{accessTokenTypePlural}.'), {
+      accessTokenTypePlural,
+    });
+
+  const initialInactiveAccessTokens = JSON.parse(initialInactiveAccessTokensJson);
+
+  return new Vue({
+    el,
+    name: 'InactiveAccessTokenTableRoot',
+    provide: {
+      accessTokenType,
+      accessTokenTypePlural,
+      initialInactiveAccessTokens,
+      noInactiveTokensMessage,
+    },
+    render(h) {
+      return h(InactiveAccessTokenTableApp);
+    },
+  });
+};
+
 export const initExpiresAtField = () => {
   const el = document.querySelector('.js-access-tokens-expires-at');
 
diff --git a/app/assets/javascripts/pages/groups/settings/access_tokens/index.js b/app/assets/javascripts/pages/groups/settings/access_tokens/index.js
index b9f282a123c36d97cdb0b31bdd43248962f47d71..6e0b6c78e2896b21ec0542bed6ed91bd63e48b80 100644
--- a/app/assets/javascripts/pages/groups/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/groups/settings/access_tokens/index.js
@@ -1,5 +1,6 @@
 import {
   initAccessTokenTableApp,
+  initInactiveAccessTokenTableApp,
   initExpiresAtField,
   initNewAccessTokenApp,
 } from '~/access_tokens';
@@ -7,3 +8,7 @@ import {
 initAccessTokenTableApp();
 initExpiresAtField();
 initNewAccessTokenApp();
+
+if (gon.features.retainResourceAccessTokenUserAfterRevoke) {
+  initInactiveAccessTokenTableApp();
+}
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
index b9f282a123c36d97cdb0b31bdd43248962f47d71..6e0b6c78e2896b21ec0542bed6ed91bd63e48b80 100644
--- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -1,5 +1,6 @@
 import {
   initAccessTokenTableApp,
+  initInactiveAccessTokenTableApp,
   initExpiresAtField,
   initNewAccessTokenApp,
 } from '~/access_tokens';
@@ -7,3 +8,7 @@ import {
 initAccessTokenTableApp();
 initExpiresAtField();
 initNewAccessTokenApp();
+
+if (gon.features.retainResourceAccessTokenUserAfterRevoke) {
+  initInactiveAccessTokenTableApp();
+}
diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb
index 84cbdda1581318f4cf37d6e1d9d19a1cc2523949..ed3bacfd175364ced39685e828c0ef123ff0f82c 100644
--- a/app/controllers/concerns/access_tokens_actions.rb
+++ b/app/controllers/concerns/access_tokens_actions.rb
@@ -7,6 +7,9 @@ module AccessTokensActions
     before_action -> { check_permission(:read_resource_access_tokens) }, only: [:index]
     before_action -> { check_permission(:destroy_resource_access_tokens) }, only: [:revoke]
     before_action -> { check_permission(:create_resource_access_tokens) }, only: [:create]
+    before_action do
+      push_frontend_feature_flag(:retain_resource_access_token_user_after_revoke, resource.root_ancestor)
+    end
   end
 
   # rubocop:disable Gitlab/ModuleWithInstanceVariables
@@ -70,6 +73,9 @@ def set_index_vars
 
     @scopes = Gitlab::Auth.available_scopes_for(resource)
     @active_access_tokens = active_access_tokens
+    if Feature.enabled?(:retain_resource_access_token_user_after_revoke, resource.root_ancestor) # rubocop:disable Style/GuardClause
+      @inactive_access_tokens = inactive_access_tokens
+    end
   end
   # rubocop:enable Gitlab/ModuleWithInstanceVariables
 
diff --git a/app/controllers/concerns/render_access_tokens.rb b/app/controllers/concerns/render_access_tokens.rb
index 072b01cb131baba3443b12a41533d8b36fa0f619..ac820287d46e6527d5630861e572cd685ed95b03 100644
--- a/app/controllers/concerns/render_access_tokens.rb
+++ b/app/controllers/concerns/render_access_tokens.rb
@@ -14,6 +14,15 @@ def active_access_tokens
     represent(tokens)
   end
 
+  def inactive_access_tokens
+    tokens = finder(state: 'inactive', sort: 'updated_at_desc').execute.preload_users
+
+    # We don't call `add_pagination_headers` as this overrides the
+    # pagination of active tokens.
+
+    represent(tokens)
+  end
+
   def add_pagination_headers(relation)
     Gitlab::Pagination::OffsetHeaderBuilder.new(
       request_context: self,
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index e04eadc01338d9b103d21c731e196da2428e271f..907633aa6f9c7fef16c2e5df5c1750b728b619c4 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -3,6 +3,7 @@
 - type = _('group access token')
 - type_plural = _('group access tokens')
 - @force_desktop_expanded_sidebar = true
+- shared_card_component_classes = "gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
 
 .settings-section.js-search-settings-section
   .settings-sticky-header
@@ -25,7 +26,7 @@
 
   #js-new-access-token-app{ data: { access_token_type: type } }
 
-  = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
+  = render Pajamas::CardComponent.new(card_options: { class: "#{shared_card_component_classes} js-toggle-container js-token-card" }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
     - c.with_header do
       .gl-new-card-title-wrapper
         %h3.gl-new-card-title
@@ -54,3 +55,15 @@
             help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
 
   #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } }
+
+  - if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @group.root_ancestor)
+    = render Pajamas::CardComponent.new(card_options: { class: shared_card_component_classes }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0 gl-bg-gray-10 gl-border-b gl-rounded-bottom-base' }) do |c|
+      - c.with_header do
+        .gl-new-card-title-wrapper
+          %h3.gl-new-card-title
+            = _('Inactive group access tokens')
+          .gl-new-card-count
+            = sprite_icon('token', css_class: 'gl-mr-2')
+            %span.js-token-count= @inactive_access_tokens.size
+      - c.with_body do
+        #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This group has no inactive access tokens.')} }
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index ea3ad370fb5c8fa047af97451d60abddcb5be4f8..12202578cfe0e3ba8c2d940ee7d8dd83adecaf86 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -3,6 +3,7 @@
 - type = _('project access token')
 - type_plural = _('project access tokens')
 - @force_desktop_expanded_sidebar = true
+- shared_card_component_classes = "gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
 
 .settings-section.js-search-settings-section
   .settings-sticky-header
@@ -24,7 +25,7 @@
 
   #js-new-access-token-app{ data: { access_token_type: type } }
 
-  = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
+  = render Pajamas::CardComponent.new(card_options: { class: "#{shared_card_component_classes} js-toggle-container js-token-card" }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
     - c.with_header do
       .gl-new-card-title-wrapper
         %h3.gl-new-card-title
@@ -42,3 +43,15 @@
           = render_if_exists 'projects/settings/access_tokens/form', type: type
 
   #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } }
+
+  - if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @project.root_ancestor)
+    = render Pajamas::CardComponent.new(card_options: { class: shared_card_component_classes }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0 gl-bg-gray-10 gl-border-b gl-rounded-bottom-base' }) do |c|
+      - c.with_header do
+        .gl-new-card-title-wrapper
+          %h3.gl-new-card-title
+            = _('Inactive project access tokens')
+          .gl-new-card-count
+            = sprite_icon('token', css_class: 'gl-mr-2')
+            %span.js-token-count= @inactive_access_tokens.size
+      - c.with_body do
+        #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This project has no inactive access tokens.')} }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e54fb7b32c852efd442ea743c912fb56483061a0..9fbb00c5660b8344522d48634f4736da2104c412 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -27424,6 +27424,12 @@ msgstr ""
 msgid "Inactive"
 msgstr ""
 
+msgid "Inactive group access tokens"
+msgstr ""
+
+msgid "Inactive project access tokens"
+msgstr ""
+
 msgid "Incident"
 msgstr ""
 
@@ -54307,6 +54313,9 @@ msgstr ""
 msgid "This group has no active access tokens."
 msgstr ""
 
+msgid "This group has no inactive access tokens."
+msgstr ""
+
 msgid "This group has no projects yet"
 msgstr ""
 
@@ -54600,6 +54609,9 @@ msgstr ""
 msgid "This project has no active access tokens."
 msgstr ""
 
+msgid "This project has no inactive access tokens."
+msgstr ""
+
 msgid "This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:"
 msgstr ""
 
@@ -54672,6 +54684,9 @@ msgstr ""
 msgid "This resource has no comments to summarize"
 msgstr ""
 
+msgid "This resource has no inactive %{accessTokenTypePlural}."
+msgstr ""
+
 msgid "This runner will only run on pipelines triggered on protected branches"
 msgstr ""
 
diff --git a/spec/frontend/access_tokens/components/inactive_access_token_table_app_spec.js b/spec/frontend/access_tokens/components/inactive_access_token_table_app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..956916d76e0047b7a7aaf5b143f5525132562605
--- /dev/null
+++ b/spec/frontend/access_tokens/components/inactive_access_token_table_app_spec.js
@@ -0,0 +1,180 @@
+import { GlPagination, GlTable } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import InactiveAccessTokenTableApp from '~/access_tokens/components/inactive_access_token_table_app.vue';
+import { PAGE_SIZE } from '~/access_tokens/components/constants';
+import { __, s__, sprintf } from '~/locale';
+
+describe('~/access_tokens/components/inactive_access_token_table_app', () => {
+  let wrapper;
+
+  const accessTokenType = 'access token';
+  const accessTokenTypePlural = 'access tokens';
+  const information = undefined;
+  const noInactiveTokensMessage = 'This resource has no inactive access tokens.';
+
+  const defaultInactiveAccessTokens = [
+    {
+      name: 'a',
+      scopes: ['api'],
+      created_at: '2023-05-01T00:00:00.000Z',
+      last_used_at: null,
+      expired: true,
+      expires_at: '2024-05-01T00:00:00.000Z',
+      revoked: true,
+      role: 'Maintainer',
+    },
+    {
+      name: 'b',
+      scopes: ['api', 'sudo'],
+      created_at: '2024-04-21T00:00:00.000Z',
+      last_used_at: '2024-04-21T00:00:00.000Z',
+      expired: true,
+      expires_at: new Date().toISOString(),
+      revoked: false,
+      role: 'Maintainer',
+    },
+  ];
+
+  const createComponent = (props = {}) => {
+    wrapper = mountExtended(InactiveAccessTokenTableApp, {
+      provide: {
+        accessTokenType,
+        accessTokenTypePlural,
+        information,
+        initialInactiveAccessTokens: defaultInactiveAccessTokens,
+        noInactiveTokensMessage,
+        ...props,
+      },
+    });
+  };
+
+  const findTable = () => wrapper.findComponent(GlTable);
+  const findHeaders = () => findTable().findAll('th > div > span');
+  const findCells = () => findTable().findAll('td');
+  const findPagination = () => wrapper.findComponent(GlPagination);
+
+  it('should render an empty table with a default message', () => {
+    createComponent({ initialInactiveAccessTokens: [] });
+
+    const cells = findCells();
+    expect(cells).toHaveLength(1);
+    expect(cells.at(0).text()).toBe(
+      sprintf(__('This resource has no inactive %{accessTokenTypePlural}.'), {
+        accessTokenTypePlural,
+      }),
+    );
+  });
+
+  it('should render an empty table with a custom message', () => {
+    const noTokensMessage = 'This group has no inactive access tokens.';
+    createComponent({ initialInactiveAccessTokens: [], noInactiveTokensMessage: noTokensMessage });
+
+    const cells = findCells();
+    expect(cells).toHaveLength(1);
+    expect(cells.at(0).text()).toBe(noTokensMessage);
+  });
+
+  describe('table headers', () => {
+    it('has expected columns', () => {
+      createComponent();
+
+      const headers = findHeaders();
+      expect(headers.wrappers.map((header) => header.text())).toStrictEqual([
+        __('Token name'),
+        __('Scopes'),
+        s__('AccessTokens|Created'),
+        'Last Used',
+        __('Expired'),
+        __('Role'),
+      ]);
+    });
+  });
+
+  it('`Last Used` header should contain a link and an assistive message', () => {
+    createComponent();
+
+    const headers = wrapper.findAll('th');
+    const lastUsed = headers.at(3);
+    const anchor = lastUsed.find('a');
+    const assistiveElement = lastUsed.find('.gl-sr-only');
+    expect(anchor.exists()).toBe(true);
+    expect(anchor.attributes('href')).toBe(
+      '/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used',
+    );
+    expect(assistiveElement.text()).toBe(s__('AccessTokens|The last time a token was used'));
+  });
+
+  it('sorts rows alphabetically', async () => {
+    createComponent();
+
+    const cells = findCells();
+
+    // First and second rows
+    expect(cells.at(0).text()).toBe('a');
+    expect(cells.at(6).text()).toBe('b');
+
+    const headers = findHeaders();
+    await headers.at(0).trigger('click');
+    await headers.at(0).trigger('click');
+
+    // First and second rows have swapped
+    expect(cells.at(0).text()).toBe('b');
+    expect(cells.at(6).text()).toBe('a');
+  });
+
+  it('sorts rows by last used date', async () => {
+    createComponent();
+
+    const cells = findCells();
+
+    // First and second rows
+    expect(cells.at(0).text()).toBe('a');
+    expect(cells.at(6).text()).toBe('b');
+
+    const headers = findHeaders();
+    await headers.at(3).trigger('click');
+
+    // First and second rows have swapped
+    expect(cells.at(0).text()).toBe('b');
+    expect(cells.at(6).text()).toBe('a');
+  });
+
+  it('sorts rows by expiry date', async () => {
+    createComponent();
+
+    const cells = findCells();
+    const headers = findHeaders();
+    await headers.at(4).trigger('click');
+
+    // First and second rows have swapped
+    expect(cells.at(0).text()).toBe('b');
+    expect(cells.at(6).text()).toBe('a');
+  });
+
+  it('shows Revoked in expiry column when revoked', () => {
+    createComponent();
+
+    const cells = findCells();
+
+    // First and second rows
+    expect(cells.at(4).text()).toBe('Revoked');
+    expect(cells.at(10).text()).toBe('Expired just now');
+  });
+
+  describe('pagination', () => {
+    it('does not show pagination component', () => {
+      createComponent({
+        initialInactiveAccessTokens: Array(PAGE_SIZE).fill(defaultInactiveAccessTokens[0]),
+      });
+
+      expect(findPagination().exists()).toBe(false);
+    });
+
+    it('shows the pagination component', () => {
+      createComponent({
+        initialInactiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultInactiveAccessTokens[0]),
+      });
+      expect(findPagination().exists()).toBe(true);
+    });
+  });
+});
diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js
index 7d4d73b00b212ad51857e6633df4087f6d70791c..89de2606bb6a0a45f8f7436da6598cd20b170671 100644
--- a/spec/frontend/access_tokens/index_spec.js
+++ b/spec/frontend/access_tokens/index_spec.js
@@ -5,9 +5,11 @@ import {
   initAccessTokenTableApp,
   initExpiresAtField,
   initNewAccessTokenApp,
+  initInactiveAccessTokenTableApp,
   initTokensApp,
 } from '~/access_tokens';
 import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
+import InactiveAccessTokenTableApp from '~/access_tokens/components/inactive_access_token_table_app.vue';
 import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
 import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
 import TokensApp from '~/access_tokens/components/tokens_app.vue';
@@ -173,4 +175,81 @@ describe('access tokens', () => {
       expect(initNewAccessTokenApp()).toBe(null);
     });
   });
+
+  describe('initInactiveAccessTokenTableApp', () => {
+    const accessTokenType = 'group access token';
+    const accessTokenTypePlural = 'group access tokens';
+    const initialInactiveAccessTokens = [
+      {
+        name: 'a',
+        scopes: ['api'],
+        created_at: '2023-05-01T00:00:00.000Z',
+        last_used_at: null,
+        expired: false,
+        expires_at: null,
+        revoked: true,
+        role: 'Maintainer',
+      },
+    ];
+
+    it('mounts the component and provides required values', () => {
+      setHTMLFixture(
+        `<div id="js-inactive-access-token-table-app"
+        data-access-token-type="${accessTokenType}"
+        data-access-token-type-plural="${accessTokenTypePlural}"
+        data-initial-inactive-access-tokens=${JSON.stringify(initialInactiveAccessTokens)}
+        >
+        </div>`,
+      );
+
+      const vueInstance = initInactiveAccessTokenTableApp();
+      wrapper = createWrapper(vueInstance);
+      const component = wrapper.findComponent({ name: 'InactiveAccessTokenTableRoot' });
+
+      expect(component.exists()).toBe(true);
+      expect(wrapper.findComponent(InactiveAccessTokenTableApp).vm).toMatchObject({
+        // Required value
+        accessTokenType,
+        accessTokenTypePlural,
+        initialInactiveAccessTokens,
+
+        // Default values
+        noInactiveTokensMessage: sprintf(
+          __('This resource has no inactive %{accessTokenTypePlural}.'),
+          {
+            accessTokenTypePlural,
+          },
+        ),
+      });
+    });
+
+    it('mounts the component and provides all values', () => {
+      const noInactiveTokensMessage = 'This group has no inactive access tokens.';
+      setHTMLFixture(
+        `<div id="js-inactive-access-token-table-app"
+          data-access-token-type="${accessTokenType}"
+          data-access-token-type-plural="${accessTokenTypePlural}"
+          data-initial-inactive-access-tokens=${JSON.stringify(initialInactiveAccessTokens)}
+          data-no-inactive-tokens-message="${noInactiveTokensMessage}"
+          >
+        </div>`,
+      );
+
+      const vueInstance = initInactiveAccessTokenTableApp();
+      wrapper = createWrapper(vueInstance);
+      const component = wrapper.findComponent({ name: 'InactiveAccessTokenTableRoot' });
+
+      expect(component.exists()).toBe(true);
+      expect(component.findComponent(InactiveAccessTokenTableApp).vm).toMatchObject({
+        accessTokenType,
+        accessTokenTypePlural,
+        initialInactiveAccessTokens,
+        noInactiveTokensMessage,
+      });
+    });
+
+    it('returns `null`', () => {
+      expect(initInactiveAccessTokenTableApp()).toBe(null);
+    });
+  });
 });
diff --git a/spec/requests/groups/settings/access_tokens_controller_spec.rb b/spec/requests/groups/settings/access_tokens_controller_spec.rb
index b38c40515bea86fa76de4226d9f7e64fdccf9fc1..bed67d3aebc7ad4a66ecc40b52f1e5b8d1239d85 100644
--- a/spec/requests/groups/settings/access_tokens_controller_spec.rb
+++ b/spec/requests/groups/settings/access_tokens_controller_spec.rb
@@ -40,6 +40,7 @@
     it_behaves_like 'feature unavailable'
     it_behaves_like 'GET resource access tokens available'
     it_behaves_like 'GET access tokens are paginated and ordered'
+    it_behaves_like 'GET access tokens includes inactive tokens'
   end
 
   describe 'POST /:namespace/-/settings/access_tokens' do
diff --git a/spec/requests/projects/settings/access_tokens_controller_spec.rb b/spec/requests/projects/settings/access_tokens_controller_spec.rb
index a354e3a42bd936ee5e3a21b34955af8fa8a7539b..8b3d4c02506884967b9a039dc6bf70ae7caf96f7 100644
--- a/spec/requests/projects/settings/access_tokens_controller_spec.rb
+++ b/spec/requests/projects/settings/access_tokens_controller_spec.rb
@@ -41,6 +41,7 @@
     it_behaves_like 'feature unavailable'
     it_behaves_like 'GET resource access tokens available'
     it_behaves_like 'GET access tokens are paginated and ordered'
+    it_behaves_like 'GET access tokens includes inactive tokens'
   end
 
   describe 'POST /:namespace/:project/-/settings/access_tokens' do
diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
index 34e3ba95b0d234a720acd342b6cc8689b9faeb84..c1a2fc52fe83a61501cc06fcf2ce41869d6e7570 100644
--- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb
+++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
@@ -129,6 +129,21 @@ def active_access_tokens
     find("[data-testid='active-tokens']")
   end
 
+  def inactive_access_tokens
+    find("[data-testid='inactive-access-tokens']")
+  end
+
+  context 'when feature flag is disabled' do
+    before do
+      stub_feature_flags(retain_resource_access_token_user_after_revoke: false)
+    end
+
+    it 'does not show inactive tokens' do
+      visit resource_settings_access_tokens_path
+      expect(page).to have_no_selector("[data-testid='inactive-access-tokens']")
+    end
+  end
+
   it 'allows revocation of an active token' do
     visit resource_settings_access_tokens_path
     accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' }
@@ -141,6 +156,15 @@ def active_access_tokens
     visit resource_settings_access_tokens_path
 
     expect(active_access_tokens).to have_text(no_active_tokens_text)
+    expect(inactive_access_tokens).to have_text(resource_access_token.name)
+  end
+
+  it 'removes revoked tokens from active section' do
+    resource_access_token.revoke!
+    visit resource_settings_access_tokens_path
+
+    expect(active_access_tokens).to have_text(no_active_tokens_text)
+    expect(inactive_access_tokens).to have_text(resource_access_token.name)
   end
 
   context 'when resource access token creation is not allowed' do
diff --git a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
index 3c11b2fea6432dbfb20faf5931c3a07ce1d049f7..da5b41f18d6bf768c3c70ffb4e7cb3e28874eaad 100644
--- a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
+++ b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
@@ -58,7 +58,7 @@
     end
   end
 
-  context "when tokens returned are ordered" do
+  context "when active tokens returned are ordered" do
     let(:expires_1_day_from_now) { 1.day.from_now.to_date }
     let(:expires_2_day_from_now) { 2.days.from_now.to_date }
 
@@ -95,6 +95,33 @@ def expect_header(header_name, header_val)
   end
 end
 
+RSpec.shared_examples 'GET access tokens includes inactive tokens' do
+  context "when inactive tokens returned are ordered" do
+    let(:one_day_ago) { 1.day.ago.to_date }
+    let(:two_days_ago) { 2.days.ago.to_date }
+
+    before do
+      create(:personal_access_token, :revoked, user: access_token_user, name: "Token1").update!(updated_at: one_day_ago)
+      create(:personal_access_token, :expired, user: access_token_user,
+        name: "Token2").update!(updated_at: two_days_ago)
+    end
+
+    it "orders token list descending on updated_at" do
+      get_access_tokens
+
+      first_token = assigns(:inactive_access_tokens).first.as_json
+      expect(first_token['name']).to eq("Token1")
+    end
+  end
+
+  context "when there are no inactive tokens" do
+    it "returns an empty array" do
+      get_access_tokens
+      expect(assigns(:inactive_access_tokens)).to eq([])
+    end
+  end
+end
+
 RSpec.shared_examples 'POST resource access tokens available' do
   def created_token
     PersonalAccessToken.order(:created_at).last