diff --git a/README-cert.md b/README-cert.md
new file mode 100644
index 0000000000000000000000000000000000000000..ad2ee75c197fb840e775aaacfaf61833b98c2840
--- /dev/null
+++ b/README-cert.md
@@ -0,0 +1,175 @@
+Cert module
+============
+
+Description
+-----------
+
+The cert module makes it possible to request, revoke and retrieve SSL certificates for hosts, services and users.
+
+Features
+--------
+
+* Certificate request
+* Certificate hold/release
+* Certificate revocation
+* Certificate retrieval
+
+
+Supported FreeIPA Versions
+--------------------------
+
+FreeIPA versions 4.4.0 and up are supported by the ipacert module.
+
+
+Requirements
+------------
+
+**Controller**
+* Ansible version: 2.8+
+* Some tool to generate a certificate signing request (CSR) might be needed, like `openssl`.
+
+**Node**
+* Supported FreeIPA version (see above)
+
+
+Usage
+=====
+
+Example inventory file
+
+```ini
+[ipaserver]
+ipaserver.test.local
+```
+
+Example playbook to request a new certificate for a service:
+
+```yaml
+---
+- name: Certificate request
+  hosts: ipaserver
+
+  tasks:
+  - name: Request a certificate for a web server
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      state: requested
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs
+        HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA
+        5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU
+        SYaXm/gF8cDYjQI=
+        -----END CERTIFICATE REQUEST-----
+      principal: HTTP/www.example.com
+    register: cert
+```
+
+Example playbook to revoke an existing certificate:
+
+```yaml
+---
+- name: Revoke certificate
+  hosts: ipaserver
+
+  tasks:
+  - name Revoke a certificate
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      serial_number: 123456789
+      state: revoked
+```
+
+Example to hold a certificate (alias for revoking a certificate with reason `certificateHold (6)`):
+
+```yaml
+---
+- name: Hold a certificate
+  hosts: ipaserver
+
+  tasks:
+  - name: Hold certificate
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      serial_number: 0xAB1234
+      state: held
+```
+
+Example playbook to release hold of certificate (may be used with any revoked certificates, despite of the rovoke reason):
+
+```yaml
+---
+- name: Release hold
+  hosts: ipaserver
+
+  tasks:
+  - name: Take a revoked certificate off hold
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      serial_number: 0xAB1234
+      state: released
+```
+
+Example playbook to retrieve a certificate and save it to a file in the target node:
+
+```yaml
+---
+- name: Retriev certificate
+  hosts: ipaserver
+
+  tasks:
+  - name: Retrieve a certificate and save it to file 'cert.pem'
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      certificate_out: cert.pem
+      state: retrieved
+```
+
+
+ipacert
+-------
+
+Variable | Description | Required
+-------- | ----------- | --------
+`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
+`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
+`ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no
+`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to yes. (bool) | no
+`csr` | X509 certificate signing request, in PEM format. | yes, if `state: requested`
+`principal` | Host/service/user principal for the certificate. | yes, if `state: requested`
+`add` \| `add_principal` | Automatically add the principal if it doesn't exist (service principals only). (bool) | no
+`profile_id` \| `profile` | Certificate Profile to use | no
+`ca` | Name of the issuing certificate authority. | no
+`chain` | Include certificate chain in output. (bool) | no
+`serial_number` | Certificate serial number. (int) | yes, if `state` is `retrieved`, `held`, `released` or `revoked`.
+`revocation_reason` \| `reason` | Reason for revoking the certificate. Use one of the reason strings, or the corresponding value: "unspecified" (0), "keyCompromise" (1), "cACompromise" (2), "affiliationChanged" (3), "superseded" (4), "cessationOfOperation" (5), "certificateHold" (6), "removeFromCRL" (8), "privilegeWithdrawn" (9), "aACompromise" (10) | yes, if `state: revoked`
+`certificate_out` | Write certificate (chain if `chain` is set) to this file, on the target node. | no
+`state` | The state to ensure. It can be one of `requested`, `held`, `released`, `revoked`, or `retrieved`. `held` is the same as revoke with reason "certificateHold" (6). `released` is the same as `cert-revoke-hold` on IPA CLI, releasing the hold status of a certificate. | yes
+
+
+Return Values
+=============
+
+Values are returned only if `state` is `requested` or `retrieved` and if `certificate_out` is not defined.
+
+Variable | Description | Returned When
+-------- | ----------- | -------------
+`certificate` | Certificate fields and data. (dict) <br>Options: | if `state` is `requested` or `retrieved` and if `certificate_out` is not defined
+&nbsp; | `certificate` - Issued X509 certificate in PEM encoding. Will include certificate chain if `chain: true`. (list) | always 
+&nbsp; | `san_dnsname` - X509 Subject Alternative Name. | When DNSNames are present in the Subject Alternative Name extension of the issued certificate.
+&nbsp; | `issuer` - X509 distinguished name of issuer. | always
+&nbsp; | `subject` - X509 distinguished name of certificate subject. | always
+&nbsp; | `serial_number` - Serial number of the issued certificate. (int) | always
+&nbsp; | `revoked` - Revoked status of the certificate. (bool) | if certificate was revoked
+&nbsp; | `owner_user` - The username that owns the certificate. | if `state: retrieved` and certificate is owned by a user
+&nbsp; | `owner_host` - The host that owns the certificate. | if `state: retrieved` and certificate is owned by a host
+&nbsp; | `owner_service` - The service that owns the certificate. | if `state: retrieved` and certificate is owned by a service
+&nbsp; | `valid_not_before` - Time when issued certificate becomes valid, in GeneralizedTime format (YYYYMMDDHHMMSSZ) | always
+&nbsp; | `valid_not_after` - Time when issued certificate ceases to be valid, in GeneralizedTime format (YYYYMMDDHHMMSSZ) | always
+
+
+Authors
+=======
+
+Sam Morris
+Rafael Jeffman
diff --git a/README.md b/README.md
index 34d8e69d980d2ad45819733797bc81c1a22d4b89..6bc009ebe410a5b8d0ab201139d1925b9a478662 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Features
 * Modules for automount key management
 * Modules for automount location management
 * Modules for automount map management
