diff --git a/ansible/inventory/group_vars/all/apt b/ansible/inventory/group_vars/all/apt
index 904e05176e5fd42589b87c8e3be71a044cbb4d65..80218456cfdd7b00060c52ec7511f27ece92f1cc 100644
--- a/ansible/inventory/group_vars/all/apt
+++ b/ansible/inventory/group_vars/all/apt
@@ -45,3 +45,12 @@ apt_repositories: []
 # when replacing the distribution repositories via apt_repositories.
 # Default is false.
 apt_disable_sources_list: false
+
+# List of Apt auth configurations. Each item is a dict with the following keys:
+# * machine: 'machine' entry in the auth file
+# * login: 'login' entry in the auth file
+# * password: 'password' entry in the auth file
+# * filename: Name of a file in /etc/apt/auth.conf.d in which to store
+#   the auth configuration. The extension should be ``.conf``.
+# Default is an empty list.
+apt_auth: []
diff --git a/ansible/roles/apt/defaults/main.yml b/ansible/roles/apt/defaults/main.yml
index 39013cd67cb2946d54aed8a0d1bb1c2736dc5e57..de6c4142bbaa0c6de28e0b1899391673241f0e4d 100644
--- a/ansible/roles/apt/defaults/main.yml
+++ b/ansible/roles/apt/defaults/main.yml
@@ -48,3 +48,12 @@ apt_repositories: []
 # when replacing the distribution repositories via apt_repositories.
 # Default is false.
 apt_disable_sources_list: false
+
+# List of Apt auth configurations. Each item is a dict with the following keys:
+# * machine: 'machine' entry in the auth file
+# * login: 'login' entry in the auth file
+# * password: 'password' entry in the auth file
+# * filename: Name of a file in /etc/apt/auth.conf.d in which to store
+#   the auth configuration. The extension should be ``.conf``.
+# Default is an empty list.
+apt_auth: []
diff --git a/ansible/roles/apt/files/auth_schema.json b/ansible/roles/apt/files/auth_schema.json
new file mode 100644
index 0000000000000000000000000000000000000000..9773508223d62daba7176f574bf996ce460bbb86
--- /dev/null
+++ b/ansible/roles/apt/files/auth_schema.json
@@ -0,0 +1,28 @@
+{
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "description": "List of Apt auth configurations",
+    "type": "array",
+    "items": {
+        "description": "Apt auth configuration",
+        "type": "object",
+        "required": ["machine", "login", "password", "filename"],
+        "properties": {
+            "machine": {
+                "type": "string",
+		"minLength": 1
+            },
+            "login": {
+                "type": "string",
+		"minLength": 1
+            },
+            "password": {
+                "type": "string",
+		"minLength": 1
+            },
+            "filename": {
+                "type": "string",
+		"minLength": 1
+            }
+        }
+    }
+}
diff --git a/ansible/roles/apt/tasks/auth.yml b/ansible/roles/apt/tasks/auth.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9718ebfc1b85d8495257248576c31bcc59580528
--- /dev/null
+++ b/ansible/roles/apt/tasks/auth.yml
@@ -0,0 +1,32 @@
+---
+- name: Validate Apt auth config
+  ansible.utils.validate:
+    criteria: "{{ lookup('ansible.builtin.file', 'auth_schema.json') }}"
+    data: "{{ apt_auth }}"
+
+- name: Ensure the Apt auth.conf.d directory exists
+  ansible.builtin.file:
+    path: "/etc/apt/auth.conf.d"
+    state: directory
+    owner: root
+    group: root
+    mode: 0755
+  become: true
+
+- name: Configure Apt auth files
+  ansible.builtin.template:
+    src: "auth.conf.j2"
+    dest: "/etc/apt/auth.conf.d/{{ auth.filename }}"
+    owner: root
+    group: root
+    mode: 0600
+  become: true
+  # apt_auth contains sensitive data, so iterate over indices to avoid exposing
+  # them in Ansible output.
+  loop: "{{ apt_auth | map(attribute='filename') }}"
+  loop_control:
+    index_var: auth_index
+  vars:
+    auth: "{{ apt_auth[auth_index] }}"
+  notify:
+    - Update apt cache
diff --git a/ansible/roles/apt/tasks/main.yml b/ansible/roles/apt/tasks/main.yml
index b4cb8f636fdb94d38d0f011b8dffdf5fcc22d976..7bdf2cf010dbbd77396abad7682b96f43d78e809 100644
--- a/ansible/roles/apt/tasks/main.yml
+++ b/ansible/roles/apt/tasks/main.yml
@@ -6,3 +6,5 @@
 - import_tasks: keys.yml
 
 - import_tasks: repos.yml
