Repository: authelia/authelia (go)

Issue:
Support for mTLS
Currently, the Authelia server config supports specifying a TLS certificate and key. Options to allow or require a client certificate, and to specify a CA certificate to verify the client certificate should be added. This enhances security by better identifying connecting clients or proxy servers.

Pull request:
feat: implement mutual tls in the web server
mutual tls prevent other clients than the proxies to communicate with Authelia. This reduces the attack
surface.

Fix #3041

Patch:
diff --git a/cmd/authelia-scripts/cmd_bootstrap.go b/cmd/authelia-scripts/cmd_bootstrap.go
index 28797e194..a7dbcb4db 100644
--- a/cmd/authelia-scripts/cmd_bootstrap.go
+++ b/cmd/authelia-scripts/cmd_bootstrap.go
@@ -19,6 +19,9 @@ type HostEntry struct {
 }
 
 var hostEntries = []HostEntry{
+	// For unit tests.
+	{Domain: "local.example.com", IP: "127.0.0.1"},
+
 	// For authelia backend.
 	{Domain: "authelia.example.com", IP: "192.168.240.50"},
 
diff --git a/config.template.yml b/config.template.yml
index 042aeecdd..db21d7af5 100644
--- a/config.template.yml
+++ b/config.template.yml
@@ -71,6 +71,9 @@ server:
     ## The path to the DER base64/PEM format public certificate.
     certificate: ""
 
+    ## The list of certificates for client authentication.
+    client_certificates: []
+
   ## Server headers configuration/customization.
   headers:
 
diff --git a/docs/configuration/server.md b/docs/configuration/server.md
index 5f3d28e27..3a705a425 100644
--- a/docs/configuration/server.md
+++ b/docs/configuration/server.md
@@ -24,6 +24,7 @@ server:
   tls:
     key: ""
     certificate: ""
+    client_certificates: []
   headers:
     csp_template: ""
 ```
@@ -213,6 +214,19 @@ required: situational
 
 The path to the public certificate for TLS connections. Must be in DER base64/PEM format.
 
+#### client_certificates
+<div markdown="1">
+type: list(string)
+{: .label .label-config .label-purple }
+default: []
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-yellow }
+</div>
+
+The list of file paths to certificates used for authenticating clients. Those certificates can be root
+or intermediate certificates. If no item is provided mutual TLS is disabled.
+
 
 ### headers
 
diff --git a/docs/security/measures.md b/docs/security/measures.md
index 556357f96..12bd1c566 100644
--- a/docs/security/measures.md
+++ b/docs/security/measures.md
@@ -189,6 +189,19 @@ Authelia protects your users against open redirect attacks by always checking if
 to a subdomain of the domain protected by Authelia. This prevents phishing campaigns tricking users into visiting
 infected websites leveraging legit links.
 
+## Mutual TLS
+
+For the best security protection, configuration with TLS is highly recommended. TLS is used to secure the connection between
+the proxies and Authelia instances meaning that an attacker on the network cannot perform a man-in-the-middle attack on those
+connections. However, an attacker on the network can still impersonate proxies but this can be prevented by configuring mutual
+TLS.
+Mutual TLS brings mutual authentication between Authelia and the proxies. Any other party attempting to contact Authelia
+would not even be able to create a TCP connection. This measure is recommended in all cases except if you already configured
+some kind of ACLs specifically allowing the communication between proxies and Authelia instances like in a service mesh or
+some kind of network overlay.
+
+To configure mutual TLS, please refer to [this document](../configuration/server.md#client_certificates)
+
 ## Additional security
 
 ### Reset Password
diff --git a/internal/commands/certificates.go b/internal/commands/certificates.go
index 6257aae61..b6f0bfc5b 100644
--- a/internal/commands/certificates.go
+++ b/internal/commands/certificates.go
@@ -1,22 +1,15 @@
 package commands
 
 import (
-	"crypto/ecdsa"
-	"crypto/ed25519"
 	"crypto/elliptic"
-	"crypto/rand"
-	"crypto/rsa"
-	"crypto/x509"
-	"crypto/x509/pkix"
-	"encoding/pem"
 	"fmt"
 	"log"
-	"math/big"
-	"net"
 	"os"
 	"path/filepath"
 	"time"
 
+	"github.com/authelia/authelia/v4/internal/utils"
+
 	"github.com/spf13/cobra"
 )
 
@@ -113,14 +106,13 @@ func cmdCertificatesGenerateRun(cmd *cobra.Command, _ []string) {
 }
 
 func cmdCertificatesGenerateRunExtended(hosts []string, ecdsaCurve, validFrom, certificateTargetDirectory string, ed25519Key, isCA bool, rsaBits int, validFor time.Duration) {
-	priv, err := getPrivateKey(ecdsaCurve, ed25519Key, rsaBits)
-
-	if err != nil {
-		fmt.Printf("Failed to generate private key: %v\n", err)
-		os.Exit(1)
-	}
+	certPath := filepath.Join(certificateTargetDirectory, "cert.pem")
+	keyPath := filepath.Join(certificateTargetDirectory, "key.pem")
 
-	var notBefore time.Time
+	var (
+		notBefore time.Time
+		err       error
+	)
 
 	switch len(validFrom) {
 	case 0:
@@ -128,122 +120,47 @@ func cmdCertificatesGenerateRunExtended(hosts []string, ecdsaCurve, validFrom, c
 	default:
 		notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom)
 		if err != nil {
-			fmt.Printf("Failed to parse start date: %v\n", err)
-			os.Exit(1)
+			log.Fatalf("Failed to parse start date: %v", err)
 		}
 	}
 
-	notAfter := notBefore.Add(validFor)
-
-	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
-
-	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
-	if err != nil {
-		fmt.Printf("Failed to generate serial number: %v\n", err)
-		os.Exit(1)
-	}
-
-	template := x509.Certificate{
-		SerialNumber: serialNumber,
-		Subject: pkix.Name{
-			Organization: []string{"Acme Co"},
-		},
-		NotBefore: notBefore,
-		NotAfter:  notAfter,
-
-		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
-		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
-		BasicConstraintsValid: true,
-	}
-
-	for _, h := range hosts {
-		if ip := net.ParseIP(h); ip != nil {
-			template.IPAddresses = append(template.IPAddresses, ip)
-		} else {
-			template.DNSNames = append(template.DNSNames, h)
-		}
-	}
-
-	if isCA {
-		template.IsCA = true
-		template.KeyUsage |= x509.KeyUsageCertSign
-	}
-
-	certPath := filepath.Join(certificateTargetDirectory, "cert.pem")
-
-	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv)
-	if err != nil {
-		fmt.Printf("Failed to create certificate: %v\n", err)
-		os.Exit(1)
-	}
-
-	writePEM(derBytes, "CERTIFICATE", certPath)
-
-	fmt.Printf("Certificate Public Key written to %s\n", certPath)
-
-	keyPath := filepath.Join(certificateTargetDirectory, "key.pem")
-
-	privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
-	if err != nil {
-		fmt.Printf("Failed to marshal private key: %v\n", err)
-		os.Exit(1)
-	}
-
-	writePEM(privBytes, "PRIVATE KEY", keyPath)
-
-	fmt.Printf("Certificate Private Key written to %s\n", keyPath)
-}
+	var privateKeyBuilder utils.PrivateKeyBuilder
 
-func getPrivateKey(ecdsaCurve string, ed25519Key bool, rsaBits int) (priv interface{}, err error) {
 	switch ecdsaCurve {
 	case "":
 		if ed25519Key {
-			_, priv, err = ed25519.GenerateKey(rand.Reader)
+			privateKeyBuilder = utils.Ed25519KeyBuilder{}
 		} else {
-			priv, err = rsa.GenerateKey(rand.Reader, rsaBits)
+			privateKeyBuilder = utils.RSAKeyBuilder{}.WithKeySize(rsaBits)
 		}
 	case "P224":
-		priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
+		privateKeyBuilder = utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P224())
 	case "P256":
-		priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		privateKeyBuilder = utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P256())
 	case "P384":
-		priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
+		privateKeyBuilder = utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P384())
 	case "P521":
-		priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
+		privateKeyBuilder = utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P521())
 	default:
-		err = fmt.Errorf("unrecognized elliptic curve: %q", ecdsaCurve)
+		log.Fatalf("Failed to generate private key: unrecognized elliptic curve: \"%s\"", ecdsaCurve)
 	}
 
-	return priv, err
-}
-
-func writePEM(bytes []byte, blockType, path string) {
-	keyOut, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+	certBytes, keyBytes, err := utils.GenerateCertificate(privateKeyBuilder, hosts, notBefore, validFor, isCA)
 	if err != nil {
-		fmt.Printf("Failed to open %s for writing: %v\n", path, err)
-		os.Exit(1)
+		log.Fatal(err)
 	}
 
-	if err := pem.Encode(keyOut, &pem.Block{Type: blockType, Bytes: bytes}); err != nil {
-		fmt.Printf("Failed to write data to %s: %v\n", path, err)
-		os.Exit(1)
+	err = os.WriteFile(certPath, certBytes, 0600)
+	if err != nil {
+		log.Fatalf("failed to write %s for writing: %v", certPath, err)
 	}
 
-	if err := keyOut.Close(); err != nil {
-		fmt.Printf("Error closing %s: %v\n", path, err)
-		os.Exit(1)
-	}
-}
+	fmt.Printf("Certificate written to %s\n", certPath)
 
-func publicKey(priv interface{}) interface{} {
-	switch k := priv.(type) {
-	case *rsa.PrivateKey:
-		return &k.PublicKey
-	case *ecdsa.PrivateKey:
-		return &k.PublicKey
-	case ed25519.PrivateKey:
-		return k.Public().(ed25519.PublicKey)
-	default:
-		return nil
+	err = os.WriteFile(keyPath, keyBytes, 0600)
+	if err != nil {
+		log.Fatalf("failed to write %s for writing: %v", certPath, err)
 	}
+
+	fmt.Printf("Private Key written to %s\n", keyPath)
 }
diff --git a/internal/commands/root.go b/internal/commands/root.go
index 38ce814df..d64e99c2c 100644
--- a/internal/commands/root.go
+++ b/internal/commands/root.go
@@ -77,7 +77,9 @@ func cmdRootRun(_ *cobra.Command, _ []string) {
 
 	doStartupChecks(config, &providers)
 
-	server.Start(*config, providers)
+	s, listener := server.CreateServer(*config, providers)
+
+	logger.Fatal(s.Serve(listener))
 }
 
 func doStartupChecks(config *schema.Configuration, providers *middlewares.Providers) {
diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml
index 042aeecdd..db21d7af5 100644
--- a/internal/configuration/config.template.yml
+++ b/internal/configuration/config.template.yml
@@ -71,6 +71,9 @@ server:
     ## The path to the DER base64/PEM format public certificate.
     certificate: ""
 
+    ## The list of certificates for client authentication.
+    client_certificates: []
+
   ## Server headers configuration/customization.
   headers:
 
diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go
index 528df6e54..77146a454 100644
--- a/internal/configuration/schema/server.go
+++ b/internal/configuration/schema/server.go
@@ -18,8 +18,9 @@ type ServerConfiguration struct {
 
 // ServerTLSConfiguration represents the configuration of the http servers TLS options.
 type ServerTLSConfiguration struct {
-	Certificate string `koanf:"certificate"`
-	Key         string `koanf:"key"`
+	Certificate        string   `koanf:"certificate"`
+	Key                string   `koanf:"key"`
+	ClientCertificates []string `koanf:"client_certificates"`
 }
 
 // ServerHeadersConfiguration represents the customization of the http server headers.
diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go
index 75a79486d..21a470609 100644
--- a/internal/configuration/validator/const.go
+++ b/internal/configuration/validator/const.go
@@ -44,8 +44,6 @@ const (
 	testLDAPURL       = "ldap://ldap"
 	testLDAPUser      = "user"
 	testModeDisabled  = "disable"
-	testTLSCert       = "/tmp/cert.pem"
-	testTLSKey        = "/tmp/key.pem"
 	testEncryptionKey = "a_not_so_secure_encryption_key"
 )
 
@@ -222,8 +220,12 @@ const (
 
 // Server Error constants.
 const (
-	errFmtServerTLSCert = "server: tls: option 'key' must also be accompanied by option 'certificate'"
-	errFmtServerTLSKey  = "server: tls: option 'certificate' must also be accompanied by option 'key'"
+	errFmtServerTLSCert                           = "server: tls: option 'key' must also be accompanied by option 'certificate'"
+	errFmtServerTLSKey                            = "server: tls: option 'certificate' must also be accompanied by option 'key'"
+	errFmtServerTLSCertFileDoesNotExist           = "server: tls: file path %s provided in 'certificate' does not exist"
+	errFmtServerTLSKeyFileDoesNotExist            = "server: tls: file path %s provided in 'key' does not exist"
+	errFmtServerTLSClientAuthCertFileDoesNotExist = "server: tls: client_certificates: certificates: file path %s does not exist"
+	errFmtServerTLSClientAuthNoAuth               = "server: tls: client authentication cannot be configured if no server certificate and key are provided"
 
 	errFmtServerPathNoForwardSlashes = "server: option 'path' must not contain any forward slashes"
 	errFmtServerPathAlphaNum         = "server: option 'path' must only contain alpha numeric characters"
diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go
index a958f0e13..06f26e051 100644
--- a/internal/configuration/validator/server.go
+++ b/internal/configuration/validator/server.go
@@ -9,6 +9,44 @@ import (
 	"github.com/authelia/authelia/v4/internal/utils"
 )
 
+// validateFileExists checks whether a file exist.
+func validateFileExists(path string, validator *schema.StructValidator, errTemplate string) {
+	exist, err := utils.FileExists(path)
+	if err != nil {
+		validator.Push(fmt.Errorf("tls: unable to check if file %s exists: %s", path, err))
+	}
+
+	if !exist {
+		validator.Push(fmt.Errorf(errTemplate, path))
+	}
+}
+
+// ValidateServerTLS checks a server TLS configuration is correct.
+func ValidateServerTLS(config *schema.Configuration, validator *schema.StructValidator) {
+	if config.Server.TLS.Key != "" && config.Server.TLS.Certificate == "" {
+		validator.Push(fmt.Errorf(errFmtServerTLSCert))
+	} else if config.Server.TLS.Key == "" && config.Server.TLS.Certificate != "" {
+		validator.Push(fmt.Errorf(errFmtServerTLSKey))
+	}
+
+	if config.Server.TLS.Key != "" {
+		validateFileExists(config.Server.TLS.Key, validator, errFmtServerTLSKeyFileDoesNotExist)
+	}
+
+	if config.Server.TLS.Certificate != "" {
+		validateFileExists(config.Server.TLS.Certificate, validator, errFmtServerTLSCertFileDoesNotExist)
+	}
+
+	if config.Server.TLS.Key == "" && config.Server.TLS.Certificate == "" &&
+		len(config.Server.TLS.ClientCertificates) > 0 {
+		validator.Push(fmt.Errorf(errFmtServerTLSClientAuthNoAuth))
+	}
+
+	for _, clientCertPath := range config.Server.TLS.ClientCertificates {
+		validateFileExists(clientCertPath, validator, errFmtServerTLSClientAuthCertFileDoesNotExist)
+	}
+}
+
 // ValidateServer checks a server configuration is correct.
 func ValidateServer(config *schema.Configuration, validator *schema.StructValidator) {
 	if config.Server.Host == "" {
@@ -19,11 +57,7 @@ func ValidateServer(config *schema.Configuration, validator *schema.StructValida
 		config.Server.Port = schema.DefaultServerConfiguration.Port
 	}
 
-	if config.Server.TLS.Key != "" && config.Server.TLS.Certificate == "" {
-		validator.Push(fmt.Errorf(errFmtServerTLSCert))
-	} else if config.Server.TLS.Key == "" && config.Server.TLS.Certificate != "" {
-		validator.Push(fmt.Errorf(errFmtServerTLSKey))
-	}
+	ValidateServerTLS(config, validator)
 
 	switch {
 	case strings.Contains(config.Server.Path, "/"):
diff --git a/internal/server/const.go b/internal/server/const.go
index 8fb605e23..a00d6b880 100644
--- a/internal/server/const.go
+++ b/internal/server/const.go
@@ -40,6 +40,9 @@ var (
 	}
 )
 
+const schemeHTTP = "http"
+const schemeHTTPS = "https"
+
 const (
 	dev = "dev"
 	f   = "false"
diff --git a/internal/server/server.go b/internal/server/server.go
index b52c87001..6bd13484c 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -1,11 +1,15 @@
 package server
 
 import (
+	"crypto/tls"
+	"crypto/x509"
 	"net"
 	"os"
 	"strconv"
 	"time"
 
+	"github.com/authelia/authelia/v4/internal/logging"
+
 	duoapi "github.com/duosecurity/duo_api_golang"
 	"github.com/fasthttp/router"
 	"github.com/valyala/fasthttp"
@@ -15,7 +19,6 @@ import (
 	"github.com/authelia/authelia/v4/internal/configuration/schema"
 	"github.com/authelia/authelia/v4/internal/duo"
 	"github.com/authelia/authelia/v4/internal/handlers"
-	"github.com/authelia/authelia/v4/internal/logging"
 	"github.com/authelia/authelia/v4/internal/middlewares"
 )
 
@@ -178,10 +181,8 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
 	return handler
 }
 
-// Start Authelia's internal webserver with the given configuration and providers.
-func Start(configuration schema.Configuration, providers middlewares.Providers) {
-	logger := logging.Logger()
-
+// CreateServer Create Authelia's internal webserver with the given configuration and providers.
+func CreateServer(configuration schema.Configuration, providers middlewares.Providers) (*fasthttp.Server, net.Listener) {
 	handler := registerRoutes(configuration, providers)
 
 	server := &fasthttp.Server{
@@ -191,36 +192,66 @@ func Start(configuration schema.Configuration, providers middlewares.Providers)
 		ReadBufferSize:        configuration.Server.ReadBufferSize,
 		WriteBufferSize:       configuration.Server.WriteBufferSize,
 	}
+	logger := logging.Logger()
 
 	address := net.JoinHostPort(configuration.Server.Host, strconv.Itoa(configuration.Server.Port))
 
-	listener, err := net.Listen("tcp", address)
-	if err != nil {
-		logger.Fatalf("Error initializing listener: %s", err)
-	}
+	var (
+		listener         net.Listener
+		err              error
+		connectionType   string
+		connectionScheme string
+	)
 
 	if configuration.Server.TLS.Certificate != "" && configuration.Server.TLS.Key != "" {
-		if err = writeHealthCheckEnv(configuration.Server.DisableHealthcheck, "https", configuration.Server.Host, configuration.Server.Path, configuration.Server.Port); err != nil {
-			logger.Fatalf("Could not configure healthcheck: %v", err)
+		connectionType, connectionScheme = "TLS", schemeHTTPS
+		err = server.AppendCert(configuration.Server.TLS.Certificate, configuration.Server.TLS.Key)
+
+		if err != nil {
+			logger.Fatalf("unable to load certificate: %v", err)
 		}
 
-		if configuration.Server.Path == "" {
-			logger.Infof("Listening for TLS connections on '%s' path '/'", address)
-		} else {
-			logger.Infof("Listening for TLS connections on '%s' paths '/' and '%s'", address, configuration.Server.Path)
+		if len(configuration.Server.TLS.ClientCertificates) > 0 {
+			caCertPool := x509.NewCertPool()
+
+			for _, path := range configuration.Server.TLS.ClientCertificates {
+				cert, err := os.ReadFile(path)
+				if err != nil {
+					logger.Fatalf("Cannot read client TLS certificate %s: %s", path, err)
+				}
+
+				caCertPool.AppendCertsFromPEM(cert)
+			}
+
+			// ClientCAs should never be nil, otherwise the system cert pool is used for client authentication
+			// but we don't want everybody on the Internet to be able to authenticate.
+			server.TLSConfig.ClientCAs = caCertPool
+			server.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
 		}
 
-		logger.Fatal(server.ServeTLS(listener, configuration.Server.TLS.Certificate, configuration.Server.TLS.Key))
+		listener, err = tls.Listen("tcp", address, server.TLSConfig.Clone())
+		if err != nil {
+			logger.Fatalf("Error initializing listener: %s", err)
+		}
 	} else {
-		if err = writeHealthCheckEnv(configuration.Server.DisableHealthcheck, "http", configuration.Server.Host, configuration.Server.Path, configuration.Server.Port); err != nil {
-			logger.Fatalf("Could not configure healthcheck: %v", err)
+		connectionType, connectionScheme = "non-TLS", schemeHTTP
+		listener, err = net.Listen("tcp", address)
+		if err != nil {
+			logger.Fatalf("Error initializing listener: %s", err)
 		}
+	}
 
-		if configuration.Server.Path == "" {
-			logger.Infof("Listening for non-TLS connections on '%s' path '/'", address)
-		} else {
-			logger.Infof("Listening for non-TLS connections on '%s' paths '/' and '%s'", address, configuration.Server.Path)
-		}
-		logger.Fatal(server.Serve(listener))
+	if err = writeHealthCheckEnv(configuration.Server.DisableHealthcheck, connectionScheme, configuration.Server.Host,
+		configuration.Server.Path, configuration.Server.Port); err != nil {
+		logger.Fatalf("Could not configure healthcheck: %v", err)
 	}
+
+	actualAddress := listener.Addr().String()
+	if configuration.Server.Path == "" {
+		logger.Infof("Initializing server for %s connections on '%s' path '/'", connectionType, actualAddress)
+	} else {
+		logger.Infof("Initializing server for %s connections on '%s' paths '/' and '%s'", connectionType, actualAddress, configuration.Server.Path)
+	}
+
+	return server, listener
 }
diff --git a/internal/suites/environment.go b/internal/suites/environment.go
index da70eed8f..bbdbd5909 100644
--- a/internal/suites/environment.go
+++ b/internal/suites/environment.go
@@ -46,7 +46,7 @@ func waitUntilAutheliaBackendIsReady(dockerEnvironment *DockerEnvironment) error
 		90*time.Second,
 		dockerEnvironment,
 		"authelia-backend",
-		[]string{"Listening for"})
+		[]string{"Initializing server for"})
 }
 
 func waitUntilAutheliaFrontendIsReady(dockerEnvironment *DockerEnvironment) error {
diff --git a/internal/utils/certificates.go b/internal/utils/certificates.go
index 523dd28cd..bc7d2952a 100644
--- a/internal/utils/certificates.go
+++ b/internal/utils/certificates.go
@@ -1,17 +1,38 @@
 package utils
 
 import (
+	"bytes"
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
 	"crypto/tls"
 	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
 	"fmt"
+	"math/big"
+	"net"
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 
 	"github.com/authelia/authelia/v4/internal/configuration/schema"
 	"github.com/authelia/authelia/v4/internal/logging"
 )
 
+// PEMBlockType represent an enum of the existing PEM block types.
+type PEMBlockType int
+
+const (
+	// Certificate block type.
+	Certificate PEMBlockType = iota
+	// PrivateKey block type.
+	PrivateKey
+)
+
 // NewTLSConfig generates a tls.Config from a schema.TLSConfig and a x509.CertPool.
 func NewTLSConfig(config *schema.TLSConfig, defaultMinVersion uint16, certPool *x509.CertPool) (tlsConfig *tls.Config) {
 	minVersion, err := TLSStringToTLSConfigVersion(config.MinimumVersion)
@@ -83,3 +104,150 @@ func TLSStringToTLSConfigVersion(input string) (version uint16, err error) {
 
 	return 0, ErrTLSVersionNotSupported
 }
+
+// GenerateCertificate generate a certificate given a private key. RSA, Ed25519 and ECDSA are officially supported.
+func GenerateCertificate(privateKeyBuilder PrivateKeyBuilder, hosts []string, validFrom time.Time, validFor time.Duration, isCA bool) ([]byte, []byte, error) {
+	privateKey, err := privateKeyBuilder.Build()
+	if err != nil {
+		return nil, nil, fmt.Errorf("unable to build private key: %w", err)
+	}
+
+	notBefore := validFrom
+	notAfter := validFrom.Add(validFor)
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to generate serial number: %v", err)
+	}
+
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			Organization: []string{"Acme Co"},
+		},
+		NotBefore: notBefore,
+		NotAfter:  notAfter,
+
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+		BasicConstraintsValid: true,
+	}
+
+	for _, h := range hosts {
+		if ip := net.ParseIP(h); ip != nil {
+			template.IPAddresses = append(template.IPAddresses, ip)
+		} else {
+			template.DNSNames = append(template.DNSNames, h)
+		}
+	}
+
+	if isCA {
+		template.IsCA = true
+		template.KeyUsage |= x509.KeyUsageCertSign
+	}
+
+	certDERBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(privateKey), privateKey)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to create certificate: %v", err)
+	}
+
+	certPEMBytes, err := ConvertDERToPEM(certDERBytes, Certificate)
+	if err != nil {
+		return nil, nil, fmt.Errorf("faile to convert certificate in DER format into PEM: %v", err)
+	}
+
+	keyDERBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to marshal private key: %v", err)
+	}
+
+	keyPEMBytes, err := ConvertDERToPEM(keyDERBytes, PrivateKey)
+	if err != nil {
+		return nil, nil, fmt.Errorf("faile to convert certificate in DER format into PEM: %v", err)
+	}
+
+	return certPEMBytes, keyPEMBytes, nil
+}
+
+// ConvertDERToPEM convert certificate in DER format into PEM format.
+func ConvertDERToPEM(der []byte, blockType PEMBlockType) ([]byte, error) {
+	var buf bytes.Buffer
+
+	var blockTypeStr string
+
+	switch blockType {
+	case Certificate:
+		blockTypeStr = "CERTIFICATE"
+	case PrivateKey:
+		blockTypeStr = "PRIVATE KEY"
+	default:
+		return nil, fmt.Errorf("unknown PEM block type %d", blockType)
+	}
+
+	if err := pem.Encode(&buf, &pem.Block{Type: blockTypeStr, Bytes: der}); err != nil {
+		return nil, fmt.Errorf("failed to encode DER data into PEM: %v", err)
+	}
+
+	return buf.Bytes(), nil
+}
+
+func publicKey(privateKey interface{}) interface{} {
+	switch k := privateKey.(type) {
+	case *rsa.PrivateKey:
+		return &k.PublicKey
+	case *ecdsa.PrivateKey:
+		return &k.PublicKey
+	case ed25519.PrivateKey:
+		return k.Public().(ed25519.PublicKey)
+	default:
+		return nil
+	}
+}
+
+// PrivateKeyBuilder interface for a private key builder.
+type PrivateKeyBuilder interface {
+	Build() (interface{}, error)
+}
+
+// RSAKeyBuilder builder of RSA private key.
+type RSAKeyBuilder struct {
+	keySizeInBits int
+}
+
+// WithKeySize configure the key size to use with RSA.
+func (rkb RSAKeyBuilder) WithKeySize(bits int) RSAKeyBuilder {
+	rkb.keySizeInBits = bits
+	return rkb
+}
+
+// Build a RSA private key.
+func (rkb RSAKeyBuilder) Build() (interface{}, error) {
+	return rsa.GenerateKey(rand.Reader, rkb.keySizeInBits)
+}
+
+// Ed25519KeyBuilder builder of Ed25519 private key.
+type Ed25519KeyBuilder struct{}
+
+// Build an Ed25519 private key.
+func (ekb Ed25519KeyBuilder) Build() (interface{}, error) {
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	return priv, err
+}
+
+// ECDSAKeyBuilder builder of ECDSA private key.
+type ECDSAKeyBuilder struct {
+	curve elliptic.Curve
+}
+
+// WithCurve configure the curve to use for the ECDSA private key.
+func (ekb ECDSAKeyBuilder) WithCurve(curve elliptic.Curve) ECDSAKeyBuilder {
+	ekb.curve = curve
+	return ekb
+}
+
+// Build an ECDSA private key.
+func (ekb ECDSAKeyBuilder) Build() (interface{}, error) {
+	return ecdsa.GenerateKey(ekb.curve, rand.Reader)
+}


Test patch:
diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go
index e11e884a2..7349465cd 100644
--- a/internal/configuration/validator/server_test.go
+++ b/internal/configuration/validator/server_test.go
@@ -1,6 +1,7 @@
 package validator
 
 import (
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -9,6 +10,8 @@ import (
 	"github.com/authelia/authelia/v4/internal/configuration/schema"
 )
 
+const unexistingFilePath = "/tmp/unexisting_file"
+
 func TestShouldSetDefaultServerValues(t *testing.T) {
 	validator := schema.NewStructValidator()
 	config := &schema.Configuration{}
@@ -119,33 +122,129 @@ func TestShouldValidateAndUpdateHost(t *testing.T) {
 func TestShouldRaiseErrorWhenTLSCertWithoutKeyIsProvided(t *testing.T) {
 	validator := schema.NewStructValidator()
 	config := newDefaultConfig()
-	config.Server.TLS.Certificate = testTLSCert
+
+	file, err := os.CreateTemp("", "cert")
+	require.NoError(t, err)
+
+	defer os.Remove(file.Name())
+
+	config.Server.TLS.Certificate = file.Name()
 
 	ValidateServer(&config, validator)
 	require.Len(t, validator.Errors(), 1)
 	assert.EqualError(t, validator.Errors()[0], "server: tls: option 'certificate' must also be accompanied by option 'key'")
 }
 
+func TestShouldRaiseErrorWhenTLSCertDoesNotExist(t *testing.T) {
+	validator := schema.NewStructValidator()
+	config := newDefaultConfig()
+
+	file, err := os.CreateTemp("", "key")
+	require.NoError(t, err)
+
+	defer os.Remove(file.Name())
+
+	config.Server.TLS.Certificate = unexistingFilePath
+	config.Server.TLS.Key = file.Name()
+
+	ValidateServer(&config, validator)
+	require.Len(t, validator.Errors(), 1)
+	assert.EqualError(t, validator.Errors()[0], "server: tls: file path /tmp/unexisting_file provided in 'certificate' does not exist")
+}
+
 func TestShouldRaiseErrorWhenTLSKeyWithoutCertIsProvided(t *testing.T) {
 	validator := schema.NewStructValidator()
 	config := newDefaultConfig()
-	config.Server.TLS.Key = testTLSKey
+
+	file, err := os.CreateTemp("", "key")
+	require.NoError(t, err)
+
+	defer os.Remove(file.Name())
+
+	config.Server.TLS.Key = file.Name()
 
 	ValidateServer(&config, validator)
 	require.Len(t, validator.Errors(), 1)
 	assert.EqualError(t, validator.Errors()[0], "server: tls: option 'key' must also be accompanied by option 'certificate'")
 }
 
+func TestShouldRaiseErrorWhenTLSKeyDoesNotExist(t *testing.T) {
+	validator := schema.NewStructValidator()
+	config := newDefaultConfig()
+
+	file, err := os.CreateTemp("", "key")
+	require.NoError(t, err)
+
+	defer os.Remove(file.Name())
+
+	config.Server.TLS.Key = unexistingFilePath
+	config.Server.TLS.Certificate = file.Name()
+
+	ValidateServer(&config, validator)
+	require.Len(t, validator.Errors(), 1)
+	assert.EqualError(t, validator.Errors()[0], "server: tls: file path /tmp/unexisting_file provided in 'key' does not exist")
+}
+
 func TestShouldNotRaiseErrorWhenBothTLSCertificateAndKeyAreProvided(t *testing.T) {
 	validator := schema.NewStructValidator()
 	config := newDefaultConfig()
-	config.Server.TLS.Certificate = testTLSCert
-	config.Server.TLS.Key = testTLSKey
+
+	certFile, err := os.CreateTemp("", "cert")
+	require.NoError(t, err)
+
+	defer os.Remove(certFile.Name())
+
+	keyFile, err := os.CreateTemp("", "key")
+	require.NoError(t, err)
+
+	defer os.Remove(keyFile.Name())
+
+	config.Server.TLS.Certificate = certFile.Name()
+	config.Server.TLS.Key = keyFile.Name()
 
 	ValidateServer(&config, validator)
 	require.Len(t, validator.Errors(), 0)
 }
 
+func TestShouldRaiseErrorWhenTLSClientCertificateDoesNotExist(t *testing.T) {
+	validator := schema.NewStructValidator()
+	config := newDefaultConfig()
+
+	certFile, err := os.CreateTemp("", "cert")
+	require.NoError(t, err)
+
+	defer os.Remove(certFile.Name())
+
+	keyFile, err := os.CreateTemp("", "key")
+	require.NoError(t, err)
+
+	defer os.Remove(keyFile.Name())
+
+	config.Server.TLS.Certificate = certFile.Name()
+	config.Server.TLS.Key = keyFile.Name()
+	config.Server.TLS.ClientCertificates = []string{"/tmp/unexisting"}
+
+	ValidateServer(&config, validator)
+	require.Len(t, validator.Errors(), 1)
+	assert.EqualError(t, validator.Errors()[0], "server: tls: client_certificates: certificates: file path /tmp/unexisting does not exist")
+}
+
+func TestShouldRaiseErrorWhenTLSClientAuthIsDefinedButNotServerCertificate(t *testing.T) {
+	validator := schema.NewStructValidator()
+	config := newDefaultConfig()
+
+	certFile, err := os.CreateTemp("", "cert")
+	require.NoError(t, err)
+
+	defer os.Remove(certFile.Name())
+
+	config.Server.TLS.ClientCertificates = []string{certFile.Name()}
+
+	ValidateServer(&config, validator)
+	require.Len(t, validator.Errors(), 1)
+	assert.EqualError(t, validator.Errors()[0], "server: tls: client authentication cannot be configured if no server certificate and key are provided")
+}
+
 func TestShouldNotUpdateConfig(t *testing.T) {
 	validator := schema.NewStructValidator()
 	config := newDefaultConfig()
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
new file mode 100644
index 000000000..dcf86d464
--- /dev/null
+++ b/internal/server/server_test.go
@@ -0,0 +1,379 @@
+package server
+
+import (
+	"crypto/elliptic"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/authelia/authelia/v4/internal/logging"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/valyala/fasthttp"
+
+	"github.com/authelia/authelia/v4/internal/configuration/schema"
+	"github.com/authelia/authelia/v4/internal/middlewares"
+	"github.com/authelia/authelia/v4/internal/utils"
+)
+
+// TemporaryCertificate contains the FD of 2 temporary files containing the PEM format of the certificate and private key.
+type TemporaryCertificate struct {
+	CertFile *os.File
+	KeyFile  *os.File
+
+	Certificate *x509.Certificate
+
+	CertificatePEM []byte
+	KeyPEM         []byte
+}
+
+func (tc TemporaryCertificate) TLSCertificate() (tls.Certificate, error) {
+	return tls.LoadX509KeyPair(tc.CertFile.Name(), tc.KeyFile.Name())
+}
+
+func (tc *TemporaryCertificate) Close() {
+	if tc.CertFile != nil {
+		tc.CertFile.Close()
+	}
+
+	if tc.KeyFile != nil {
+		tc.KeyFile.Close()
+	}
+}
+
+type CertificateContext struct {
+	Certificates      []TemporaryCertificate
+	privateKeyBuilder utils.PrivateKeyBuilder
+}
+
+// NewCertificateContext instantiate a new certificate context used to easily generate certificates within tests.
+func NewCertificateContext(privateKeyBuilder utils.PrivateKeyBuilder) (*CertificateContext, error) {
+	certificateContext := new(CertificateContext)
+	certificateContext.privateKeyBuilder = privateKeyBuilder
+
+	cert, err := certificateContext.GenerateCertificate()
+	if err != nil {
+		return nil, err
+	}
+
+	certificateContext.Certificates = []TemporaryCertificate{*cert}
+
+	return certificateContext, nil
+}
+
+// GenerateCertificate generate a new certificate in the context.
+func (cc *CertificateContext) GenerateCertificate() (*TemporaryCertificate, error) {
+	certBytes, keyBytes, err := utils.GenerateCertificate(cc.privateKeyBuilder,
+		[]string{"authelia.com", "example.org", "local.example.com"},
+		time.Now(), 3*time.Hour, false)
+	if err != nil {
+		return nil, fmt.Errorf("unable to generate certificate: %v", err)
+	}
+
+	tmpCertificate := new(TemporaryCertificate)
+
+	certFile, err := os.CreateTemp("", "cert")
+	if err != nil {
+		return nil, fmt.Errorf("unable to create temp file for certificate: %v", err)
+	}
+
+	tmpCertificate.CertFile = certFile
+	tmpCertificate.CertificatePEM = certBytes
+
+	block, _ := pem.Decode(certBytes)
+	c, err := x509.ParseCertificate(block.Bytes)
+
+	if err != nil {
+		return nil, fmt.Errorf("unable to parse certificate: %v", err)
+	}
+
+	tmpCertificate.Certificate = c
+
+	err = os.WriteFile(tmpCertificate.CertFile.Name(), certBytes, 0600)
+	if err != nil {
+		tmpCertificate.Close()
+		return nil, fmt.Errorf("unable to write certificates in file: %v", err)
+	}
+
+	keyFile, err := os.CreateTemp("", "key")
+	if err != nil {
+		tmpCertificate.Close()
+		return nil, fmt.Errorf("unable to create temp file for private key: %v", err)
+	}
+
+	tmpCertificate.KeyFile = keyFile
+	tmpCertificate.KeyPEM = keyBytes
+
+	err = os.WriteFile(tmpCertificate.KeyFile.Name(), keyBytes, 0600)
+	if err != nil {
+		tmpCertificate.Close()
+		return nil, fmt.Errorf("unable to write private key in file: %v", err)
+	}
+
+	cc.Certificates = append(cc.Certificates, *tmpCertificate)
+
+	return tmpCertificate, nil
+}
+
+func (cc *CertificateContext) Close() {
+	for _, tc := range cc.Certificates {
+		tc.Close()
+	}
+}
+
+type TLSServerContext struct {
+	server *fasthttp.Server
+	port   int
+}
+
+func NewTLSServerContext(configuration schema.Configuration) (*TLSServerContext, error) {
+	serverContext := new(TLSServerContext)
+
+	s, listener := CreateServer(configuration, middlewares.Providers{})
+	serverContext.server = s
+
+	go func() {
+		err := s.Serve(listener)
+		if err != nil {
+			logging.Logger().Fatal(err)
+		}
+	}()
+
+	addrSplit := strings.Split(listener.Addr().String(), ":")
+	if len(addrSplit) > 1 {
+		port, err := strconv.ParseInt(addrSplit[len(addrSplit)-1], 10, 32)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse port from address: %v", err)
+		}
+
+		serverContext.port = int(port)
+	}
+
+	return serverContext, nil
+}
+
+func (sc *TLSServerContext) Port() int {
+	return sc.port
+}
+
+func (sc *TLSServerContext) Close() error {
+	return sc.server.Shutdown()
+}
+
+func TestShouldRaiseErrorWhenClientDoesNotSkipVerify(t *testing.T) {
+	privateKeyBuilder := utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P256())
+	certificateContext, err := NewCertificateContext(privateKeyBuilder)
+	require.NoError(t, err)
+
+	defer certificateContext.Close()
+
+	tlsServerContext, err := NewTLSServerContext(schema.Configuration{
+		Server: schema.ServerConfiguration{
+			TLS: schema.ServerTLSConfiguration{
+				Certificate: certificateContext.Certificates[0].CertFile.Name(),
+				Key:         certificateContext.Certificates[0].KeyFile.Name(),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	defer tlsServerContext.Close()
+
+	fmt.Println(tlsServerContext.Port())
+	req, err := http.NewRequest("GET", fmt.Sprintf("https://local.example.com:%d", tlsServerContext.Port()), nil)
+	require.NoError(t, err)
+
+	_, err = http.DefaultClient.Do(req)
+	require.Error(t, err)
+
+	require.Contains(t, err.Error(), "x509: certificate signed by unknown authority")
+}
+
+func TestShouldServeOverTLSWhenClientDoesSkipVerify(t *testing.T) {
+	privateKeyBuilder := utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P256())
+	certificateContext, err := NewCertificateContext(privateKeyBuilder)
+	require.NoError(t, err)
+
+	defer certificateContext.Close()
+
+	tlsServerContext, err := NewTLSServerContext(schema.Configuration{
+		Server: schema.ServerConfiguration{
+			TLS: schema.ServerTLSConfiguration{
+				Certificate: certificateContext.Certificates[0].CertFile.Name(),
+				Key:         certificateContext.Certificates[0].KeyFile.Name(),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	defer tlsServerContext.Close()
+
+	req, err := http.NewRequest("GET", fmt.Sprintf("https://local.example.com:%d/api/notfound", tlsServerContext.Port()), nil)
+	require.NoError(t, err)
+
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Needs to be enabled in tests. Not used in production.
+	}
+	client := &http.Client{Transport: tr}
+
+	res, err := client.Do(req)
+	require.NoError(t, err)
+
+	defer res.Body.Close()
+
+	_, err = io.ReadAll(res.Body)
+	require.NoError(t, err)
+	assert.Equal(t, "404 Not Found", res.Status)
+}
+
+func TestShouldServeOverTLSWhenClientHasProperRootCA(t *testing.T) {
+	privateKeyBuilder := utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P256())
+	certificateContext, err := NewCertificateContext(privateKeyBuilder)
+	require.NoError(t, err)
+
+	defer certificateContext.Close()
+
+	tlsServerContext, err := NewTLSServerContext(schema.Configuration{
+		Server: schema.ServerConfiguration{
+			TLS: schema.ServerTLSConfiguration{
+				Certificate: certificateContext.Certificates[0].CertFile.Name(),
+				Key:         certificateContext.Certificates[0].KeyFile.Name(),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	defer tlsServerContext.Close()
+
+	req, err := http.NewRequest("GET", fmt.Sprintf("https://local.example.com:%d/api/notfound", tlsServerContext.Port()), nil)
+	require.NoError(t, err)
+
+	block, _ := pem.Decode(certificateContext.Certificates[0].CertificatePEM)
+	c, err := x509.ParseCertificate(block.Bytes)
+	require.NoError(t, err)
+
+	// Create a root CA for the client to properly validate server cert.
+	rootCAs := x509.NewCertPool()
+	rootCAs.AddCert(c)
+
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{
+			RootCAs:    rootCAs,
+			MinVersion: tls.VersionTLS13,
+		},
+	}
+	client := &http.Client{Transport: tr}
+
+	res, err := client.Do(req)
+	require.NoError(t, err)
+
+	defer res.Body.Close()
+
+	_, err = io.ReadAll(res.Body)
+	require.NoError(t, err)
+	assert.Equal(t, "404 Not Found", res.Status)
+}
+
+func TestShouldRaiseWhenMutualTLSIsConfiguredAndClientIsNotAuthenticated(t *testing.T) {
+	privateKeyBuilder := utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P256())
+	certificateContext, err := NewCertificateContext(privateKeyBuilder)
+	require.NoError(t, err)
+
+	defer certificateContext.Close()
+
+	clientCert, err := certificateContext.GenerateCertificate()
+	require.NoError(t, err)
+
+	tlsServerContext, err := NewTLSServerContext(schema.Configuration{
+		Server: schema.ServerConfiguration{
+			TLS: schema.ServerTLSConfiguration{
+				Certificate:        certificateContext.Certificates[0].CertFile.Name(),
+				Key:                certificateContext.Certificates[0].KeyFile.Name(),
+				ClientCertificates: []string{clientCert.CertFile.Name()},
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	defer tlsServerContext.Close()
+
+	req, err := http.NewRequest("GET", fmt.Sprintf("https://local.example.com:%d/api/notfound", tlsServerContext.Port()), nil)
+	require.NoError(t, err)
+
+	// Create a root CA for the client to properly validate server cert.
+	rootCAs := x509.NewCertPool()
+	rootCAs.AddCert(certificateContext.Certificates[0].Certificate)
+
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{
+			RootCAs:    rootCAs,
+			MinVersion: tls.VersionTLS13,
+		},
+	}
+	client := &http.Client{Transport: tr}
+
+	_, err = client.Do(req)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "remote error: tls: bad certificate")
+}
+
+func TestShouldServeProperlyWhenMutualTLSIsConfiguredAndClientIsAuthenticated(t *testing.T) {
+	privateKeyBuilder := utils.ECDSAKeyBuilder{}.WithCurve(elliptic.P256())
+	certificateContext, err := NewCertificateContext(privateKeyBuilder)
+	require.NoError(t, err)
+
+	defer certificateContext.Close()
+
+	clientCert, err := certificateContext.GenerateCertificate()
+	require.NoError(t, err)
+
+	tlsServerContext, err := NewTLSServerContext(schema.Configuration{
+		Server: schema.ServerConfiguration{
+			TLS: schema.ServerTLSConfiguration{
+				Certificate:        certificateContext.Certificates[0].CertFile.Name(),
+				Key:                certificateContext.Certificates[0].KeyFile.Name(),
+				ClientCertificates: []string{clientCert.CertFile.Name()},
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	defer tlsServerContext.Close()
+
+	req, err := http.NewRequest("GET", fmt.Sprintf("https://local.example.com:%d/api/notfound", tlsServerContext.Port()), nil)
+	require.NoError(t, err)
+
+	// Create a root CA for the client to properly validate server cert.
+	rootCAs := x509.NewCertPool()
+	rootCAs.AddCert(certificateContext.Certificates[0].Certificate)
+
+	cCert, err := certificateContext.Certificates[1].TLSCertificate()
+	require.NoError(t, err)
+
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{
+			RootCAs:      rootCAs,
+			Certificates: []tls.Certificate{cCert},
+			MinVersion:   tls.VersionTLS13,
+		},
+	}
+	client := &http.Client{Transport: tr}
+
+	res, err := client.Do(req)
+	require.NoError(t, err)
+
+	defer res.Body.Close()
+
+	_, err = io.ReadAll(res.Body)
+	require.NoError(t, err)
+	assert.Equal(t, "404 Not Found", res.Status)
+}
diff --git a/internal/suites/suite_cli_test.go b/internal/suites/suite_cli_test.go
index d6e322add..846ce23fe 100644
--- a/internal/suites/suite_cli_test.go
+++ b/internal/suites/suite_cli_test.go
@@ -45,6 +45,10 @@ func (s *CLISuite) SetupTest() {
 }
 
 func (s *CLISuite) TestShouldPrintBuildInformation() {
+	if os.Getenv("CI") == "false" {
+		s.T().Skip("Skipping testing in dev environment")
+	}
+
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "build-info"})
 	s.Assert().NoError(err)
 	s.Assert().Contains(output, "Last Tag: ")
@@ -92,22 +96,22 @@ func (s *CLISuite) TestShouldHashPasswordSHA512() {
 func (s *CLISuite) TestShouldGenerateCertificateRSA() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldGenerateCertificateRSAWithIPAddress() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=127.0.0.1", "--dir=/tmp/"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldGenerateCertificateRSAWithStartDate() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--start-date='Jan 1 15:04:05 2011'"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldFailGenerateCertificateRSAWithStartDate() {
@@ -119,15 +123,15 @@ func (s *CLISuite) TestShouldFailGenerateCertificateRSAWithStartDate() {
 func (s *CLISuite) TestShouldGenerateCertificateCA() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--ca"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldGenerateCertificateEd25519() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--ed25519"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldFailGenerateCertificateECDSA() {
@@ -139,29 +143,29 @@ func (s *CLISuite) TestShouldFailGenerateCertificateECDSA() {
 func (s *CLISuite) TestShouldGenerateCertificateECDSAP224() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--ecdsa-curve=P224"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldGenerateCertificateECDSAP256() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--ecdsa-curve=P256"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldGenerateCertificateECDSAP384() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--ecdsa-curve=P384"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestShouldGenerateCertificateECDSAP521() {
 	output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host=*.example.com", "--dir=/tmp/", "--ecdsa-curve=P521"})
 	s.Assert().NoError(err)
-	s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem")
-	s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem")
+	s.Assert().Contains(output, "Certificate written to /tmp/cert.pem")
+	s.Assert().Contains(output, "Private Key written to /tmp/key.pem")
 }
 
 func (s *CLISuite) TestStorageShouldShowErrWithoutConfig() {
diff --git a/internal/utils/certificates_test.go b/internal/utils/certificates_test.go
index 2cf103076..918678fe8 100644
--- a/internal/utils/certificates_test.go
+++ b/internal/utils/certificates_test.go
@@ -1,9 +1,11 @@
 package utils
 
 import (
+	"crypto/elliptic"
 	"crypto/tls"
 	"runtime"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -124,3 +126,44 @@ func TestShouldReadCertsFromDirectoryButNotKeys(t *testing.T) {
 
 	assert.EqualError(t, errors[0], "could not import certificate key.pem")
 }
+
+func TestShouldGenerateCertificateAndPersistIt(t *testing.T) {
+	testCases := []struct {
+		Name              string
+		PrivateKeyBuilder PrivateKeyBuilder
+	}{
+		{
+			Name:              "P224",
+			PrivateKeyBuilder: ECDSAKeyBuilder{}.WithCurve(elliptic.P224()),
+		},
+		{
+			Name:              "P256",
+			PrivateKeyBuilder: ECDSAKeyBuilder{}.WithCurve(elliptic.P256()),
+		},
+		{
+			Name:              "P384",
+			PrivateKeyBuilder: ECDSAKeyBuilder{}.WithCurve(elliptic.P384()),
+		},
+		{
+			Name:              "P521",
+			PrivateKeyBuilder: ECDSAKeyBuilder{}.WithCurve(elliptic.P521()),
+		},
+		{
+			Name:              "Ed25519",
+			PrivateKeyBuilder: Ed25519KeyBuilder{},
+		},
+		{
+			Name:              "RSA",
+			PrivateKeyBuilder: RSAKeyBuilder{keySizeInBits: 2048},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Name, func(t *testing.T) {
+			certBytes, keyBytes, err := GenerateCertificate(tc.PrivateKeyBuilder, []string{"authelia.com", "example.org"}, time.Now(), 3*time.Hour, false)
+			require.NoError(t, err)
+			assert.True(t, len(certBytes) > 0)
+			assert.True(t, len(keyBytes) > 0)
+		})
+	}
+}