diff --git a/caddytest/integration/caddyfile_adapt/log_filters.txt b/caddytest/integration/caddyfile_adapt/log_filters.txt
index 7873b1c9b..2693e9c60 100644
--- a/caddytest/integration/caddyfile_adapt/log_filters.txt
+++ b/caddytest/integration/caddyfile_adapt/log_filters.txt
@@ -11,6 +11,10 @@ log {
 			}
 			request>headers>Authorization replace REDACTED
 			request>headers>Server delete
+			request>headers>Cookie cookie {
+				replace foo REDACTED
+				delete bar
+			}
 			request>remote_addr ip_mask {
 				ipv4 24
 				ipv6 32
@@ -37,6 +41,20 @@ log {
 							"filter": "replace",
 							"value": "REDACTED"
 						},
+						"request\u003eheaders\u003eCookie": {
+							"actions": [
+								{
+									"name": "foo",
+									"type": "replace",
+									"value": "REDACTED"
+								},
+								{
+									"name": "bar",
+									"type": "delete"
+								}
+							],
+							"filter": "cookie"
+						},
 						"request\u003eheaders\u003eServer": {
 							"filter": "delete"
 						},
diff --git a/modules/logging/filters.go b/modules/logging/filters.go
index ceb0d8ac3..cf3b5cc15 100644
--- a/modules/logging/filters.go
+++ b/modules/logging/filters.go
@@ -17,6 +17,7 @@ package logging
 import (
 	"errors"
 	"net"
+	"net/http"
 	"net/url"
 	"strconv"
 
@@ -30,6 +31,7 @@ func init() {
 	caddy.RegisterModule(ReplaceFilter{})
 	caddy.RegisterModule(IPMaskFilter{})
 	caddy.RegisterModule(QueryFilter{})
+	caddy.RegisterModule(CookieFilter{})
 }
 
 // LogFieldFilter can filter (or manipulate)
@@ -311,17 +313,132 @@ func (m QueryFilter) Filter(in zapcore.Field) zapcore.Field {
 	return in
 }
 
+type cookieFilterAction struct {
+	// `replace` to replace the value of the cookie or `delete` to remove it entirely.
+	Type filterAction `json:"type"`
+
+	// The name of the cookie.
+	Name string `json:"name"`
+
+	// The value to use as replacement if the action is `replace`.
+	Value string `json:"value,omitempty"`
+}
+
+// CookieFilter is a Caddy log field filter that filters
+// cookies.
+//
+// This filter updates the logged HTTP header string
+// to remove or replace cookies containing sensitive data. For instance,
+// it can be used to redact any kind of secrets, such as session IDs.
+//
+// If several actions are configured for the same cookie name, only the first
+// will be applied.
+type CookieFilter struct {
+	// A list of actions to apply to the cookies.
+	Actions []cookieFilterAction `json:"actions"`
+}
+
+// Validate checks that action types are correct.
+func (f *CookieFilter) Validate() error {
+	for _, a := range f.Actions {
+		if err := a.Type.IsValid(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// CaddyModule returns the Caddy module information.
+func (CookieFilter) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "caddy.logging.encoders.filter.cookie",
+		New: func() caddy.Module { return new(CookieFilter) },
+	}
+}
+
+// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
+func (m *CookieFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+	for d.Next() {
+		for d.NextBlock(0) {
+			cfa := cookieFilterAction{}
+			switch d.Val() {
+			case "replace":
+				if !d.NextArg() {
+					return d.ArgErr()
+				}
+
+				cfa.Type = replaceAction
+				cfa.Name = d.Val()
+
+				if !d.NextArg() {
+					return d.ArgErr()
+				}
+				cfa.Value = d.Val()
+
+			case "delete":
+				if !d.NextArg() {
+					return d.ArgErr()
+				}
+
+				cfa.Type = deleteAction
+				cfa.Name = d.Val()
+
+			default:
+				return d.Errf("unrecognized subdirective %s", d.Val())
+			}
+
+			m.Actions = append(m.Actions, cfa)
+		}
+	}
+	return nil
+}
+
+// Filter filters the input field.
+func (m CookieFilter) Filter(in zapcore.Field) zapcore.Field {
+	originRequest := http.Request{Header: http.Header{"Cookie": []string{in.String}}}
+	cookies := originRequest.Cookies()
+	transformedRequest := http.Request{Header: make(http.Header)}
+
+OUTER:
+	for _, c := range cookies {
+		for _, a := range m.Actions {
+			if c.Name != a.Name {
+				continue
+			}
+
+			switch a.Type {
+			case replaceAction:
+				c.Value = a.Value
+				transformedRequest.AddCookie(c)
+				continue OUTER
+
+			case deleteAction:
+				continue OUTER
+			}
+		}
+
+		transformedRequest.AddCookie(c)
+	}
+
+	in.String = transformedRequest.Header.Get("Cookie")
+
+	return in
+}
+
 // Interface guards
 var (
 	_ LogFieldFilter = (*DeleteFilter)(nil)
 	_ LogFieldFilter = (*ReplaceFilter)(nil)
 	_ LogFieldFilter = (*IPMaskFilter)(nil)
 	_ LogFieldFilter = (*QueryFilter)(nil)
+	_ LogFieldFilter = (*CookieFilter)(nil)
 
 	_ caddyfile.Unmarshaler = (*DeleteFilter)(nil)
 	_ caddyfile.Unmarshaler = (*ReplaceFilter)(nil)
 	_ caddyfile.Unmarshaler = (*IPMaskFilter)(nil)
 	_ caddyfile.Unmarshaler = (*QueryFilter)(nil)
+	_ caddyfile.Unmarshaler = (*CookieFilter)(nil)
 
 	_ caddy.Provisioner = (*IPMaskFilter)(nil)
 
diff --git a/modules/logging/filters_test.go b/modules/logging/filters_test.go
index 883a13844..6871bea0c 100644
--- a/modules/logging/filters_test.go
+++ b/modules/logging/filters_test.go
@@ -39,3 +39,31 @@ func TestValidateQueryFilter(t *testing.T) {
 		t.Fatalf("unknown action type must be invalid")
 	}
 }
+
+func TestCookieFilter(t *testing.T) {
+	f := CookieFilter{[]cookieFilterAction{
+		{replaceAction, "foo", "REDACTED"},
+		{deleteAction, "bar", ""},
+	}}
+
+	out := f.Filter(zapcore.Field{String: "foo=a; foo=b; bar=c; bar=d; baz=e"})
+	if out.String != "foo=REDACTED; foo=REDACTED; baz=e" {
+		t.Fatalf("cookies have not been filtered: %s", out.String)
+	}
+}
+
+func TestValidateCookieFilter(t *testing.T) {
+	f := CookieFilter{[]cookieFilterAction{
+		{},
+	}}
+	if f.Validate() == nil {
+		t.Fatalf("empty action type must be invalid")
+	}
+
+	f = CookieFilter{[]cookieFilterAction{
+		{Type: "foo"},
+	}}
+	if f.Validate() == nil {
+		t.Fatalf("unknown action type must be invalid")
+	}
+}