+
+- import_tasks: auth.yml
diff --git a/ansible/roles/apt/templates/auth.conf.j2 b/ansible/roles/apt/templates/auth.conf.j2
new file mode 100644
index 0000000000000000000000000000000000000000..a5abd10ae3c49318dcf313889f08e1ba99ee82ca
--- /dev/null
+++ b/ansible/roles/apt/templates/auth.conf.j2
@@ -0,0 +1,5 @@
+# {{ ansible_managed }}
+
+machine {{ auth.machine }}
+login {{ auth.login }}
+password {{ auth.password }}
diff --git a/doc/source/configuration/reference/hosts.rst b/doc/source/configuration/reference/hosts.rst
index 19edabdb23ba4df09ed4c57408f0fe728181174d..4dd7075ee5ec4ff352d8e319f5d8676d29a57a53 100644
--- a/doc/source/configuration/reference/hosts.rst
+++ b/doc/source/configuration/reference/hosts.rst
@@ -444,6 +444,39 @@ that is signed by the key.
        components: all
        signed_by: example-key.asc
 
+Apt auth configuration
+----------------------
+
+Some repositories may require authentication using HTTP basic auth. Apt
+supports specifying credentials in URLs in ``sources.list`` files, but these
+files must be world-readable. A more secure setup involves writing credentials
+to `auth.conf
+<https://manpages.ubuntu.com/manpages/jammy/man5/apt_auth.conf.5.html>`__
+files which can have more restrictive permissions.
+
+Auth configuration is defined by the ``apt_auth`` variable. The format is a
+list, with each item mapping to a dict/map with the following items:
+
+* ``machine``: ``machine`` entry in the auth file
+* ``login``: ``machine`` entry in the auth file
+* ``password``: ``machine`` entry in the auth file
+* ``filename``: Name of a file in ``/etc/apt/auth.conf.d`` in which to store
+  the auth configuration. The extension should be ``.conf``.
+
+The default value of ``apt_auth`` is an empty list.
+
+In the following example, credentials are provided for package repositories at
+apt.example.com.
+
+.. code-block:: yaml
+   :caption: ``apt.yml``
+
+   apt_auth:
+     - machine: apt.example.com
+       login: my-username
+       password: my-password
+       filename: example.conf
+
 Development tools
 =================
 *tags:*
diff --git a/etc/kayobe/apt.yml b/etc/kayobe/apt.yml
index 9a9d8853862867bad88303499a3ece66a0f881c2..e4bb5b1795ae56f615ac72649cdfad5ce1e71d53 100644
--- a/etc/kayobe/apt.yml
+++ b/etc/kayobe/apt.yml
@@ -46,6 +46,17 @@
 # Default is false.
 #apt_disable_sources_list:
 
+# List of Apt auth configurations. Each item is a dict with the following keys:
+# * machine: 'machine' entry in the auth file
+# * login: 'login' entry in the auth file
+# * password: 'password' entry in the auth file
+# * filename: Name of a file in which to store the auth configuration. The
+#   extension should be '.conf'.
+# * filename: Name of a file in /etc/apt/auth.conf.d in which to store
+#   the auth configuration. The extension should be ``.conf``.
+# Default is an empty list.
+#apt_auth:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
index 2d7828f0549a2708675e67564fcd9ef9eb33f50f..01497d1e18f4619904c599bb9e5d5740696752ae 100644
--- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
+++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
@@ -146,6 +146,11 @@ apt_repositories:
     components: contrib
     signed_by: td-agent.asc
 apt_disable_sources_list: true
+apt_auth:
+  - machine: https://apt.example.com
+    login: foo
+    password: bar
+    filename: test.conf
 {% endif %}
 
 {% if ansible_facts.os_family == 'RedHat' %}
diff --git a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py
index 1c9c1024ae2b1bfffd9a5968edf0fb0a851019f1..f0ed4721aec6186a4f0021e35ae6dfce9d289158 100644
--- a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py
+++ b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py
@@ -219,6 +219,17 @@ def test_apt_custom_package_repository_is_available(host):
     assert host.package("td-agent").is_installed
 
 
+@pytest.mark.skipif(not _is_apt(), reason="Apt only supported on Ubuntu")
+def test_apt_auth(host):
+    apt_auth = host.file("/etc/apt/auth.conf.d/test.conf")
+    assert apt_auth.exists
+    with host.sudo():
+        auth_lines = apt_auth.content_string.splitlines()
+    assert "machine https://apt.example.com" in auth_lines
+    assert "login foo" in auth_lines
+    assert "password bar" in auth_lines
+
+
 @pytest.mark.parametrize('repo', ["appstream", "baseos", "extras", "epel"])
 @pytest.mark.skipif(not _is_dnf_mirror(), reason="DNF OpenDev mirror only for CentOS 8")
 def test_dnf_local_package_mirrors(host, repo):
diff --git a/releasenotes/notes/apt-auth-97d0291600836dec.yaml b/releasenotes/notes/apt-auth-97d0291600836dec.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..6da3d2e3915765c7b0cf1758b1fed8ef01b72baa
--- /dev/null
+++ b/releasenotes/notes/apt-auth-97d0291600836dec.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Adds support for auth configuration for Apt respositories and proxies using
+    ``auth.conf`` files.