diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
index 8b36646810d37670459f5641a9e1968de566a01e..bbd8c4111a5427bb77a47d1543ae529ae90277fa 100644
--- a/app/channels/application_cable/channel.rb
+++ b/app/channels/application_cable/channel.rb
@@ -9,7 +9,7 @@ class Channel < ActionCable::Channel::Base
 
     def validate_token_scope
       validate_and_save_access_token!(scopes: authorization_scopes)
-    rescue Gitlab::Auth::InsufficientScopeError
+    rescue Gitlab::Auth::AuthenticationError
       reject
     end
 
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index 7b9988379afd485fa6cba1138b6618b637652bb0..7316b1ce0e5319aa171173b5806113b6fe7cd41d 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -11,7 +11,7 @@ class Connection < ActionCable::Connection::Base
 
     def connect
       self.current_user = find_user_from_bearer_token || find_user_from_session_store
-    rescue Gitlab::Auth::UnauthorizedError
+    rescue Gitlab::Auth::AuthenticationError
       reject_unauthorized_connection
     end
 
diff --git a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
index 071eb6c967eaff0bf79d8688b77f22e1863b3631..4d52caa9441f3becda6cd6790e5f92963849d91e 100644
--- a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
+++ b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
@@ -8,7 +8,7 @@ class DeleteJobs < BaseMutation
 
         ADMIN_MESSAGE = 'You must be an admin to use this mutation'
 
-        ::Gitlab::ApplicationContext.known_keys.each do |key|
+        ::Gitlab::ApplicationContext.allowed_job_keys.each do |key|
           argument key,
             GraphQL::Types::String,
             required: false,
diff --git a/lib/gitlab/application_context.rb b/lib/gitlab/application_context.rb
index d616c2e037ddac1fea8200914f50873b7b7980a9..7b600ec45c0f7fdd9abedcc437b63d56bb910b15 100644
--- a/lib/gitlab/application_context.rb
+++ b/lib/gitlab/application_context.rb
@@ -28,10 +28,18 @@ class ApplicationContext
       :root_caller_id,
       :merge_action_status,
       :bulk_import_entity_id,
-      :sidekiq_destination_shard_redis
+      :sidekiq_destination_shard_redis,
+      :auth_fail_reason,
+      :auth_fail_token_id
     ].freeze
     private_constant :KNOWN_KEYS
 
+    WEB_ONLY_KEYS = [
+      :auth_fail_reason,
+      :auth_fail_token_id
+    ].freeze
+    private_constant :WEB_ONLY_KEYS
+
     APPLICATION_ATTRIBUTES = [
       Attribute.new(:project, Project),
       Attribute.new(:namespace, Namespace),
@@ -49,7 +57,9 @@ class ApplicationContext
       Attribute.new(:root_caller_id, String),
       Attribute.new(:merge_action_status, String),
       Attribute.new(:bulk_import_entity_id, Integer),
-      Attribute.new(:sidekiq_destination_shard_redis, String)
+      Attribute.new(:sidekiq_destination_shard_redis, String),
+      Attribute.new(:auth_fail_reason, String),
+      Attribute.new(:auth_fail_token_id, String)
     ].freeze
     private_constant :APPLICATION_ATTRIBUTES
 
@@ -57,6 +67,12 @@ def self.known_keys
       KNOWN_KEYS
     end
 
+    # Sidekiq jobs may be deleted by matching keys in ApplicationContext.
+    # Filter out keys that aren't available in Sidekiq jobs.
+    def self.allowed_job_keys
+      known_keys - WEB_ONLY_KEYS
+    end
+
     def self.application_attributes
       APPLICATION_ATTRIBUTES
     end
@@ -113,6 +129,8 @@ def to_lazy_hash
         assign_hash_if_value(hash, :merge_action_status)
         assign_hash_if_value(hash, :bulk_import_entity_id)
         assign_hash_if_value(hash, :sidekiq_destination_shard_redis)
+        assign_hash_if_value(hash, :auth_fail_reason)
+        assign_hash_if_value(hash, :auth_fail_token_id)
 
         hash[:user] = -> { username } if include_user?
         hash[:user_id] = -> { user_id } if include_user?
diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb
index 9b052fe1882704562eaa283e394573ab7a593a5c..a15abd4ec9bb94c0d28b504a33057e1f653f9a3c 100644
--- a/lib/gitlab/auth/auth_finders.rb
+++ b/lib/gitlab/auth/auth_finders.rb
@@ -174,7 +174,7 @@ def find_runner_from_token
         ::Ci::Runner.find_by_token(token.to_s) || raise(UnauthorizedError)
       end
 
-      def validate_and_save_access_token!(scopes: [])
+      def validate_and_save_access_token!(scopes: [], save_auth_context: true)
         # return early if we've already authenticated via a job token
         return if @current_authenticated_job.present? # rubocop:disable Gitlab/ModuleWithInstanceVariables
 
