diff --git a/doc/api/personal_access_tokens.md b/doc/api/personal_access_tokens.md
index 9d3edbab0ae00b978c9a5437abdaec27279f5839..938c37ee3e437a5f3584e0e0181526057b9557a5 100644
--- a/doc/api/personal_access_tokens.md
+++ b/doc/api/personal_access_tokens.md
@@ -211,9 +211,16 @@ Example response:
 
 ## Rotate a personal access token
 
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/403042) in GitLab 16.0
+Rotate a personal access token. Revokes the previous token and creates a new token that expires in one week
+
+You can either:
+
+- Use the personal access token ID.
+- Pass the personal access token to the API in a request header.
 
-Rotate a personal access token. Revokes the previous token and creates a new token that expires in one week.
+### Use a personal access token ID
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/403042) in GitLab 16.0
 
 In GitLab 16.6 and later, you can use the `expires_at` parameter to set a different expiry date. This non-default expiry date can be up to a maximum of one year from the rotation date.
 
@@ -250,7 +257,7 @@ Example response:
 }
 ```
 
-### Responses
+#### Responses
 
 - `200: OK` if the existing token is successfully revoked and the new token successfully created.
 - `400: Bad Request` if not rotated successfully.
@@ -259,6 +266,50 @@ Example response:
   - Token with the specified ID does not exist.
 - `404: Not Found` if the user is an administrator but the token with the specified ID does not exist.
 
+### Use a request header
+
+Requires:
+
+- `api` scope.
+
+You can use the `expires_at` parameter to set a different expiry date. This non-default expiry date can be up to a maximum of one year from the rotation date.
+
+```plaintext
+POST /personal_access_tokens/self/rotate
+```
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/personal_access_tokens/self/rotate"
+```
+
+Example response:
+
+```json
+{
+    "id": 42,
+    "name": "Rotated Token",
+    "revoked": false,
+    "created_at": "2023-08-01T15:00:00.000Z",
+    "scopes": ["api"],
+    "user_id": 1337,
+    "last_used_at": null,
+    "active": true,
+    "expires_at": "2023-08-15",
+    "token": "s3cr3t"
+}
+```
+
+#### Responses
+
+- `200: OK` if the existing token is successfully revoked and the new token successfully created.
+- `400: Bad Request` if not rotated successfully.
+- `401: Unauthorized` if either:
+  - The token does not exist.
+  - The token has expired.
+  - The token has been revoked.
+- `403: Forbidden` if the token is not allowed to rotate itself.
+- `405: Method Not Allowed` if the token is not a personal access token.
+
 ### Automatic reuse detection
 
 > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/395352) in GitLab 16.3
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 9982051d6954bb9ed30590690ae6d484c1f0f91d..cecb0d9001ebed0f73fce3ea6270f996143883e8 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -296,6 +296,7 @@ def initialize(location_url)
         mount ::API::Pages
         mount ::API::PagesDomains
         mount ::API::PersonalAccessTokens::SelfInformation
+        mount ::API::PersonalAccessTokens::SelfRotation
         mount ::API::PersonalAccessTokens
         mount ::API::ProjectAvatar
         mount ::API::ProjectClusters
diff --git a/lib/api/helpers/personal_access_tokens_helpers.rb b/lib/api/helpers/personal_access_tokens_helpers.rb
index 4fd72d89f4c7cb2a5d20a61b85fe6e52675c83a1..99ff48251608ff5e28668d8b65b4361d63a9e206 100644
--- a/lib/api/helpers/personal_access_tokens_helpers.rb
+++ b/lib/api/helpers/personal_access_tokens_helpers.rb
@@ -33,6 +33,18 @@ def revoke_token(token)
 
         service.success? ? no_content! : bad_request!(nil)
       end
+
+      def rotate_token(token, params)
+        service = ::PersonalAccessTokens::RotateService.new(current_user, token).execute(params)
+
+        if service.success?
+          status :ok
+
+          service.payload[:personal_access_token]
+        else
+          bad_request!(service.message)
+        end
+      end
     end
   end
 end
diff --git a/lib/api/personal_access_tokens.rb b/lib/api/personal_access_tokens.rb
index de00b66ead3a693c01fd0b39b029701e3cb14700..8bcacc7f32fc5679ce8a1081f5aac8e491cfa8fd 100644
--- a/lib/api/personal_access_tokens.rb
+++ b/lib/api/personal_access_tokens.rb
@@ -82,16 +82,9 @@ class PersonalAccessTokens < ::API::Base
         token = PersonalAccessToken.find_by_id(params[:id])
 
         if Ability.allowed?(current_user, :manage_user_personal_access_token, token&.user)
