diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go
index 03234b396..d4c2bb61e 100644
--- a/caddyconfig/httpcaddyfile/httptype.go
+++ b/caddyconfig/httpcaddyfile/httptype.go
@@ -88,6 +88,13 @@ func (st ServerType) Setup(originalServerBlocks []caddyfile.ServerBlock,
 			"{remote}", "{http.request.remote}",
 			"{scheme}", "{http.request.scheme}",
 			"{uri}", "{http.request.uri}",
+
+			"{tls_cipher}", "{http.request.tls.cipher_suite}",
+			"{tls_version}", "{http.request.tls.version}",
+			"{tls_client_fingerprint}", "{http.request.tls.client.fingerprint}",
+			"{tls_client_issuer}", "{http.request.tls.client.issuer}",
+			"{tls_client_serial}", "{http.request.tls.client.serial}",
+			"{tls_client_subject}", "{http.request.tls.client.subject}",
 		)
 		for _, segment := range sb.block.Segments {
 			for i := 0; i < len(segment); i++ {
diff --git a/modules/caddyhttp/caddyhttp.go b/modules/caddyhttp/caddyhttp.go
index 30c2f7988..135afef98 100644
--- a/modules/caddyhttp/caddyhttp.go
+++ b/modules/caddyhttp/caddyhttp.go
@@ -71,6 +71,16 @@ func init() {
 // `{http.request.remote.port}` | The port part of the remote client's address
 // `{http.request.remote}` | The address of the remote client
 // `{http.request.scheme}` | The request scheme
+// `{http.request.tls.version}` | The TLS version name
+// `{http.request.tls.cipher_suite}` | The TLS cipher suite
+// `{http.request.tls.resumed}` | The TLS connection resumed a previous connection
+// `{http.request.tls.proto}` | The negotiated next protocol
+// `{http.request.tls.proto_mutual}` | The negotiated next protocol was advertised by the server
+// `{http.request.tls.server_name}` | The server name requested by the client, if any
+// `{http.request.tls.client.fingerprint}` | The SHA256 checksum of the client certificate
+// `{http.request.tls.client.issuer}` | The issuer DN of the client certificate
+// `{http.request.tls.client.serial}` | The serial number of the client certificate
+// `{http.request.tls.client.subject}` | The subject DN of the client certificate
 // `{http.request.uri.path.*}` | Parts of the path, split by `/` (0-based from left)
 // `{http.request.uri.path.dir}` | The directory, excluding leaf filename
 // `{http.request.uri.path.file}` | The filename of the path, excluding directory
diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go
index cea820d05..c9c7522e7 100644
--- a/modules/caddyhttp/replacer.go
+++ b/modules/caddyhttp/replacer.go
@@ -15,6 +15,9 @@
 package caddyhttp
 
 import (
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
 	"fmt"
 	"net"
 	"net/http"
@@ -24,14 +27,15 @@ import (
 	"strings"
 
 	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/modules/caddytls"
 )
 
 func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.ResponseWriter) {
 	httpVars := func(key string) (string, bool) {
 		if req != nil {
 			// query string parameters
-			if strings.HasPrefix(key, queryReplPrefix) {
-				vals := req.URL.Query()[key[len(queryReplPrefix):]]
+			if strings.HasPrefix(key, reqURIQueryReplPrefix) {
+				vals := req.URL.Query()[key[len(reqURIQueryReplPrefix):]]
 				// always return true, since the query param might
 				// be present only in some requests
 				return strings.Join(vals, ","), true
@@ -47,8 +51,8 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 			}
 
 			// cookies
-			if strings.HasPrefix(key, cookieReplPrefix) {
-				name := key[len(cookieReplPrefix):]
+			if strings.HasPrefix(key, reqCookieReplPrefix) {
+				name := key[len(reqCookieReplPrefix):]
 				for _, cookie := range req.Cookies() {
 					if strings.EqualFold(name, cookie.Name) {
 						// always return true, since the cookie might
@@ -58,6 +62,11 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 				}
 			}
 
+			// http.request.tls.
+			if strings.HasPrefix(key, reqTLSReplPrefix) {
+				return getReqTLSReplacement(req, key)
+			}
+
 			switch key {
 			case "http.request.method":
 				return req.Method, true
@@ -129,8 +138,8 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 			}
 
 			// hostname labels
-			if strings.HasPrefix(key, hostLabelReplPrefix) {
-				idxStr := key[len(hostLabelReplPrefix):]
+			if strings.HasPrefix(key, reqHostLabelsReplPrefix) {
+				idxStr := key[len(reqHostLabelsReplPrefix):]
 				idx, err := strconv.Atoi(idxStr)
 				if err != nil {
 					return "", false
@@ -150,8 +159,8 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 			}
 
 			// path parts
-			if strings.HasPrefix(key, pathPartsReplPrefix) {
-				idxStr := key[len(pathPartsReplPrefix):]
+			if strings.HasPrefix(key, reqURIPathReplPrefix) {
+				idxStr := key[len(reqURIPathReplPrefix):]
 				idx, err := strconv.Atoi(idxStr)
 				if err != nil {
 					return "", false
@@ -208,12 +217,77 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 	repl.Map(httpVars)
 }
 
+func getReqTLSReplacement(req *http.Request, key string) (string, bool) {
+	if req == nil || req.TLS == nil {
+		return "", false
+	}
+
+	if len(key) < len(reqTLSReplPrefix) {
+		return "", false
+	}
+
+	field := strings.ToLower(key[len(reqTLSReplPrefix):])
+
+	if strings.HasPrefix(field, "client.") {
+		cert := getTLSPeerCert(req.TLS)
+		if cert == nil {
+			return "", false
+		}
+
+		switch field {
+		case "client.fingerprint":
+			return fmt.Sprintf("%x", sha256.Sum256(cert.Raw)), true
+		case "client.issuer":
+			return cert.Issuer.String(), true
+		case "client.serial":
+			return fmt.Sprintf("%x", cert.SerialNumber), true
+		case "client.subject":
+			return cert.Subject.String(), true
+		default:
+			return "", false
+		}
+	}
+
+	switch field {
+	case "version":
+		return caddytls.ProtocolName(req.TLS.Version), true
+	case "cipher_suite":
+		return tls.CipherSuiteName(req.TLS.CipherSuite), true
+	case "resumed":
+		if req.TLS.DidResume {
+			return "true", true
+		}
+		return "false", true
+	case "proto":
+		return req.TLS.NegotiatedProtocol, true
+	case "proto_mutual":
+		if req.TLS.NegotiatedProtocolIsMutual {
+			return "true", true
+		}
+		return "false", true
+	case "server_name":
+		return req.TLS.ServerName, true
+	default:
+		return "", false
+	}
+}
+
+// getTLSPeerCert retrieves the first peer certificate from a TLS session.
+// Returns nil if no peer cert is in use.
+func getTLSPeerCert(cs *tls.ConnectionState) *x509.Certificate {
+	if len(cs.PeerCertificates) == 0 {
+		return nil
+	}
+	return cs.PeerCertificates[0]
+}
+
 const (
-	queryReplPrefix      = "http.request.uri.query."
-	reqHeaderReplPrefix  = "http.request.header."
-	cookieReplPrefix     = "http.request.cookie."
-	hostLabelReplPrefix  = "http.request.host.labels."
-	pathPartsReplPrefix  = "http.request.uri.path."
-	varsReplPrefix       = "http.vars."
-	respHeaderReplPrefix = "http.response.header."
+	reqCookieReplPrefix     = "http.request.cookie."
+	reqHeaderReplPrefix     = "http.request.header."
+	reqHostLabelsReplPrefix = "http.request.host.labels."
+	reqTLSReplPrefix        = "http.request.tls."
+	reqURIPathReplPrefix    = "http.request.uri.path."
+	reqURIQueryReplPrefix   = "http.request.uri.query."
+	respHeaderReplPrefix    = "http.response.header."
+	varsReplPrefix          = "http.vars."
 )
diff --git a/modules/caddyhttp/replacer_test.go b/modules/caddyhttp/replacer_test.go
index b355c7fc3..ea9fa6569 100644
--- a/modules/caddyhttp/replacer_test.go
+++ b/modules/caddyhttp/replacer_test.go
@@ -16,6 +16,9 @@ package caddyhttp
 
 import (
 	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/pem"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -30,6 +33,41 @@ func TestHTTPVarReplacement(t *testing.T) {
 	req = req.WithContext(ctx)
 	req.Host = "example.com:80"
 	req.RemoteAddr = "localhost:1234"
+
+	clientCert := []byte(`-----BEGIN CERTIFICATE-----
+MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk
+eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG
+A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB
+iQKBgQDFDEpzF0ew68teT3xDzcUxVFaTII+jXH1ftHXxxP4BEYBU4q90qzeKFneF
+z83I0nC0WAQ45ZwHfhLMYHFzHPdxr6+jkvKPASf0J2v2HDJuTM1bHBbik5Ls5eq+
+fVZDP8o/VHKSBKxNs8Goc2NTsr5b07QTIpkRStQK+RJALk4x9QIDAQABo0swSTAJ
+BgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A
+AAEwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEANSjz2Sk+
+eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
+3Q9fgDkiUod+uIK0IynzIKvw+Cjg+3nx6NQ0IM0zo8c7v398RzB4apbXKZyeeqUH
+9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g=
+-----END CERTIFICATE-----`)
+
+	block, _ := pem.Decode(clientCert)
+	if block == nil {
+		t.Fatalf("failed to decode PEM certificate")
+	}
+
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		t.Fatalf("failed to decode PEM certificate: %v", err)
+	}
+
+	req.TLS = &tls.ConnectionState{
+		Version:                    tls.VersionTLS13,
+		HandshakeComplete:          true,
+		ServerName:                 "foo.com",
+		CipherSuite:                tls.TLS_AES_256_GCM_SHA384,
+		PeerCertificates:           []*x509.Certificate{cert},
+		NegotiatedProtocol:         "h2",
+		NegotiatedProtocolIsMutual: true,
+	}
+
 	res := httptest.NewRecorder()
 	addHTTPVarsToReplacer(repl, req, res)
 
@@ -39,7 +77,7 @@ func TestHTTPVarReplacement(t *testing.T) {
 	}{
 		{
 			input:  "{http.request.scheme}",
-			expect: "http",
+			expect: "https",
 		},
 		{
 			input:  "{http.request.host}",
@@ -69,6 +107,46 @@ func TestHTTPVarReplacement(t *testing.T) {
 			input:  "{http.request.host.labels.1}",
 			expect: "example",
 		},
+		{
+			input:  "{http.request.tls.cipher_suite}",
+			expect: "TLS_AES_256_GCM_SHA384",
+		},
+		{
+			input:  "{http.request.tls.proto}",
+			expect: "h2",
+		},
+		{
+			input:  "{http.request.tls.proto_mutual}",
+			expect: "true",
+		},
+		{
+			input:  "{http.request.tls.resumed}",
+			expect: "false",
+		},
+		{
+			input:  "{http.request.tls.server_name}",
+			expect: "foo.com",
+		},
+		{
+			input:  "{http.request.tls.version}",
+			expect: "tls1.3",
+		},
+		{
+			input:  "{http.request.tls.client.fingerprint}",
+			expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702",
+		},
+		{
+			input:  "{http.request.tls.client.issuer}",
+			expect: "CN=Caddy Test CA",
+		},
+		{
+			input:  "{http.request.tls.client.serial}",
+			expect: "2",
+		},
+		{
+			input:  "{http.request.tls.client.subject}",
+			expect: "CN=client.localdomain",
+		},
 	} {
 		actual := repl.ReplaceAll(tc.input, "<empty>")
 		if actual != tc.expect {
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index 124331de1..580449b22 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -173,7 +173,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			}
 
 			log("handled request",
-				zap.String("common_log", repl.ReplaceAll(commonLogFormat, "-")),
+				zap.String("common_log", repl.ReplaceAll(commonLogFormat, commonLogEmptyValue)),
 				zap.Duration("latency", latency),
 				zap.Int("size", wrec.Size()),
 				zap.Int("status", wrec.Status()),
diff --git a/modules/caddytls/values.go b/modules/caddytls/values.go
index 21a6b3305..d23165098 100644
--- a/modules/caddytls/values.go
+++ b/modules/caddytls/values.go
@@ -17,6 +17,7 @@ package caddytls
 import (
 	"crypto/tls"
 	"crypto/x509"
+	"fmt"
 
 	"github.com/go-acme/lego/v3/certcrypto"
 	"github.com/klauspost/cpuid"
@@ -127,9 +128,36 @@ var SupportedProtocols = map[string]uint16{
 	"tls1.3": tls.VersionTLS13,
 }
 
+// unsupportedProtocols is a map of unsupported protocols.
+// Used for logging only, not enforcement.
+var unsupportedProtocols = map[string]uint16{
+	"ssl3.0": tls.VersionSSL30,
+	"tls1.0": tls.VersionTLS10,
+	"tls1.1": tls.VersionTLS11,
+}
+
 // publicKeyAlgorithms is the map of supported public key algorithms.
 var publicKeyAlgorithms = map[string]x509.PublicKeyAlgorithm{
 	"rsa":   x509.RSA,
 	"dsa":   x509.DSA,
 	"ecdsa": x509.ECDSA,
 }
+
+// ProtocolName returns the standard name for the passed protocol version ID
+// (e.g.  "TLS1.3") or a fallback representation of the ID value if the version
+// is not supported.
+func ProtocolName(id uint16) string {
+	for k, v := range SupportedProtocols {
+		if v == id {
+			return k
+		}
+	}
+
+	for k, v := range unsupportedProtocols {
+		if v == id {
+			return k
+		}
+	}
+
+	return fmt.Sprintf("0x%04x", id)
+}