@@ -185,14 +185,18 @@ def validate_and_save_access_token!(scopes: [])
 
         case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
         when AccessTokenValidationService::INSUFFICIENT_SCOPE
+          save_auth_failure_in_application_context(access_token, :insufficient_scope) if save_auth_context
           raise InsufficientScopeError, scopes
         when AccessTokenValidationService::EXPIRED
+          save_auth_failure_in_application_context(access_token, :token_expired) if save_auth_context
           raise ExpiredError
         when AccessTokenValidationService::REVOKED
+          save_auth_failure_in_application_context(access_token, :token_revoked) if save_auth_context
           revoke_token_family(access_token)
 
           raise RevokedError
         when AccessTokenValidationService::IMPERSONATION_DISABLED
+          save_auth_failure_in_application_context(access_token, :impersonation_disabled) if save_auth_context
           raise ImpersonationDisabled
         end
 
@@ -211,6 +215,12 @@ def save_current_token_in_env
         request.env[API_TOKEN_ENV] = { token_id: access_token.id, token_type: access_token.class.to_s }
       end
 
+      def save_auth_failure_in_application_context(access_token, cause)
+        Gitlab::ApplicationContext.push(
+          auth_fail_reason: cause.to_s,
+          auth_fail_token_id: "#{access_token.class}/#{access_token.id}")
+      end
+
       def find_user_from_job_bearer_token
         return unless route_authentication_setting[:job_token_allowed]
 
diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb
index 0b3b3badcdc694859c15841e8f65c051b6bc33a5..d288943483abcb8fcb2c8b173c7e662f59a8fe92 100644
--- a/lib/gitlab/auth/request_authenticator.rb
+++ b/lib/gitlab/auth/request_authenticator.rb
@@ -70,7 +70,9 @@ def find_user_from_personal_access_token_for_api_or_git
       end
 
       def valid_access_token?(scopes: [])
-        validate_and_save_access_token!(scopes: scopes)
+        # We may just be checking whether the user has :admin_mode access, so
+        # don't construe an auth failure as a real failure.
+        validate_and_save_access_token!(scopes: scopes, save_auth_context: false)
 
         true
       rescue Gitlab::Auth::AuthenticationError
diff --git a/lib/gitlab/sidekiq_queue.rb b/lib/gitlab/sidekiq_queue.rb
index df2dd7b5c0767ffd66ca097422c21f43433b75bf..b7c4a7c28f1cf62c629f8289cd5b186bc8eb14d6 100644
--- a/lib/gitlab/sidekiq_queue.rb
+++ b/lib/gitlab/sidekiq_queue.rb
@@ -8,7 +8,7 @@ class SidekiqQueue
     InvalidQueueError = Class.new(StandardError)
 
     WORKER_KEY = 'worker_class'
-    ALLOWED_KEYS = Gitlab::ApplicationContext.known_keys.map(&:to_s) + [WORKER_KEY]
+    ALLOWED_KEYS = Gitlab::ApplicationContext.allowed_job_keys.map(&:to_s) + [WORKER_KEY]
 
     attr_reader :queue_name
 
diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb
index 961568608e787b1b392d4b2463e68adce0b8f871..ba0a5aeecc1aaf6ce03382f780120c5669260a71 100644
--- a/spec/channels/application_cable/connection_spec.rb
+++ b/spec/channels/application_cable/connection_spec.rb
@@ -46,12 +46,47 @@
   context 'when bearer header is provided' do
     context 'when it is a personal_access_token' do
       let(:user_pat) { create(:personal_access_token) }
+      let(:app_context) { Gitlab::ApplicationContext.current }
+      let_it_be(:expired_token) { create(:personal_access_token, :expired, scopes: %w[read_api]) }
+      let_it_be(:revoked_token) { create(:personal_access_token, :revoked, scopes: %w[read_api]) }
 
       it 'finds user by PAT' do
         connect(ActionCable.server.config.mount_path, headers: { Authorization: "Bearer #{user_pat.token}" })
 
         expect(connection.current_user).to eq(user_pat.user)
       end
+
+      context 'when an expired personal_access_token' do
+        let_it_be(:user_pat) { expired_token }
+
+        it 'sets the current_user as `nil`, and rejects the connection' do
+          expect do
+            connect(ActionCable.server.config.mount_path,
+              headers: { Authorization: "Bearer #{user_pat.token}" }
+            )
+          end.to have_rejected_connection
+
+          expect(connection.current_user).to be_nil
+          expect(app_context['meta.auth_fail_reason']).to eq('token_expired')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{user_pat.id}")
+        end
+      end
+
+      context 'when a revoked personal_access_token' do
+        let_it_be(:user_pat) { revoked_token }
+
+        it 'sets the current_user as `nil`, and rejects the connection' do
+          expect do
+            connect(ActionCable.server.config.mount_path,
+              headers: { Authorization: "Bearer #{user_pat.token}" }
+            )
+          end.to have_rejected_connection
+
+          expect(connection.current_user).to be_nil
+          expect(app_context['meta.auth_fail_reason']).to eq('token_revoked')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{user_pat.id}")
+        end
+      end
     end
 
     context 'when it is an OAuth access token' do