-          response = ::PersonalAccessTokens::RotateService.new(current_user, token).execute(declared_params)
+          new_token = rotate_token(token, declared_params)
 
-          if response.success?
-            status :ok
-
-            new_token = response.payload[:personal_access_token]
-            present new_token, with: Entities::PersonalAccessTokenWithToken
-          else
-            bad_request!(response.message)
-          end
+          present new_token, with: Entities::PersonalAccessTokenWithToken
         else
           # Only admins should be informed if the token doesn't exist
           current_user.can_admin_all_resources? ? not_found! : unauthorized!
diff --git a/lib/api/personal_access_tokens/self_rotation.rb b/lib/api/personal_access_tokens/self_rotation.rb
new file mode 100644
index 0000000000000000000000000000000000000000..da21fca8b554be5689c42db671481850129db76d
--- /dev/null
+++ b/lib/api/personal_access_tokens/self_rotation.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module API
+  class PersonalAccessTokens
+    class SelfRotation < ::API::Base
+      include APIGuard
+
+      feature_category :system_access
+
+      helpers ::API::Helpers::PersonalAccessTokensHelpers
+
+      allow_access_with_scope :api
+
+      before { authenticate! }
+
+      resource :personal_access_tokens do
+        desc 'Rotate a personal access token' do
+          detail 'Rotates a personal access token by passing it to the API in a header'
+          success code: 200, model: Entities::PersonalAccessTokenWithToken
+          failure [
+            { code: 400, message: 'Bad Request' },
+            { code: 401, message: 'Unauthorized' },
+            { code: 403, message: 'Forbidden' },
+            { code: 405, message: 'Method not allowed' }
+          ]
+          tags %w[personal_access_tokens]
+        end
+        params do
+          optional :expires_at,
+            type: Date,
+            desc: "The expiration date of the token",
+            documentation: { example: '2021-01-31' }
+        end
+        post 'self/rotate' do
+          not_allowed! unless access_token.is_a? PersonalAccessToken
+          forbidden! if current_user.project_bot?
+
+          new_token = rotate_token(access_token, declared_params)
+
+          present new_token, with: Entities::PersonalAccessTokenWithToken
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb
index 25465e73b95d95e7fab56a44dbb1522aee7b7552..b4ba2a8e50ea98fff521612d68bf599374c3ef5d 100644
--- a/lib/gitlab/auth/auth_finders.rb
+++ b/lib/gitlab/auth/auth_finders.rb
@@ -426,7 +426,8 @@ def revoke_token_family(token)
       end
 
       def access_token_rotation_request?
-        current_request.path.match(%r{access_tokens/\d+/rotate$})
+        current_request.path.match(%r{access_tokens/\d+/rotate$}) ||
+          current_request.path.match(%r{/personal_access_tokens/self/rotate$})
       end
     end
   end