+* Modules for certificate management
 * Modules for config management
 * Modules for delegation management
 * Modules for dns config management
@@ -436,6 +437,7 @@ Modules in plugin/modules
 * [ipaautomountkey](README-automountkey.md)
 * [ipaautomountlocation](README-automountlocation.md)
 * [ipaautomountmap](README-automountmap.md)
+* [ipacert](README-cert.md)
 * [ipaconfig](README-config.md)
 * [ipadelegation](README-delegation.md)
 * [ipadnsconfig](README-dnsconfig.md)
diff --git a/playbooks/cert/cert-hold.yml b/playbooks/cert/cert-hold.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bd9ab85ab9ce25e62ec635d0bfb0e5138814d9b4
--- /dev/null
+++ b/playbooks/cert/cert-hold.yml
@@ -0,0 +1,14 @@
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Temporarily hold a certificate
+    ipacert:
+      serial_number: 12345
+      state: held
diff --git a/playbooks/cert/cert-release.yml b/playbooks/cert/cert-release.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3dec4ca3029bc80ee8ee598e1d6467922e412eca
--- /dev/null
+++ b/playbooks/cert/cert-release.yml
@@ -0,0 +1,15 @@
+---
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Release a certificate hold
+    ipacert:
+      serial_number: 12345
+      state: released
diff --git a/playbooks/cert/cert-request-host.yml b/playbooks/cert/cert-request-host.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5f704cce70e0d6261c601b50220dd3982e96b667
--- /dev/null
+++ b/playbooks/cert/cert-request-host.yml
@@ -0,0 +1,26 @@
+---
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Request a certificate for a host
+    ipacert:
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        MIIBWjCBxAIBADAbMRkwFwYDVQQDDBBob3N0LmV4YW1wbGUuY29tMIGfMA0GCSqG
+        SIb3DQEBAQUAA4GNADCBiQKBgQCzR3Vd4Cwl0uVgwB3+wxz+4JldFk3x526bPeuK
+        g8EEc+rEHILzJWeXC8ywCYPOgK9n7hrdMfVQiIx3yHYrY+0IYuLehWow4o1iJEf5
+        urPNAP9K9C4Y7MMXzzoQmoWR3IFQQpOYwvWOtiZfvrhmtflnYEGLE2tgz53gOQHD
+        NnbCCwIDAQABoAAwDQYJKoZIhvcNAQELBQADgYEAgF+6YC39WhnvmFgNz7pjAh5E
+        2ea3CgG+zrzAyiSBGG6WpXEjqMRnAQxciQNGxQacxjwWrscZidZzqg8URJPugewq
+        tslYB1+RkZn+9UWtfnWvz89+xnOgco7JlytnbH10Nfxt5fXXx13rY0tl54jBtk2W
+        422eYZ12wb4gjNcQy3A=
+        -----END CERTIFICATE REQUEST-----
+      principal: host/host.example.com
+      state: requested
diff --git a/playbooks/cert/cert-request-service.yml b/playbooks/cert/cert-request-service.yml
new file mode 100644
index 0000000000000000000000000000000000000000..340f7bb1d176564b78bdb635b37c74aeb589fe24
--- /dev/null
+++ b/playbooks/cert/cert-request-service.yml
@@ -0,0 +1,23 @@
+---
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Request a certificate for a service
+    ipacert:
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs
+        HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA
+        5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU
+        SYaXm/gF8cDYjQI=
+        -----END CERTIFICATE REQUEST-----
+      principal: HTTP/www.example.com
+      add: true
+      state: requested
diff --git a/playbooks/cert/cert-request-user.yml b/playbooks/cert/cert-request-user.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5d99d9f9bc9092a1ab028058252a4965186767ed
--- /dev/null
+++ b/playbooks/cert/cert-request-user.yml
@@ -0,0 +1,27 @@
+---
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Request a certificate for a user with a specific profile
+    ipacert:
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        MIIBejCB5AIBADAQMQ4wDAYDVQQDDAVwaW5reTCBnzANBgkqhkiG9w0BAQEFAAOB
+        jQAwgYkCgYEA7uChccy1Is1FTM0SF23WPYW472E3ozeLh2kzhKR9Ni6FLmeEGgu7
+        /hicR1VwvXHYkNwI1tpW9LqxRVvgr6vheqHySljrBcoRfshfYvKejp03l2327Bfq
+        BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr
+        MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq
+        hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j
+        AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx
+        5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w==
+        -----END CERTIFICATE REQUEST-----
+      principal: pinky
+      profile: IECUserRoles
+      state: requested
diff --git a/playbooks/cert/cert-retrieve.yml b/playbooks/cert/cert-retrieve.yml
new file mode 100644
index 0000000000000000000000000000000000000000..62a4c5effb36342360350a2a9e35e30766c205e3
--- /dev/null
+++ b/playbooks/cert/cert-retrieve.yml
@@ -0,0 +1,16 @@
+---
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Retrieve a certificate
+    ipacert:
+      serial_number: 12345
+      state: retrieved
+    register: cert_retrieved
diff --git a/playbooks/cert/cert-revoke.yml b/playbooks/cert/cert-revoke.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7bdb2df9984bd049a100bd8e5195068d30c19640
--- /dev/null
+++ b/playbooks/cert/cert-revoke.yml
@@ -0,0 +1,18 @@
+---
+- name: Certificate manage example
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: client
+
+  tasks:
+  - name: Permanently revoke a certificate issued by a lightweight sub-CA
+    ipacert:
+      serial_number: 12345
+      ca: vpn-ca
+      # reason: keyCompromise (1)
+      reason: 1
+      state: revoked
diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py
index c5f8d7f287f5aa32ba0c994eb831d1f2a9dfe6f4..6bae7cc3bf923ab3e5808a66e9f207d571e994f5 100644
--- a/plugins/module_utils/ansible_freeipa_module.py
+++ b/plugins/module_utils/ansible_freeipa_module.py
@@ -29,7 +29,8 @@ __all__ = ["gssapi", "netaddr", "api", "ipalib_errors", "Env",
            "DEFAULT_CONFIG", "LDAP_GENERALIZED_TIME_FORMAT",
            "kinit_password", "kinit_keytab", "run", "DN", "VERSION",
            "paths", "tasks", "get_credentials_if_valid", "Encoding",
-           "load_pem_x509_certificate", "DNSName", "getargspec"]
+           "DNSName", "getargspec", "certificate_loader",
+           "write_certificate_list"]
 
 import os
 # ansible-freeipa requires locale to be C, IPA requires utf-8.