diff --git a/spec/channels/graphql_channel_spec.rb b/spec/channels/graphql_channel_spec.rb
index e02c58f30f98ea04058779ca81cea8ee25690f17..97e7bf392facf171e9f96b9a4029ee5392376cd8 100644
--- a/spec/channels/graphql_channel_spec.rb
+++ b/spec/channels/graphql_channel_spec.rb
@@ -11,6 +11,9 @@
     create(:personal_access_token, scopes: %w[read_user read_api], user: user)
   end
 
+  let_it_be(:expired_token) { create(:personal_access_token, :expired, scopes: %w[read_api], user: user) }
+  let_it_be(:revoked_token) { create(:personal_access_token, :revoked, scopes: %w[read_api], user: user) }
+
   describe '#subscribed' do
     let(:query) do
       <<~GRAPHQL
@@ -41,6 +44,8 @@
     end
 
     context 'with a personal access token' do
+      let(:app_context) { Gitlab::ApplicationContext.current }
+
       before do
         stub_action_cable_connection current_user: user, access_token: access_token
       end
@@ -53,6 +58,7 @@
 
           expect(subscription).to be_confirmed
           expect(subscription.streams).to include(/graphql-event::mergeRequestReviewersUpdated:issuableId/)
+          expect(app_context.keys).not_to include('meta.auth_fail_reason', 'meta.auth_fail_token_id')
         end
       end
 
@@ -63,6 +69,8 @@
           subscribe(subscribe_params)
 
           expect(subscription).not_to be_confirmed
+          expect(app_context['meta.auth_fail_reason']).to eq('insufficient_scope')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{access_token.id}")
         end
       end
 
@@ -74,6 +82,31 @@
 
           expect(subscription).to be_confirmed
           expect(subscription.streams).to include(/graphql-event::mergeRequestReviewersUpdated:issuableId/)
+          expect(app_context.keys).not_to include('meta.auth_fail_reason', 'meta.auth_fail_token_id')
+        end
+      end
+
+      context 'with an expired read_user personal access token' do
+        let(:access_token) { expired_token }
+
+        it 'does not subscribe to the given graphql subscription' do
+          subscribe(subscribe_params)
+
+          expect(subscription).not_to be_confirmed
+          expect(app_context['meta.auth_fail_reason']).to eq('token_expired')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{access_token.id}")
+        end
+      end
+
+      context 'with a revoked read_user personal access token' do
+        let(:access_token) { revoked_token }
+
+        it 'does not subscribe to the given graphql subscription' do
+          subscribe(subscribe_params)
+
+          expect(subscription).not_to be_confirmed
+          expect(app_context['meta.auth_fail_reason']).to eq('token_revoked')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{access_token.id}")
         end
       end
     end
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index c35374e8bb417839eca0a8a1ccdd3d1f3121f689..958a2b5eee2661501aac8d37fd5d0cf5957f6cd2 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -8,6 +8,8 @@
   # two days is enough to make timezones irrelevant
   let_it_be(:last_activity_on) { 2.days.ago.to_date }
 
+  let(:app_context) { Gitlab::ApplicationContext.current }
+
   describe 'rescue_from' do
     let_it_be(:message) { 'green ideas sleep furiously' }
 
@@ -326,6 +328,32 @@
         end
       end
 