diff --git a/spec/requests/api/personal_access_tokens/self_rotation_spec.rb b/spec/requests/api/personal_access_tokens/self_rotation_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..697f8f3a07fb79f2f74786e841e80bf94b5a71d6
--- /dev/null
+++ b/spec/requests/api/personal_access_tokens/self_rotation_spec.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::PersonalAccessTokens::SelfRotation, feature_category: :system_access do
+  let(:path) { '/personal_access_tokens/self/rotate' }
+  let(:token) { create(:personal_access_token, user: current_user) }
+  let(:expiry_date) { Date.today + 1.week }
+  let(:params) { {} }
+
+  let_it_be(:current_user) { create(:user) }
+
+  describe 'POST /personal_access_tokens/self/rotate' do
+    subject(:rotate_token) { post(api(path, personal_access_token: token), params: params) }
+
+    shared_examples 'rotating token succeeds' do
+      it 'rotate token', :aggregate_failures do
+        rotate_token
+
+        expect(response).to have_gitlab_http_status(:ok)
+        expect(json_response['token']).not_to eq(token.token)
+        expect(json_response['expires_at']).to eq(expiry_date.to_s)
+        expect(token.reload).to be_revoked
+      end
+    end
+
+    shared_examples 'rotating token denied' do |status|
+      it 'cannot rotate token' do
+        rotate_token
+
+        expect(response).to have_gitlab_http_status(status)
+      end
+    end
+
+    context 'when current_user is an administrator', :enable_admin_mode do
+      let(:current_user) { create(:admin) }
+
+      it_behaves_like 'rotating token succeeds'
+
+      context 'when expiry is defined' do
+        let(:expiry_date) { Date.today + 1.month }
+        let(:params) { { expires_at: expiry_date } }
+
+        it_behaves_like 'rotating token succeeds'
+      end
+
+      context 'with impersonated token' do
+        let(:token) { create(:personal_access_token, :impersonation, user: current_user) }
+
+        it_behaves_like 'rotating token succeeds'
+      end
+
+      Gitlab::Auth.all_available_scopes.each do |scope|
+        context "with a '#{scope}' scoped token" do
+          let(:current_user) { create(:admin) }
+          let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) }
+
+          if [Gitlab::Auth::API_SCOPE].include? scope
+            it_behaves_like 'rotating token succeeds'
+          else
+            it_behaves_like 'rotating token denied', :forbidden
+          end
+        end
+      end
+    end
+
+    context 'when current_user is not an administrator' do
+      let(:current_user) { create(:user) }
+
+      it_behaves_like 'rotating token succeeds'
+
+      context 'when expiry is defined' do
+        let(:expiry_date) { Date.today + 1.month }
+        let(:params) { { expires_at: expiry_date } }
+
+        it_behaves_like 'rotating token succeeds'
+      end
+
+      context 'with impersonated token' do
+        let(:token) { create(:personal_access_token, :impersonation, user: current_user) }
+
+        it_behaves_like 'rotating token succeeds'
+      end
+
+      Gitlab::Auth.all_available_scopes.each do |scope|
+        context "with a '#{scope}' scoped token" do
+          let(:current_user) { create(:user) }
+          let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) }
+
+          if [Gitlab::Auth::API_SCOPE].include? scope
+            it_behaves_like 'rotating token succeeds'
+          else
+            it_behaves_like 'rotating token denied', :forbidden
+          end
+        end
+      end
+    end
+
+    context 'when token is invalid' do
+      let(:current_user) { create(:user) }
+      let(:token) { instance_double(PersonalAccessToken, token: 'invalidtoken') }
+
+      it_behaves_like 'rotating token denied', :unauthorized
+    end
+
+    context 'with a revoked token' do
+      let(:token) { create(:personal_access_token, :revoked, user: current_user) }
+
+      it_behaves_like 'rotating token denied', :unauthorized
+    end
+
+    context 'with an expired token' do
+      let(:token) { create(:personal_access_token, expires_at: 1.day.ago, user: current_user) }
+
+      it_behaves_like 'rotating token denied', :unauthorized
+    end
+
+    context 'with a rotated token' do
+      let(:token) { create(:personal_access_token, :revoked, user: current_user) }
+      let!(:child_token) { create(:personal_access_token, previous_personal_access_token_id: token.id) }
+
+      it_behaves_like 'rotating token denied', :unauthorized
+
+      it 'revokes token family' do
+        rotate_token
+
+        expect(child_token.reload).to be_revoked
+      end
+    end
+
+    context 'with an OAuth token' do
+      subject(:rotate_token) { post(api(path, oauth_access_token: token), params: params) }
+
+      context 'with default scope' do
+        let(:token) { create(:oauth_access_token) }
+
+        it_behaves_like 'rotating token denied', :forbidden
+      end
+
+      Gitlab::Auth.all_available_scopes.each do |scope|
+        context "with a '#{scope}' scoped token" do
+          let(:token) { create(:oauth_access_token, scopes: [scope]) }
+
+          if [Gitlab::Auth::API_SCOPE].include? scope
+            it_behaves_like 'rotating token denied', :method_not_allowed
+          else
+            it_behaves_like 'rotating token denied', :forbidden
+          end
+        end
+      end
+    end
+
+    context 'with a deploy token' do
+      let(:token) { create(:deploy_token) }
+      let(:headers) { { Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => token.token } }
+
+      subject(:rotate_token) { post(api(path), params: params, headers: headers) }
+
+      it_behaves_like 'rotating token denied', :unauthorized
+    end
+
+    context 'with a job token' do
+      let(:job) { create(:ci_build, :running, user: current_user) }
+
+      subject(:rotate_token) { post(api(path, job_token: job.token), params: params) }
+
+      it_behaves_like 'rotating token denied', :unauthorized
+    end
+
+    context 'when current_user is a project bot' do
+      let(:current_user) { create(:user, :project_bot) }
+
+      it_behaves_like 'rotating token denied', :forbidden
+
+      context 'when expiry is defined' do
+        let(:expiry_date) { Date.today + 1.month }
+        let(:params) { { expires_at: expiry_date } }
+
+        it_behaves_like 'rotating token denied', :forbidden
+      end
+
+      context 'with impersonated token' do
+        let(:token) { create(:personal_access_token, :impersonation, user: current_user) }
+
+        it_behaves_like 'rotating token denied', :forbidden
+      end
+
+      Gitlab::Auth.resource_bot_scopes.each do |scope|
+        context "with a '#{scope}' scoped token" do
+          let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) }
+
+          it_behaves_like 'rotating token denied', :forbidden
+        end
+      end
+    end
+  end
+end