@@ -106,6 +107,7 @@ try:
     except ImportError:
         from ipalib.x509 import load_certificate
         certificate_loader = load_certificate
+    from ipalib.x509 import write_certificate_list
 
     # Try to import is_ipa_configured or use a fallback implementation.
     try:
diff --git a/plugins/modules/ipacert.py b/plugins/modules/ipacert.py
new file mode 100644
index 0000000000000000000000000000000000000000..c88d4d1e476822ef6ad1b11f5013fe352cf55226
--- /dev/null
+++ b/plugins/modules/ipacert.py
@@ -0,0 +1,571 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Authors:
+#   Sam Morris <sam@robots.org.uk>
+#   Rafael Guterres Jeffman <rjeffman@redhat.com>
+#
+# Copyright (C) 2021 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+ANSIBLE_METADATA = {
+    "metadata_version": "1.0",
+    "supported_by": "community",
+    "status": ["preview"],
+}
+
+DOCUMENTATION = """
+---
+module: ipacert
+short description: Manage FreeIPA certificates
+description: Manage FreeIPA certificates
+extends_documentation_fragment:
+  - ipamodule_base_docs
+options:
+  csr:
+    description: |
+      X509 certificate signing request, in RFC 7468 PEM encoding.
+      Only available if `state: requested`, required if `csr_file` is not
+      provided.
+    type: str
+  csr_file:
+    description: |
+      Path to file with X509 certificate signing request, in RFC 7468 PEM
+      encoding. Only available if `state: requested`, required if `csr_file`
+      is not provided.
+    type: str
+  principal:
+    description: |
+      Host/service/user principal for the certificate.
+      Required if `state: requested`. Only available if `state: requested`.
+    type: str
+  add:
+    description: |
+      Automatically add the principal if it doesn't exist (service
+      principals only). Only available if `state: requested`.
+    type: bool
+    aliases: ["add_principal"]
+    required: false
+  ca:
+    description: Name of the issuing certificate authority.
+    type: str
+    required: false
+  serial_number:
+    description: |
+      Certificate serial number. Cannot be used with `state: requested`.
+      Required for all states, except `requested`.
+    type: int
+  profile:
+    description: Certificate Profile to use.
+    type: str
+    aliases: ["profile_id"]
+    required: false
+  revocation_reason:
+    description: |
+      Reason for revoking the certificate. Use one of the reason strings,
+      or the corresponding value: "unspecified" (0), "keyCompromise" (1),
+      "cACompromise" (2), "affiliationChanged" (3), "superseded" (4),
+      "cessationOfOperation" (5), "certificateHold" (6), "removeFromCRL" (8),
+      "privilegeWithdrawn" (9), "aACompromise" (10).
+      Use only if `state: revoked`. Required if `state: revoked`.
+    type: raw
+    aliases: ['reason']
+  certificate_out:
+    description: |
+      Write certificate (chain if `chain` is set) to this file, on the target
+      node.. Use only when `state` is `requested` or `retrieved`.
+    type: str
+    required: false
+  state:
+    description: |
+      The state to ensure. `held` is the same as revoke with reason
+      "certificateHold" (6). `released` is the same as `cert-revoke-hold`
+      on IPA CLI, releasing the hold status of a certificate.
+    choices: ["requested", "held", "released", "revoked", "retrieved"]
+    required: true
+    type: str
+author:
+authors:
+  - Sam Morris (@yrro)
+  - Rafael Guterres Jeffman (@rjeffman)
+"""
+
+EXAMPLES = """
+- name: Request a certificate for a web server
+  ipacert:
+    ipaadmin_password: SomeADMINpassword
+    state: requested
+    csr: |
+      -----BEGIN CERTIFICATE REQUEST-----
+      MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs
+      HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA
+      5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU
+      SYaXm/gF8cDYjQI=
+      -----END CERTIFICATE REQUEST-----
+    principal: HTTP/www.example.com
+  register: cert
+
+- name: Request certificate for a user, with an appropriate profile.
+  ipacert:
+    ipaadmin_password: SomeADMINpassword
+    csr: |
+      -----BEGIN CERTIFICATE REQUEST-----
+      MIIBejCB5AIBADAQMQ4wDAYDVQQDDAVwaW5reTCBnzANBgkqhkiG9w0BAQEFAAOB
+      jQAwgYkCgYEA7uChccy1Is1FTM0SF23WPYW472E3ozeLh2kzhKR9Ni6FLmeEGgu7
+      /hicR1VwvXHYkNwI1tpW9LqxRVvgr6vheqHySljrBcoRfshfYvKejp03l2327Bfq
+      BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr
+      MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq
+      hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j
+      AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx
+      5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w==
+      -----END CERTIFICATE REQUEST-----
+    principal: pinky
+    profile_id: IECUserRoles
+    state: requested
+
+- name: Temporarily hold a certificate
+  ipacert:
+    ipaadmin_password: SomeADMINpassword
+    serial_number: 12345
+    state: held
+
+- name: Remove a certificate hold
+  ipacert:
+    ipaadmin_password: SomeADMINpassword
+    state: released
+    serial_number: 12345
+
+- name: Permanently revoke a certificate issued by a lightweight sub-CA
+  ipacert:
+    ipaadmin_password: SomeADMINpassword
+    state: revoked
+    ca: vpn-ca
+    serial_number: 0x98765
+    reason: keyCompromise
+
+- name: Retrieve a certificate
+  ipacert:
+    ipaadmin_password: SomeADMINpassword
+    serial_number: 12345
+    state: retrieved
+  register: cert_retrieved
+"""
+
+RETURN = """
+certificate:
+  description: Certificate fields and data.
+  returned: |
+    if `state` is `requested` or `retrived` and `certificate_out`
+    is not defined.
+  type: dict
+  contains:
+    certificate:
+      description: |
+        Issued X509 certificate in PEM encoding. Will include certificate
+        chain if `chain: true` is used.
+      type: list
+      elements: str
+      returned: always
+    issuer:
+      description: X509 distinguished name of issuer.
+      type: str
+      sample: CN=Certificate Authority,O=EXAMPLE.COM
+      returned: always
+    serial_number:
+      description: Serial number of the issued certificate.
+      type: int
+      sample: 902156300
+      returned: always
+    valid_not_after:
+      description: |
+        Time when issued certificate ceases to be valid,
+        in GeneralizedTime format (YYYYMMDDHHMMSSZ).
+      type: str
+      returned: always
+    valid_not_before:
+      description: |
+        Time when issued certificate becomes valid, in
+        GeneralizedTime format (YYYYMMDDHHMMSSZ).
+      type: str
+      returned: always
+    subject:
+      description: X509 distinguished name of certificate subject.
+      type: str
+      sample: CN=www.example.com,O=EXAMPLE.COM
+      returned: always
+    san_dnsname:
+      description: X509 Subject Alternative Name.
+      type: list
+      elements: str
+      sample: ['www.example.com', 'other.example.com']
+      returned: |
+        when DNSNames are present in the Subject Alternative Name
+        extension of the issued certificate.
+    revoked:
+      description: Revoked status of the certificate.
+      type: bool
+      returned: always
+    owner_user:
+      description: The username that owns the certificate.
+      type: str
+      returned: when `state` is `retrieved`
+    owner_host:
+      description: The host that owns the certificate.
+      type: str
+      returned: when `state` is `retrieved`
+    owner_service:
+      description: The service that owns the certificate.
+      type: str
+      returned: when `state` is `retrieved`
+"""
+
+import base64
+import time
+import ssl
+
+from ansible.module_utils import six
+from ansible.module_utils._text import to_text
+from ansible.module_utils.ansible_freeipa_module import (
+    IPAAnsibleModule, certificate_loader, write_certificate_list,
+)
+
+if six.PY3:
+    unicode = str
+
+# Reasons are defined in RFC 5280 sec. 5.3.1; removeFromCRL is not present in
+# this list; run the module with state=released instead.
+REVOCATION_REASONS = {
+    'unspecified': 0,
+    'keyCompromise': 1,
+    'cACompromise': 2,
+    'affiliationChanged': 3,
+    'superseded': 4,
+    'cessationOfOperation': 5,
+    'certificateHold': 6,
+    'removeFromCRL': 8,
+    'privilegeWithdrawn': 9,
+    'aACompromise': 10,
+}
+
+
+def gen_args(
+    module, principal=None, add_principal=None, ca=None, chain=None,
+    profile=None, certificate_out=None, reason=None
+):
+    args = {}
+    if principal is not None:
+        args['principal'] = principal
+    if add_principal is not None:
+        args['add'] = add_principal
+    if ca is not None:
+        args['cacn'] = ca
+    if profile is not None:
+        args['profile_id'] = profile
+    if certificate_out is not None:
+        args['out'] = certificate_out
+    if chain:
+        args['chain'] = True
+    if ca:
+        args['cacn'] = ca
+    if reason is not None:
+        args['revocation_reason'] = get_revocation_reason(module, reason)
+    return args
+
+
+def get_revocation_reason(module, reason):
+    """Ensure revocation reasion is a valid integer code."""
+    reason_int = -1
+
+    try:
+        reason_int = int(reason)
+    except ValueError:
+        reason_int = REVOCATION_REASONS.get(reason, -1)
+
+    if reason_int not in REVOCATION_REASONS.values():
+        module.fail_json(msg="Invalid revocation reason: %s" % reason)
+
+    return reason_int
+
+
+def parse_cert_timestamp(dt):
+    """Ensure time is in GeneralizedTime format (YYYYMMDDHHMMSSZ)."""
+    return time.strftime(
+        "%Y%m%d%H%M%SZ",
+        time.strptime(dt, "%a %b %d %H:%M:%S %Y UTC")
+    )
+
+
+def result_handler(_module, result, _command, _name, _args, exit_args, chain):
+    """Split certificate into fields."""
+    if chain:
+        exit_args['certificate'] = [
+            ssl.DER_cert_to_PEM_cert(c)
+            for c in result['result'].get('certificate_chain', [])
+        ]
+    else:
+        exit_args['certificate'] = [
+            ssl.DER_cert_to_PEM_cert(
+                base64.b64decode(result['result']['certificate'])
+            )
+        ]
+
+    exit_args['san_dnsname'] = [
+        str(dnsname)
+        for dnsname in result['result'].get('san_dnsname', [])
+    ]
+
+    exit_args.update({
+        key: result['result'][key]
+        for key in [
+            'issuer', 'subject', 'serial_number',
+            'revoked', 'revocation_reason'
+        ]
+        if key in result['result']
+    })
+    exit_args.update({
+        key: result['result'][key][0]
+        for key in ['owner_user', 'owner_host', 'owner_service']
+        if key in result['result']
+    })
+
+    exit_args.update({
+        key: parse_cert_timestamp(result['result'][key])
+        for key in ['valid_not_after', 'valid_not_before']
+        if key in result['result']
+    })
+
+
+def do_cert_request(
+    module, csr, principal, add_principal=None, ca=None, profile=None,
+    chain=None, certificate_out=None
+):
+    """Request a certificate."""
+    args = gen_args(
+        module, principal=principal, ca=ca, chain=chain,
+        add_principal=add_principal, profile=profile,
+    )
+    exit_args = {}
+    commands = [[to_text(csr), "cert_request", args]]
+    changed = module.execute_ipa_commands(
+        commands,
+        result_handler=result_handler,
+        exit_args=exit_args,
+        chain=chain
+    )
+
+    if certificate_out is not None:
+        certs = (
+            certificate_loader(cert.encode("utf-8"))
+            for cert in exit_args['certificate']
+        )
+        write_certificate_list(certs, certificate_out)
+        exit_args = {}
+
+    return changed, exit_args
+
+
+def do_cert_revoke(ansible_module, serial_number, reason=None, ca=None):
+    """Revoke a certificate."""
+    _ign, cert = do_cert_retrieve(ansible_module, serial_number, ca)
+    if not cert or cert.get('revoked', False):
+        return False, cert
+
+    args = gen_args(ansible_module, ca=ca, reason=reason)
+
+    commands = [[serial_number, "cert_revoke", args]]
+    changed = ansible_module.execute_ipa_commands(commands)
+
+    return changed, cert
+
+
+def do_cert_release(ansible_module, serial_number, ca=None):
+    """Release hold on certificate."""
+    _ign, cert = do_cert_retrieve(ansible_module, serial_number, ca)
+    revoked = cert.get('revoked', True)
+    reason = cert.get('revocation_reason', -1)
+    if cert and not revoked:
+        return False, cert
+
+    if revoked and reason != 6:  # can only release held certificates
+        ansible_module.fail_json(
+            msg="Cannot release hold on certificate revoked with"
+                " reason: %d" % reason
+        )
+    args = gen_args(ansible_module, ca=ca)
+
+    commands = [[serial_number, "cert_remove_hold", args]]
+    changed = ansible_module.execute_ipa_commands(commands)
+
+    return changed, cert
+
+
+def do_cert_retrieve(
+    module, serial_number, ca=None, chain=None, outfile=None
+):
+    """Retrieve a certificate with 'cert-show'."""
+    args = gen_args(module, ca=ca, chain=chain, certificate_out=outfile)
+    exit_args = {}
+    commands = [[serial_number, "cert_show", args]]
+    module.execute_ipa_commands(
+        commands,
+        result_handler=result_handler,
+        exit_args=exit_args,
+        chain=chain,
+    )
+    if outfile is not None:
+        exit_args = {}
+
+    return False, exit_args
+
+
+def main():
+    ansible_module = IPAAnsibleModule(
+        argument_spec=dict(
+            # requested
+            csr=dict(type="str"),
+            csr_file=dict(type="str"),
+            principal=dict(type="str"),
+            add_principal=dict(type="bool", required=False, aliases=["add"]),
+            profile_id=dict(type="str", aliases=["profile"], required=False),
+            # revoked
+            revocation_reason=dict(type="raw", aliases=["reason"]),
+            # general
+            serial_number=dict(type="int"),
+            ca=dict(type="str"),
+            chain=dict(type="bool", required=False),
+            certificate_out=dict(type="str", required=False),
+            # state
+            state=dict(
+                type="str",
+                required=True,
+                choices=[
+                    "requested", "held", "released", "revoked", "retrieved"
+                ]
+            ),
+        ),
+        mutually_exclusive=[["csr", "csr_file"]],
+        required_if=[
+            ('state', 'requested', ['principal']),
+            ('state', 'retrieved', ['serial_number']),
+            ('state', 'held', ['serial_number']),
+            ('state', 'released', ['serial_number']),
+            ('state', 'revoked', ['serial_number', 'revocation_reason']),
+        ],
+        supports_check_mode=False,
+    )
+
+    ansible_module._ansible_debug = True
+
+    # Get parameters
+
+    # requested
+    csr = ansible_module.params_get("csr")
+    csr_file = ansible_module.params_get("csr_file")
+    principal = ansible_module.params_get("principal")
+    add_principal = ansible_module.params_get("add_principal")
+    profile = ansible_module.params_get("profile_id")
+
+    # revoked
+    reason = ansible_module.params_get("revocation_reason")
+
+    # general
+    serial_number = ansible_module.params.get("serial_number")
+    ca = ansible_module.params_get("ca")
+    chain = ansible_module.params_get("chain")
+    certificate_out = ansible_module.params_get("certificate_out")
+
+    # state
+    state = ansible_module.params_get("state")
+
+    # Check parameters
+
+    if ansible_module.params_get("ipaapi_context") == "server":
+        ansible_module.fail_json(
+            msg="Context 'server' for ipacert is not yet supported."
+        )
+
+    invalid = []
+    if state == "requested":
+        invalid = ["serial_number", "revocation_reason"]
+        if csr is None and csr_file is None:
+            ansible_module.fail_json(
+                msg="Required 'csr' or 'csr_file' with 'state: requested'.")
+    else:
+        invalid = [
+            "csr", "principal", "add_principal", "profile"
+            "certificate_out"
+        ]
+        if state in ["released", "held"]:
+            invalid.extend(["revocation_reason", "certificate_out", "chain"])
+        if state == "retrieved":
+            invalid.append("revocation_reason")
+        if state == "revoked":
+            invalid.extend(["certificate_out", "chain"])
+        elif state == "held":
+            reason = 6  # certificateHold
+
+    ansible_module.params_fail_used_invalid(invalid, state)
+
+    # Init
+
+    changed = False
+    exit_args = {}
+
+    # Connect to IPA API
+    # If executed on 'server' contexot, cert plugin uses the IPA RA agent
+    # TLS client certificate/key, which users are not able to access,
+    # resulting in a 'permission denied' exception when attempting to connect
+    # the CA service. Therefore 'client' context in forced for this module.
+    with ansible_module.ipa_connect(context="client"):
+
+        if state == "requested":
+            if csr_file is not None:
+                with open(csr_file, "rt") as csr_in:
+                    csr = "".join(csr_in.readlines())
+            changed, exit_args = do_cert_request(
+                ansible_module,
+                csr,
+                principal,
+                add_principal,
+                ca,
+                profile,
+                chain,
+                certificate_out
+            )
+
+        elif state in ("held", "revoked"):
+            changed, exit_args = do_cert_revoke(
+                ansible_module, serial_number, reason, ca)
+
+        elif state == "released":
+            changed, exit_args = do_cert_release(
+                ansible_module, serial_number, ca)
+
+        elif state == "retrieved":
+            changed, exit_args = do_cert_retrieve(
+                ansible_module, serial_number, ca, chain, certificate_out)
+
+    # Done
+
+    ansible_module.exit_json(changed=changed, certificate=exit_args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/setup.cfg b/setup.cfg
index a199be564b12fd95627f85a44f5f55df09851993..4fe542423e7b8cc0c5e297d4f4a54cbd4218ba87 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -60,6 +60,7 @@ disable =
 [pylint.BASIC]
 good-names =
     ex, i, j, k, Run, _, e, x, dn, cn, ip, os, unicode, __metaclass__, ds,
+    dt, ca,
     # These are utils tools, and not part of the released collection.
     galaxyfy-playbook, galaxyfy-README, galaxyfy-module-EXAMPLES,
     module_EXAMPLES
diff --git a/tests/cert/test_cert_client_context.yml b/tests/cert/test_cert_client_context.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aceedea185414404fe95bf7cb43e5de26229668a
--- /dev/null
+++ b/tests/cert/test_cert_client_context.yml
@@ -0,0 +1,60 @@
+---
+- name: Test cert
+  hosts: ipaclients, ipaserver
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_contetx: "{{ ipa_context | default(omit) }}"
+
+  tasks:
+  - name: Include FreeIPA facts.
+    ansible.builtin.include_tasks: ../env_freeipa_facts.yml
+
+  # Test will only be executed if host is not a server.
+  - name: Execute with server context in the client.
+    ipacert:
+      ipaapi_context: server
+      name: ThisShouldNotWork
+    register: result
+    failed_when: not (result.failed and result.msg is regex("No module named '*ipaserver'*"))
+    when: ipa_host_is_client
+
+# Import basic module tests, and execute with ipa_context set to 'client'.
+# If ipaclients is set, it will be executed using the client, if not,
+# ipaserver will be used.
+#
+# With this setup, tests can be executed against an IPA client, against
+# an IPA server using "client" context, and ensure that tests are executed
+# in upstream CI.
+
+- name: Test host certs using client context, in client host.
+  ansible.builtin.import_playbook: test_cert_host.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test service certs using client context, in client host.
+  ansible.builtin.import_playbook: test_cert_service.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test user certs using client context, in client host.
+  ansible.builtin.import_playbook: test_cert_user.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test host certs using client context, in server host.
+  ansible.builtin.import_playbook: test_cert_host.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
+
+- name: Test service certs using client context, in server host.
+  ansible.builtin.import_playbook: test_cert_service.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
+
+- name: Test user certs using client context, in server host.
+  ansible.builtin.import_playbook: test_cert_user.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
diff --git a/tests/cert/test_cert_host.yml b/tests/cert/test_cert_host.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c57c6e13e177aff1c1eb39cf224f307491ee886e
--- /dev/null
+++ b/tests/cert/test_cert_host.yml
@@ -0,0 +1,214 @@
+---
+- name: Test host certificate requests
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      # ipacert only supports client context
+      ipaapi_context: "client"
+
+  tasks:
+
+  # SETUP
+
+  - name: Ensure test files do not exist
+    ansible.builtin.file:
+      path: "{{ item }}"
+      state: absent
+    with_items:
+      - "/root/retrieved.pem"
+      - "/root/cert_1.pem"
+      - "/root/host.csr"
+
+  # Ensure test items exist
+
+  - name: Ensure domain name is set
+    ansible.builtin.set_fact:
+      ipa_domain: ipa.test
+    when: ipa_domain is not defined
+
+  - name: Ensure test host exists
+    ipahost:
+      name: "certhost.{{ ipa_domain }}"
+      state: present
+      force: true
+
+  - name: Create CSR
+    ansible.builtin.shell:
+      cmd: "openssl req -newkey rsa:1024 -keyout /dev/null -nodes -subj /CN=certhost.{{ ipa_domain }}"
+    register: host_req
+
+  - name: Create CSR file
+    ansible.builtin.copy:
+      dest: "/root/host.csr"
+      content: "{{ host_req.stdout }}"
+      mode: 0644
+
+  # TESTS
+
+  - name: Request certificate for host
+    ipacert:
+      csr: '{{ host_req.stdout }}'
+      principal: "host/certhost.{{ ipa_domain }}"
+      state: requested
+    register: host_cert
+    failed_when: not host_cert.changed or host_cert.failed
+
+  - name: Display data from the requested certificate.
+    ansible.builtin.debug:
+      var: host_cert
+
+  - name: Retrieve certificate for host
+    ipacert:
+      serial_number: "{{ host_cert.certificate.serial_number }}"
+      state: retrieved
+    register: retrieved
+    failed_when: retrieved.certificate.serial_number != host_cert.certificate.serial_number
+
+  - name: Display data from the retrieved certificate.
+    ansible.builtin.debug:
+      var: retrieved
+
+  - name: Place certificate on hold
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: held
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Place certificate on hold, again
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: held
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Release hold on certificate
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Release hold on certificate, again
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Revoke certificate
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: revoked
+      reason: keyCompromise
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Revoke certificate, again
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: revoked
+      reason: keyCompromise
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Try to revoke inexistent certificate
+    ipacert:
+      serial_number: 0x123456789
+      reason: 9
+      state: revoked
+    register: result
+    failed_when: not (result.failed and ("Request failed with status 404" in result.msg or "Certificate serial number 0x123456789 not found" in result.msg))
+
+  - name: Try to release revoked certificate
+    ipacert:
+      serial_number: '{{ host_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: not result.failed or "Cannot release hold on certificate revoked with reason" not in result.msg
+
+  - name: Request certificate for host and save to file
+    ipacert:
+      csr: '{{ host_req.stdout }}'
+      principal: "host/certhost.{{ ipa_domain }}"
+      certificate_out: "/root/cert_1.pem"
+      state: requested
+    register: result
+    failed_when: not result.changed or result.failed or result.certificate
+
+  - name: Check requested certificate file
+    ansible.builtin.file:
+      path: "/root/cert_1.pem"
+    check_mode: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Retrieve certificate for host to a file
+    ipacert:
+      serial_number: "{{ host_cert.certificate.serial_number }}"
+      certificate_out: "/root/retrieved.pem"
+      state: retrieved
+    register: result
+    failed_when: result.changed or result.failed or result.certificate
+
+  - name: Check retrieved certificate file
+    ansible.builtin.file:
+      path: "/root/retrieved.pem"
+    check_mode: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Request with invalid CSR.
+    ipacert:
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr
+        MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq
+        hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j
+        AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx
+        5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w==
+        -----END CERTIFICATE REQUEST-----
+      principal: "host/certhost.{{ ipa_domain }}"
+      state: requested
+    register: result
+    failed_when: not (result.failed and "Failure decoding Certificate Signing Request" in result.msg)
+
+  - name: Request certificate using a file
+    ipacert:
+      csr_file: "/root/host.csr"
+      principal: "host/certhost.{{ ipa_domain }}"
+      state: requested
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Request certificate using an invalid profile
+    ipacert:
+      csr_file: "/root/host.csr"
+      principal: "host/certhost.{{ ipa_domain }}"
+      profile: invalid_profile
+      state: requested
+    register: result
+    failed_when: not (result.failed and "Request failed with status 400" in result.msg)
+
+
+  # CLEANUP TEST ITEMS
+
+  - name: Removet test host
+    ipahost:
+      name: "certhost.{{ ipa_domain }}"
+      state: absent
+
+  - name: Ensure test files do not exist
+    ansible.builtin.file:
+      path: "{{ item }}"
+      state: absent
+    with_items:
+      - "/root/retrieved.pem"
+      - "/root/cert_1.pem"
+      - "/root/host.csr"
diff --git a/tests/cert/test_cert_service.yml b/tests/cert/test_cert_service.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6e42ff4fa9c5256c03acb200cdc7e82aea9b272b
--- /dev/null
+++ b/tests/cert/test_cert_service.yml
@@ -0,0 +1,232 @@
+---
+- name: Test service certificate requests
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  # Change "become" or "gather_facts" to "yes",
+  # if you test playbook requires any.
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      # ipacert only supports client context
+      ipaapi_context: "client"
+
+  tasks:
+
+  # SETUP
+
+  - name: Ensure test files do not exist
+    ansible.builtin.file:
+      path: "{{ item }}"
+      state: absent
+    with_items:
+      - "/root/retrieved.pem"
+      - "/root/cert_1.pem"
+      - "/root/service.csr"
+
+  # Ensure test items exist
+
+  - name: Ensure domain name is set
+    ansible.builtin.set_fact:
+      ipa_domain: ipa.test
+    when: ipa_domain is not defined
+
+  - name: Ensure test host exist
+    ipahost:
+      name: "certservice.{{ ipa_domain }}"
+      force: true
+      state: present
+
+  - name: Ensure service exist
+    ipaservice:
+      name: "HTTP/certservice.{{ ipa_domain }}"
+      force: true
+      state: present
+
+  - name: Create signing request for certificate
+    ansible.builtin.shell:
+      cmd: "openssl req -newkey rsa:1024 -keyout /dev/null -nodes -subj /CN=certservice.{{ ipa_domain }}"
+    register: service_req
+
+  - name: Create CSR file
+    ansible.builtin.copy:
+      dest: "/root/service.csr"
+      content: "{{ service_req.stdout }}"
+      mode: '0644'
+
+  # TESTS
+
+  - name: Request certificate for service
+    ipacert:
+      csr: '{{ service_req.stdout }}'
+      principal: "HTTP/certservice.{{ ipa_domain }}"
+      add_principal: true
+      state: requested
+    register: service_cert
+    failed_when: not service_cert.changed or service_cert.failed
+
+  - name: Display data from the requested certificate.
+    ansible.builtin.debug:
+      var: service_cert
+
+  - name: Retrieve certificate for service
+    ipacert:
+      serial_number: "{{ service_cert.certificate.serial_number }}"
+      state: retrieved
+    register: retrieved
+    failed_when: retrieved.certificate.serial_number != service_cert.certificate.serial_number
+
+  - name: Display data from the retrieved certificate.
+    ansible.builtin.debug:
+      var: retrieved
+
+  - name: Place certificate on hold
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: held
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Place certificate on hold, again
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: held
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Release hold on certificate
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Release hold on certificate, again
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Revoke certificate
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: revoked
+      reason: keyCompromise
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Revoke certificate, again
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: revoked
+      reason: keyCompromise
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Try to revoke inexistent certificate
+    ipacert:
+      serial_number: 0x123456789
+      reason: 9
+      state: revoked
+    register: result
+    failed_when: not (result.failed and ("Request failed with status 404" in result.msg or "Certificate serial number 0x123456789 not found" in result.msg))
+
+  - name: Try to release revoked certificate
+    ipacert:
+      serial_number: '{{ service_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: not result.failed or "Cannot release hold on certificate revoked with reason" not in result.msg
+
+  - name: Request certificate for service and save to file
+    ipacert:
+      csr: '{{ service_req.stdout }}'
+      principal: "HTTP/certservice.{{ ipa_domain }}"
+      add_principal: true
+      certificate_out: "/root/cert_1.pem"
+      state: requested
+    register: result
+    failed_when: not result.changed or result.failed or result.certificate
+
+  - name: Check requested certificate file
+    ansible.builtin.file:
+      path: "/root/cert_1.pem"
+    check_mode: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Retrieve certificate for service to a file
+    ipacert:
+      serial_number: "{{ service_cert.certificate.serial_number }}"
+      certificate_out: "/root/retrieved.pem"
+      state: retrieved
+    register: result
+    failed_when: result.changed or result.failed or result.certificate
+
+  - name: Check retrieved certificate file
+    ansible.builtin.file:
+      path: "/root/retrieved.pem"
+    check_mode: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Request with invalid CSR.
+    ipacert:
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr
+        MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq
+        hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j
+        AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx
+        5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w==
+        -----END CERTIFICATE REQUEST-----
+      principal: "HTTP/certservice.{{ ipa_domain }}"
+      state: requested
+    register: result
+    failed_when: not (result.failed and "Failure decoding Certificate Signing Request" in result.msg)
+
+  - name: Request certificate using a file
+    ipacert:
+      csr_file: "/root/service.csr"
+      principal: "HTTP/certservice.{{ ipa_domain }}"
+      state: requested
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Request certificate using an invalid profile
+    ipacert:
+      csr_file: "/root/service.csr"
+      principal: "HTTP/certservice.{{ ipa_domain }}"
+      profile: invalid_profile
+      state: requested
+    register: result
+    failed_when: not (result.failed and "Request failed with status 400" in result.msg)
+
+  # CLEANUP TEST ITEMS
+
+  - name: Remove test service
+    ipaservice:
+      name: "HTTP/certservice.{{ ipa_domain }}"
+      state: absent
+      continue: true
+
+  - name: Remove test host
+    ipahost:
+      name: certservice.example.com
+      state: absent
+
+  - name: Ensure test files do not exist
+    ansible.builtin.file:
+      path: "{{ item }}"
+      state: absent
+    with_items:
+      - "/root/retrieved.pem"
+      - "/root/cert_1.pem"
+      - "/root/service.csr"
diff --git a/tests/cert/test_cert_user.yml b/tests/cert/test_cert_user.yml
new file mode 100644
index 0000000000000000000000000000000000000000..41c97bb3f2eb173aa9a7810887faae8cf2d28e30
--- /dev/null
+++ b/tests/cert/test_cert_user.yml
@@ -0,0 +1,213 @@
+---
+- name: Test user certificate requests
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipauser:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+    ipacert:
+      ipaadmin_password: SomeADMINpassword
+      # ipacert only supports client context
+      ipaapi_context: "client"
+
+  tasks:
+
+  # Ensure test files do not exist
+
+  - name: Check retrieved certificate file
+    ansible.builtin.file:
+      path: "{{ item }}"
+      state: absent
+    with_items:
+      - "/root/retrieved.pem"
+      - "/root/cert_1.pem"
+      - "/root/user.csr"
+
+  # Ensure test items exist.
+
+  - name: Ensure test user exists
+    ipauser:
+      name: certuser
+      first: certificate
+      last: user
+
+  - name: Crete CSR
+    ansible.builtin.shell:
+      cmd:
+        'openssl req -newkey rsa:1024 -keyout /dev/null -nodes -subj /CN=certuser -reqexts IECUserRoles
+          -config <(cat /etc/pki/tls/openssl.cnf; printf "[IECUserRoles]\n1.2.840.10070.8.1=ASN1:UTF8String:hello world")'
+      executable: /bin/bash
+    register: user_req
+
+  - name: Create CSR file
+    ansible.builtin.copy:
+      dest: "/root/user.csr"
+      content: "{{ user_req.stdout }}"
+      mode: 0644
+
+  # TESTS
+
+  - name: Request certificate for user
+    ipacert:
+      csr: '{{ user_req.stdout }}'
+      principal: certuser
+      profile: IECUserRoles
+      state: requested
+    register: user_cert
+    failed_when: not user_cert.changed or user_cert.failed
+
+  - name: Display data from the requested certificate.
+    ansible.builtin.debug:
+      var: user_cert
+
+  - name: Retrieve certificate for user
+    ipacert:
+      serial_number: "{{ user_cert.certificate.serial_number }}"
+      state: retrieved
+    register: retrieved
+    failed_when: retrieved.certificate.serial_number != user_cert.certificate.serial_number
+
+  - name: Display data from the retrieved certificate.
+    ansible.builtin.debug:
+      var: retrieved
+
+  - name: Place certificate on hold
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: held
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Place certificate on hold, again
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: held
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Release hold on certificate
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Release hold on certificate, again
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Revoke certificate
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: revoked
+      reason: keyCompromise
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Revoke certificate, again
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: revoked
+      reason: keyCompromise
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Try to revoke inexistent certificate
+    ipacert:
+      serial_number: 0x123456789
+      reason: 9
+      state: revoked
+    register: result
+    failed_when: not (result.failed and ("Request failed with status 404" in result.msg or "Certificate serial number 0x123456789 not found" in result.msg))
+
+  - name: Try to release revoked certificate
+    ipacert:
+      serial_number: '{{ user_cert.certificate.serial_number }}'
+      state: released
+    register: result
+    failed_when: not result.failed or "Cannot release hold on certificate revoked with reason" not in result.msg
+
+  - name: Request certificate for user and save to file
+    ipacert:
+      csr: '{{ user_req.stdout }}'
+      principal: certuser
+      profile: IECUserRoles
+      certificate_out: "/root/cert_1.pem"
+      state: requested
+    register: result
+    failed_when: not result.changed or result.failed or result.certificate
+
+  - name: Check requested certificate file
+    ansible.builtin.file:
+      path: "/root/cert_1.pem"
+    check_mode: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Retrieve certificate for user to a file
+    ipacert:
+      serial_number: "{{ user_cert.certificate.serial_number }}"
+      certificate_out: "/root/retrieved.pem"
+      state: retrieved
+    register: result
+    failed_when: result.changed or result.failed or result.certificate
+
+  - name: Check retrieved certificate file
+    ansible.builtin.file:
+      path: "/root/retrieved.pem"
+    check_mode: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Request with invalid CSR.
+    ipacert:
+      csr: |
+        -----BEGIN CERTIFICATE REQUEST-----
+        BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr
+        MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq
+        hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j
+        AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx
+        5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w==
+        -----END CERTIFICATE REQUEST-----
+      principal: certuser
+      state: requested
+    register: result
+    failed_when: not (result.failed and "Failure decoding Certificate Signing Request" in result.msg)
+
+  - name: Request certificate using a file
+    ipacert:
+      csr_file: "/root/user.csr"
+      principal: certuser
+      state: requested
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Request certificate using an invalid profile
+    ipacert:
+      csr_file: "/root/user.csr"
+      principal: certuser
+      profile: invalid_profile
+      state: requested
+    register: result
+    failed_when: not (result.failed and "Request failed with status 400" in result.msg)
+
+  # CLEANUP TEST ITEMS
+
+  - name: Remove test user
+    ipauser:
+      name: certuser
+      state: absent
+
+  - name: Check retrieved certificate file
+    ansible.builtin.file:
+      path: "{{ item }}"
+      state: absent
+    with_items:
+      - "/root/retrieved.pem"
+      - "/root/cert_1.pem"
+      - "/root/user.csr"