+      context 'with an expired token' do
+        let(:token) { create(:personal_access_token, :expired, user: user, scopes: [:api]) }
+
+        it_behaves_like 'invalid token'
+
+        it 'registers token_expire in application context' do
+          subject
+
+          expect(app_context['meta.auth_fail_reason']).to eq('token_expired')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{token.id}")
+        end
+      end
+
+      context 'with a revoked token' do
+        let(:token) { create(:personal_access_token, :revoked, user: user, scopes: [:api]) }
+
+        it_behaves_like 'invalid token'
+
+        it 'registers token_expire in application context' do
+          subject
+
+          expect(app_context['meta.auth_fail_reason']).to eq('token_revoked')
+          expect(app_context['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{token.id}")
+        end
+      end
+
       context 'with an invalid token' do
         context 'with auth header' do
           subject do
@@ -433,7 +461,8 @@
       it "assigns username in ApplicationContext" do
         subject
 
-        expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username)
+        expect(app_context).to include('meta.user' => user.username)
+        expect(app_context.keys).not_to include('meta.auth_fail_reason', 'meta.auth_fail_token_id')
       end
 
       it 'calls the track api when trackable method' do
@@ -513,7 +542,8 @@
       it "does not assign a username in ApplicationContext" do
         subject
 
-        expect(Gitlab::ApplicationContext.current.key?('meta.user')).to be false
+        expect(app_context.key?('meta.user')).to be false
+        expect(app_context.keys).not_to include('meta.auth_fail_reason', 'meta.auth_fail_token_id')
       end
     end
 
diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb
index b1a1fc7f62d5e92e80ff54af07de54913934f0f4..24dcf4722f60ccd375e39107c1d8dc5a9be795ce 100644
--- a/spec/lib/gitlab/application_context_spec.rb
+++ b/spec/lib/gitlab/application_context_spec.rb
@@ -2,7 +2,14 @@
 
 require 'spec_helper'
 
-RSpec.describe Gitlab::ApplicationContext do
+RSpec.describe Gitlab::ApplicationContext, feature_category: :shared do
+  describe '.allowed_job_keys' do
+    it 'includes known keys but omits Web-specific keys' do
+      expect(described_class.allowed_job_keys).to include(:user, :user_id, :project, :root_namespace, :client_id)
+      expect(described_class.allowed_job_keys).not_to include(:auth_fail_reason, :auth_fail_token_id)
+    end
+  end
+
   describe '.with_context' do
     it 'yields the block' do
       expect { |b| described_class.with_context({}, &b) }.to yield_control
diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb
index f96906cad01d3a1992ef2ccc082ece16be86417f..5a08ef7c164485d52f775fb9cfcedca870647c5d 100644
--- a/spec/lib/gitlab/auth/auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/auth_finders_spec.rb
@@ -985,6 +985,8 @@ def auth_header_with(token)
 
           expect { validate_and_save_access_token! }.to raise_error(Gitlab::Auth::ExpiredError)
           expect(request.env).not_to have_key(described_class::API_TOKEN_ENV)
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_reason']).to eq('token_expired')
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{personal_access_token.id}")
         end
 
         it 'returns Gitlab::Auth::RevokedError if token revoked', :aggregate_failures do
@@ -992,11 +994,15 @@ def auth_header_with(token)
 
           expect { validate_and_save_access_token! }.to raise_error(Gitlab::Auth::RevokedError)
           expect(request.env).not_to have_key(described_class::API_TOKEN_ENV)
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_reason']).to eq('token_revoked')
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{personal_access_token.id}")
         end
 
         it 'returns Gitlab::Auth::InsufficientScopeError if invalid token scope', :aggregate_failures do
           expect { validate_and_save_access_token!(scopes: [:sudo]) }.to raise_error(Gitlab::Auth::InsufficientScopeError)
           expect(request.env).not_to have_key(described_class::API_TOKEN_ENV)
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_reason']).to eq('insufficient_scope')
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{personal_access_token.id}")
         end
       end
     end
@@ -1012,6 +1018,8 @@ def auth_header_with(token)
 
         it 'returns Gitlab::Auth::ImpersonationDisabled' do
           expect { validate_and_save_access_token! }.to raise_error(Gitlab::Auth::ImpersonationDisabled)
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_reason']).to eq('impersonation_disabled')
+          expect(Gitlab::ApplicationContext.current['meta.auth_fail_token_id']).to eq("PersonalAccessToken/#{personal_access_token.id}")
         end
       end
     end
diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb
index 23511c57d5ee2ce70d7947194936f9eb8cba119e..a1f551528dbe581b30b0145bc23ac6092f50e694 100644
--- a/spec/requests/api/api_spec.rb
+++ b/spec/requests/api/api_spec.rb
@@ -141,6 +141,30 @@
         end
       end
 
+      context 'with an expired token' do
+        let_it_be(:private_project) { create(:project) }
+        let_it_be(:token) { create(:personal_access_token, :expired, user: user) }
+
+        it 'logs all application context fields and the route' do
+          expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data|
+            expect(data.stringify_keys).to include(
+              'correlation_id' => an_instance_of(String),
+              'meta.caller_id' => 'GET /api/:version/projects/:id/issues',
+              'meta.remote_ip' => an_instance_of(String),
+              'meta.client_id' => a_string_matching(%r{\Aip/.+}),
+              'meta.auth_fail_reason' => "token_expired",
+              'meta.auth_fail_token_id' => "PersonalAccessToken/#{token.id}",
+              'meta.feature_category' => 'team_planning',
+              'route' => '/api/:version/projects/:id/issues'
+            )
+          end
+
+          get(api("/projects/#{private_project.id}/issues", personal_access_token: token))
+
+          expect(response).to have_gitlab_http_status(:unauthorized)
+        end
+      end
+
       it 'skips context fields that do not apply' do
         expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data|
           expect(data.stringify_keys).to include(