Repository: gotenberg/gotenberg

Issue:
Feature request: Add permission passwords to PDF
Would it be possible to add permission passwords to the generated PDFs? 

https://helpx.adobe.com/acrobat/using/securing-pdfs-passwords.html

Hints from issue comments:
@janswist: I'm also interested in that feature! Any update if it's coming (or how to extract it from existing modules if they are already there) @gulien ? 

So far I found one from unoconv: https://github.com/unoconv/unoconv/issues/104@danielkorytek: @gulien Any update?@gulien: @janswist @gulien I'd like to implement it indeed, but I do not have enough free time on my side right now. In other words, I can only do minimal maintainability. 😕

Solution patch:
diff --git a/Makefile b/Makefile
index f726cdd..44ed3d9 100644
--- a/Makefile
+++ b/Makefile
@@ -62,6 +62,7 @@ PDFENGINES_FLATTEN_ENGINES=qpdf
 PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
 PDFENGINES_READ_METADATA_ENGINES=exiftool
 PDFENGINES_WRITE_METADATA_ENGINES=exiftool
+PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
 PDFENGINES_DISABLE_ROUTES=false
 PROMETHEUS_NAMESPACE=gotenberg
 PROMETHEUS_COLLECT_INTERVAL=1s
@@ -136,6 +137,7 @@ run: ## Start a Gotenberg container
 	--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
 	--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
 	--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
+	--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
 	--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
 	--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
 	--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
diff --git a/go.mod b/go.mod
index 91ef7c9..198064e 100644
--- a/go.mod
+++ b/go.mod
@@ -20,7 +20,7 @@ require (
 	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/prometheus/client_golang v1.23.0
 	github.com/shirou/gopsutil/v4 v4.25.8
-	github.com/spf13/pflag v1.0.9
+	github.com/spf13/pflag v1.0.10
 	github.com/testcontainers/testcontainers-go v0.38.0
 	go.uber.org/multierr v1.11.0
 	go.uber.org/zap v1.27.0
@@ -67,6 +67,7 @@ require (
 	github.com/gofrs/uuid v4.4.0+incompatible // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
+	github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
 	github.com/hashicorp/go-memdb v1.3.5 // indirect
@@ -97,7 +98,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/prometheus/client_model v0.6.2 // indirect
-	github.com/prometheus/common v0.65.0 // indirect
+	github.com/prometheus/common v0.66.0 // indirect
 	github.com/prometheus/procfs v0.17.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/sorairolake/lzip-go v0.3.8 // indirect
@@ -123,5 +124,6 @@ require (
 	google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
 	google.golang.org/protobuf v1.36.8 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 072ffaa..6e24f12 100644
--- a/go.sum
+++ b/go.sum
@@ -160,6 +160,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
+github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -269,8 +271,8 @@ github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
-github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY=
+github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4=
 github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
 github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -289,8 +291,8 @@ github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZ
 github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
-github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -549,6 +551,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go
index 458216f..faa8574 100644
--- a/pkg/gotenberg/mocks.go
+++ b/pkg/gotenberg/mocks.go
@@ -52,6 +52,7 @@ type PdfEngineMock struct {
 	ConvertMock       func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
 	ReadMetadataMock  func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
 	WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
+	EncryptMock       func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
 }
 
 func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
@@ -78,6 +79,10 @@ func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logg
 	return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
 }
 
+func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
+}
+
 // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
 type PdfEngineProviderMock struct {
 	PdfEngineMock func() (PdfEngine, error)
diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go
index bb9d43a..e1e37ed 100644
--- a/pkg/gotenberg/pdfengine.go
+++ b/pkg/gotenberg/pdfengine.go
@@ -3,6 +3,7 @@ package gotenberg
 import (
 	"context"
 	"errors"
+	"fmt"
 
 	"go.uber.org/zap"
 )
@@ -23,8 +24,31 @@ var (
 	// ErrPdfEngineMetadataValueNotSupported is returned when a metadata value
 	// is not supported.
 	ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported")
+
+	// ErrPdfEncryptionNotSupported is returned when encryption
+	// is not supported by the PDF engine.
+	ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
 )
 
+// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
+// invalid arguments are provided. It includes the name of the engine and a
+// detailed message describing the issue.
+type PdfEngineInvalidArgsError struct {
+	engine string
+	msg    string
+}
+
+// Error implements the error interface.
+func (e *PdfEngineInvalidArgsError) Error() string {
+	return fmt.Sprintf("%s: %s", e.engine, e.msg)
+}
+
+// NewPdfEngineInvalidArgs creates a new PdfEngineInvalidArgsError with the
+// given engine name and message.
+func NewPdfEngineInvalidArgs(engine, msg string) error {
+	return &PdfEngineInvalidArgsError{engine, msg}
+}
+
 const (
 	// SplitModeIntervals represents a mode where a PDF is split at specific
 	// intervals.
@@ -100,7 +124,7 @@ type PdfEngine interface {
 
 	// Flatten merges existing annotation appearances with page content,
 	// effectively deleting the original annotations. This process can flatten
-	// forms as well, as forms share a relationship with annotations. Note that
+	// forms as well as forms share a relationship with annotations. Note that
 	// this operation is irreversible.
 	Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
 
@@ -113,6 +137,12 @@ type PdfEngine interface {
 
 	// WriteMetadata writes the metadata into a given PDF file.
 	WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
+
+	// Encrypt adds password protection to a PDF file.
+	// The userPassword is required to open the document.
+	// The ownerPassword provides full access to the document.
+	// If the ownerPassword is empty, it defaults to the userPassword.
+	Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
 }
 
 // PdfEngineProvider offers an interface to instantiate a [PdfEngine].
diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go
index 176d6f4..a9149ef 100644
--- a/pkg/modules/api/middlewares.go
+++ b/pkg/modules/api/middlewares.go
@@ -61,6 +61,11 @@ func ParseError(err error) (int, string) {
 		return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
 	}
 
+	var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
+	if errors.As(err, &invalidArgsError) {
+		return http.StatusBadRequest, invalidArgsError.Error()
+	}
+
 	var httpErr HttpError
 	if errors.As(err, &httpErr) {
 		return httpErr.HttpError()
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index 080837d..56d6729 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -332,6 +332,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			mode := pdfengines.FormDataPdfSplitMode(form, false)
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
+			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
 
 			var url string
 			err := form.
@@ -341,7 +342,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 				return fmt.Errorf("validate form data: %w", err)
 			}
 
-			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
+			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
 			if err != nil {
 				return fmt.Errorf("convert URL to PDF: %w", err)
 			}
@@ -393,6 +394,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			mode := pdfengines.FormDataPdfSplitMode(form, false)
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
+			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
 
 			var inputPath string
 			err := form.
@@ -403,7 +405,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			}
 
 			url := fmt.Sprintf("file://%s", inputPath)
-			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
+			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
 			if err != nil {
 				return fmt.Errorf("convert HTML to PDF: %w", err)
 			}
@@ -456,6 +458,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			mode := pdfengines.FormDataPdfSplitMode(form, false)
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
+			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
 
 			var (
 				inputPath     string
@@ -475,7 +478,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 				return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
 			}
 
-			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
+			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
 			if err != nil {
 				return fmt.Errorf("convert markdown to PDF: %w", err)
 			}
@@ -599,7 +602,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
 	return fmt.Sprintf("file://%s", inputPath), nil
 }
 
-func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
+func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string) error {
 	outputPath := ctx.GeneratePath(".pdf")
 	// See https://github.com/gotenberg/gotenberg/issues/1130.
 	filename := ctx.OutputFilename(outputPath)
@@ -656,6 +659,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
 		return fmt.Errorf("write metadata: %w", err)
 	}
 
+	err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
+	if err != nil {
+		return fmt.Errorf("encrypt PDFs: %w", err)
+	}
+
 	zeroValuedSplitMode := gotenberg.SplitMode{}
 	zeroValuedPdfFormats := gotenberg.PdfFormats{}
 	if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go
index 6127035..ae33e88 100644
--- a/pkg/modules/exiftool/exiftool.go
+++ b/pkg/modules/exiftool/exiftool.go
@@ -176,6 +176,11 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
 	return nil
 }
 
+// Encrypt is not available in this implementation.
+func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*ExifTool)(nil)
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go
index 94dfc4f..e47d9b8 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go
@@ -91,6 +91,11 @@ func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *z
 	return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// Encrypt is not available in this implementation.
+func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*LibreOfficePdfEngine)(nil)
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index ceaf282..7acaea7 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -30,6 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 			splitMode := pdfengines.FormDataPdfSplitMode(form, false)
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
+			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
 
 			zeroValuedSplitMode := gotenberg.SplitMode{}
 
@@ -263,6 +264,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 				}
 			}
 
+			err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
+			if err != nil {
+				return fmt.Errorf("encrypt PDFs: %w", err)
+			}
+
 			if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
 				// If .zip archive, document.docx -> document.docx.pdf.
 				for i, inputPath := range inputPaths {
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index 50b7acb..97e25f5 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -171,6 +171,37 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
 	return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// Encrypt adds password protection to a PDF file using pdfcpu.
+func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	if userPassword == "" {
+		return errors.New("user password cannot be empty")
+	}
+
+	if ownerPassword == "" {
+		ownerPassword = userPassword
+	}
+
+	var args []string
+	args = append(args, "encrypt")
+	args = append(args, "-mode", "aes")
+	args = append(args, "-upw", userPassword)
+	args = append(args, "-opw", ownerPassword)
+	args = append(args, "-perm", "all")
+	args = append(args, inputPath, inputPath)
+
+	cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+	if err != nil {
+		return fmt.Errorf("create command: %w", err)
+	}
+
+	_, err = cmd.Exec()
+	if err != nil {
+		return fmt.Errorf("encrypt PDF with pdfcpu: %w", err)
+	}
+
+	return nil
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*PdfCpu)(nil)
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index 4347a0d..78a5277 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -18,6 +18,7 @@ type multiPdfEngines struct {
 	convertEngines       []gotenberg.PdfEngine
 	readMetadataEngines  []gotenberg.PdfEngine
 	writeMetadataEngines []gotenberg.PdfEngine
+	passwordEngines      []gotenberg.PdfEngine
 }
 
 func newMultiPdfEngines(
@@ -26,7 +27,8 @@ func newMultiPdfEngines(
 	flattenEngines,
 	convertEngines,
 	readMetadataEngines,
-	writeMetadataEngines []gotenberg.PdfEngine,
+	writeMetadataEngines,
+	passwordEngines []gotenberg.PdfEngine,
 ) *multiPdfEngines {
 	return &multiPdfEngines{
 		mergeEngines:         mergeEngines,
@@ -35,11 +37,12 @@ func newMultiPdfEngines(
 		convertEngines:       convertEngines,
 		readMetadataEngines:  readMetadataEngines,
 		writeMetadataEngines: writeMetadataEngines,
+		passwordEngines:      passwordEngines,
 	}
 }
 
-// Merge tries to merge the given PDFs into a unique PDF thanks to its
-// children. If the context is done, it stops and returns an error.
+// Merge combines multiple PDF files into a single document using the first
+// available engine that supports PDF merging.
 func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
 	var err error
 	errChan := make(chan error, 1)
@@ -68,8 +71,8 @@ type splitResult struct {
 	err         error
 }
 
-// Split tries to split at intervals a given PDF thanks to its children. If the
-// context is done, it stops and returns an error.
+// Split divides the PDF into separate pages using the first available engine
+// that supports PDF splitting.
 func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
 	var err error
 	var mu sync.Mutex // to safely append errors.
@@ -99,8 +102,8 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
 	return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
 }
 
-// Flatten merges existing annotation appearances with page content, thanks to
-// its children. If the context is done, it stops and returns an error
+// Flatten merges existing annotation appearances with page content using the
+// first available engine that supports flattening.
 func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
 	var err error
 	errChan := make(chan error, 1)
@@ -124,8 +127,8 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i
 	return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
 }
 
-// Convert converts the given PDF to a specific PDF format, thanks to its
-// children. If the context is done, it stops and returns an error.
+// Convert transforms the given PDF to a specific PDF format using the first
+// available engine that supports PDF conversion.
 func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
 	var err error
 	errChan := make(chan error, 1)
@@ -154,6 +157,8 @@ type readMetadataResult struct {
 	err      error
 }
 
+// ReadMetadata extracts metadata from a PDF file using the first available
+// engine that supports metadata reading.
 func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
 	var err error
 	var mu sync.Mutex // to safely append errors.
@@ -183,6 +188,8 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
 	return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
 }
 
+// WriteMetadata embeds metadata into a PDF file using the first available
+// engine that supports metadata writing.
 func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
 	var err error
 	errChan := make(chan error, 1)
@@ -206,6 +213,31 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
 	return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
 }
 
+// Encrypt adds password protection to a PDF file using the first available
+// engine that supports password protection.
+func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	var err error
+	errChan := make(chan error, 1)
+
+	for _, engine := range multi.passwordEngines {
+		go func(engine gotenberg.PdfEngine) {
+			errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword)
+		}(engine)
+
+		select {
+		case protectErr := <-errChan:
+			errored := multierr.AppendInto(&err, protectErr)
+			if !errored {
+				return nil
+			}
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+
+	return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index c73ea85..8e3bc17 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -33,6 +33,7 @@ type PdfEngines struct {
 	convertNames       []string
 	readMetadataNames  []string
 	writeMetadataNames []string
+	encryptNames       []string
 	engines            []gotenberg.PdfEngine
 	disableRoutes      bool
 }
@@ -49,6 +50,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
 			fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
 			fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
 			fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
+			fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
 			fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
 
 			// Deprecated flags.
@@ -74,6 +76,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 	convertNames := flags.MustStringSlice("pdfengines-convert-engines")
 	readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
 	writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
+	encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
 	mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
 
 	engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -130,6 +133,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 		mod.writeMetadataNames = writeMetadataNames
 	}
 
+	mod.encryptNames = defaultNames
+	if len(encryptNames) > 0 {
+		mod.encryptNames = encryptNames
+	}
+
 	return nil
 }
 
@@ -183,6 +191,7 @@ func (mod *PdfEngines) Validate() error {
 	findNonExistingEngines(mod.convertNames)
 	findNonExistingEngines(mod.readMetadataNames)
 	findNonExistingEngines(mod.writeMetadataNames)
+	findNonExistingEngines(mod.encryptNames)
 
 	if len(nonExistingEngines) == 0 {
 		return nil
@@ -201,6 +210,7 @@ func (mod *PdfEngines) SystemMessages() []string {
 		fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
 		fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
 		fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
+		fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")),
 	}
 }
 
@@ -227,6 +237,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
 		engines(mod.convertNames),
 		engines(mod.readMetadataNames),
 		engines(mod.writeMetadataNames),
+		engines(mod.encryptNames),
 	), nil
 }
 
@@ -250,6 +261,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
 		convertRoute(engine),
 		readMetadataRoute(engine),
 		writeMetadataRoute(engine),
+		encryptRoute(engine),
 	}, nil
 }
 
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index a316142..ce9135c 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -254,6 +254,29 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
 	return nil
 }
 
+// FormDataPdfEncrypt extracts encryption parameters from form data.
+func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
+	form.String("userPassword", &userPassword, "")
+	form.String("ownerPassword", &ownerPassword, "")
+	return userPassword, ownerPassword
+}
+
+// EncryptPdfStub adds password protection to PDF files.
+func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
+	if userPassword == "" {
+		return nil
+	}
+
+	for _, inputPath := range inputPaths {
+		err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
+		if err != nil {
+			return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
+		}
+	}
+
+	return nil
+}
+
 // mergeRoute returns an [api.Route] which can merge PDFs.
 func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 	return api.Route{
@@ -266,6 +289,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 			form := ctx.FormData()
 			pdfFormats := FormDataPdfFormats(form)
 			metadata := FormDataPdfMetadata(form, false)
+			userPassword, ownerPassword := FormDataPdfEncrypt(form)
 
 			var inputPaths []string
 			var flatten bool
@@ -300,6 +324,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 				}
 			}
 
+			err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
+			if err != nil {
+				return fmt.Errorf("encrypt PDFs: %w", err)
+			}
+
 			err = ctx.AddOutputPaths(outputPaths...)
 			if err != nil {
 				return fmt.Errorf("add output paths: %w", err)
@@ -323,6 +352,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
 			mode := FormDataPdfSplitMode(form, true)
 			pdfFormats := FormDataPdfFormats(form)
 			metadata := FormDataPdfMetadata(form, false)
+			userPassword, ownerPassword := FormDataPdfEncrypt(form)
 
 			var inputPaths []string
 			var flatten bool
@@ -356,6 +386,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
 				}
 			}
 
+			err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
+			if err != nil {
+				return fmt.Errorf("encrypt PDFs: %w", err)
+			}
+
 			zeroValuedSplitMode := gotenberg.SplitMode{}
 			zeroValuedPdfFormats := gotenberg.PdfFormats{}
 			if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
@@ -456,7 +491,6 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
 					if err != nil {
 						return fmt.Errorf("rename output path: %w", err)
 					}
-
 					outputPaths[i] = inputPath
 				}
 			}
@@ -548,3 +582,41 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
 		},
 	}
 }
+
+// encryptRoute returns an [api.Route] which can add password protection to PDFs.
+func encryptRoute(engine gotenberg.PdfEngine) api.Route {
+	return api.Route{
+		Method:      http.MethodPost,
+		Path:        "/forms/pdfengines/encrypt",
+		IsMultipart: true,
+		Handler: func(c echo.Context) error {
+			ctx := c.Get("context").(*api.Context)
+
+			form := ctx.FormData()
+
+			var inputPaths []string
+			var userPassword string
+			var ownerPassword string
+			err := form.
+				MandatoryPaths([]string{".pdf"}, &inputPaths).
+				MandatoryString("userPassword", &userPassword).
+				String("ownerPassword", &ownerPassword, "").
+				Validate()
+			if err != nil {
+				return fmt.Errorf("validate form data: %w", err)
+			}
+
+			err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths)
+			if err != nil {
+				return fmt.Errorf("encrypt PDFs: %w", err)
+			}
+
+			err = ctx.AddOutputPaths(inputPaths...)
+			if err != nil {
+				return fmt.Errorf("add output paths: %w", err)
+			}
+
+			return nil
+		},
+	}
+}
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index b2831dc..655cbd4 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -145,6 +145,44 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, meta
 	return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// Encrypt adds password protection to a PDF file using PDFtk.
+func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	if userPassword == "" {
+		return errors.New("user password cannot be empty")
+	}
+
+	if ownerPassword == userPassword || ownerPassword == "" {
+		return gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
+	}
+
+	// Create a temp output file in the same directory.
+	tmpPath := inputPath + ".tmp"
+
+	var args []string
+	args = append(args, inputPath)
+	args = append(args, "output", tmpPath)
+	args = append(args, "encrypt_128bit")
+	args = append(args, "user_pw", userPassword)
+	args = append(args, "owner_pw", ownerPassword)
+
+	cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+	if err != nil {
+		return fmt.Errorf("create command: %w", err)
+	}
+
+	_, err = cmd.Exec()
+	if err != nil {
+		return fmt.Errorf("encrypt PDF with PDFtk: %w", err)
+	}
+
+	err = os.Rename(tmpPath, inputPath)
+	if err != nil {
+		return fmt.Errorf("rename temporary output file with input file: %w", err)
+	}
+
+	return nil
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*PdfTk)(nil)
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index 7a65991..f2aa2e3 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -172,6 +172,35 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metad
 	return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// Encrypt adds password protection to a PDF file using QPDF.
+func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+	if userPassword == "" {
+		return errors.New("user password cannot be empty")
+	}
+
+	if ownerPassword == "" {
+		ownerPassword = userPassword
+	}
+
+	var args []string
+	args = append(args, inputPath)
+	args = append(args, engine.globalArgs...)
+	args = append(args, "--replace-input")
+	args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--")
+
+	cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+	if err != nil {
+		return fmt.Errorf("create command: %w", err)
+	}
+
+	_, err = cmd.Exec()
+	if err != nil {
+		return fmt.Errorf("encrypt PDF with QPDF: %w", err)
+	}
+
+	return nil
+}
+
 var (
 	_ gotenberg.Module      = (*QPdf)(nil)
 	_ gotenberg.Provisioner = (*QPdf)(nil)



Test patch:
diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go
index f5d5b21..496a440 100644
--- a/pkg/modules/pdfengines/multi_test.go
+++ b/pkg/modules/pdfengines/multi_test.go
@@ -103,6 +103,97 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
 	}
 }
 
+func TestMultiPdfEngines_Encrypt(t *testing.T) {
+	for _, tc := range []struct {
+		scenario    string
+		engine      *multiPdfEngines
+		ctx         context.Context
+		expectError bool
+	}{
+		{
+			scenario: "nominal behavior",
+			engine: &multiPdfEngines{
+				passwordEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+							return nil
+						},
+					},
+				},
+			},
+			ctx: context.Background(),
+		},
+		{
+			scenario: "at least one engine does not return an error",
+			engine: &multiPdfEngines{
+				passwordEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+							return errors.New("foo")
+						},
+					},
+					&gotenberg.PdfEngineMock{
+						EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+							return nil
+						},
+					},
+				},
+			},
+			ctx: context.Background(),
+		},
+		{
+			scenario: "all engines return an error",
+			engine: &multiPdfEngines{
+				passwordEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+							return errors.New("foo")
+						},
+					},
+					&gotenberg.PdfEngineMock{
+						EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+							return errors.New("foo")
+						},
+					},
+				},
+			},
+			ctx:         context.Background(),
+			expectError: true,
+		},
+		{
+			scenario: "context expired",
+			engine: &multiPdfEngines{
+				passwordEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+							return nil
+						},
+					},
+				},
+			},
+			ctx: func() context.Context {
+				ctx, cancel := context.WithCancel(context.Background())
+				cancel()
+
+				return ctx
+			}(),
+			expectError: true,
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			err := tc.engine.Encrypt(tc.ctx, zap.NewNop(), "", "", "")
+
+			if !tc.expectError && err != nil {
+				t.Fatalf("expected no error but got: %v", err)
+			}
+
+			if tc.expectError && err == nil {
+				t.Fatal("expected error but got none")
+			}
+		})
+	}
+}
+
 func TestMultiPdfEngines_Split(t *testing.T) {
 	for _, tc := range []struct {
 		scenario    string
diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature
index ab77d1b..10c8490 100644
--- a/test/integration/features/chromium_convert_html.feature
+++ b/test/integration/features/chromium_convert_html.feature
@@ -815,6 +815,28 @@ Feature: /forms/chromium/convert/html
     Then there should be 1 PDF(s) in the response
     Then the response PDF(s) should be flatten
 
+  Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
+      | files        | testdata/page-1-html/index.html | file  |
+      | userPassword | foo                             | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/chromium/convert/html (Encrypt - both user and owner passwords)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
+      | files         | testdata/page-1-html/index.html | file  |
+      | userPassword  | foo                             | field |
+      | ownerPassword | bar                             | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
   Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature
index aa50c65..349d52a 100644
--- a/test/integration/features/chromium_convert_markdown.feature
+++ b/test/integration/features/chromium_convert_markdown.feature
@@ -939,6 +939,30 @@ Feature: /forms/chromium/convert/markdown
     Then there should be 1 PDF(s) in the response
     Then the response PDF(s) should be flatten
 
+  Scenario: POST /forms/chromium/convert/markdown (Encrypt - user password only)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
+      | files        | testdata/page-1-markdown/index.html | file  |
+      | files        | testdata/page-1-markdown/page_1.md  | file  |
+      | userPassword | foo                                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/chromium/convert/markdown (Encrypt - both user and owner passwords)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
+      | files         | testdata/page-1-markdown/index.html | file  |
+      | files         | testdata/page-1-markdown/page_1.md  | file  |
+      | userPassword  | foo                                 | field |
+      | ownerPassword | bar                                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
   Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature
index 6bcae01..3d5f772 100644
--- a/test/integration/features/chromium_convert_url.feature
+++ b/test/integration/features/chromium_convert_url.feature
@@ -898,6 +898,30 @@ Feature: /forms/chromium/convert/url
     Then there should be 1 PDF(s) in the response
     Then the response PDF(s) should be flatten
 
+  Scenario: POST /forms/chromium/convert/url (Encrypt - user password only)
+    Given I have a default Gotenberg container
+    Given I have a static server
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+      | url          | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+      | userPassword | foo                                                                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/chromium/convert/url (Encrypt - both user and owner passwords)
+    Given I have a default Gotenberg container
+    Given I have a static server
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+      | url           | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+      | userPassword  | foo                                                                 | field |
+      | ownerPassword | bar                                                                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
   Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
     Given I have a default Gotenberg container
     Given I have a static server
diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature
index 8a96a93..0001369 100644
--- a/test/integration/features/libreoffice_convert.feature
+++ b/test/integration/features/libreoffice_convert.feature
@@ -526,6 +526,28 @@ Feature: /forms/libreoffice/convert
     Then there should be 1 PDF(s) in the response
     Then the response PDF(s) should be flatten
 
+  Scenario: POST /forms/libreoffice/convert (Encrypt - user password only)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.docx | file  |
+      | userPassword | foo                  | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/libreoffice/convert (Encrypt - both user and owner passwords)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+      | files         | testdata/page_1.docx | file  |
+      | userPassword  | foo                  | field |
+      | ownerPassword | bar                  | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
   Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
@@ -533,7 +555,6 @@ Feature: /forms/libreoffice/convert
       | pdfa                      | PDF/A-1b                                                                                                                                                                                                                                                                                                  | field  |
       | pdfua                     | true                                                                                                                                                                                                                                                                                                      | field  |
       | metadata                  | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field  |
-      | flatten                   | true                                                                                                                                                                                                                                                                                                      | field  |
       | Gotenberg-Output-Filename | foo                                                                                                                                                                                                                                                                                                       | header |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature
new file mode 100644
index 0000000..6ed48f1
--- /dev/null
+++ b/test/integration/features/pdfengines_encrypt.feature
@@ -0,0 +1,177 @@
+Feature: /forms/pdfengines/encrypt
+
+  Scenario: POST /forms/pdfengines/encrypt (default - user password only)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (default - both user and owner passwords)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files         | testdata/page_1.pdf | file  |
+      | userPassword  | foo                 | field |
+      | ownerPassword | bar                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (QPDF - user password only)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_ENCRYPT_ENGINES | qpdf |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (QPDF - both user and owner passwords)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_ENCRYPT_ENGINES | qpdf |
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files         | testdata/page_1.pdf | file  |
+      | userPassword  | foo                 | field |
+      | ownerPassword | bar                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (PDFtk - user password only)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_ENCRYPT_ENGINES | pdftk |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 400
+    Then the response body should match string:
+      """
+      pdftk: both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow
+      """
+
+  Scenario: POST /forms/pdfengines/encrypt (PDFtk - both user and owner passwords)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_ENCRYPT_ENGINES | pdftk |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files         | testdata/page_1.pdf | file  |
+      | userPassword  | foo                 | field |
+      | ownerPassword | bar                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (pdfcpu - user password only)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_ENCRYPT_ENGINES | pdfcpu |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (pdfcpu - both user and owner passwords)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_ENCRYPT_ENGINES | pdfcpu |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files         | testdata/page_1.pdf | file  |
+      | userPassword  | foo                 | field |
+      | ownerPassword | bar                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (Many PDFs)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | files        | testdata/page_2.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/zip"
+    Then there should be 2 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (Bad Request)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files | testdata/page_1.pdf | file |
+    Then the response status code should be 400
+    Then the response body should match string:
+      """
+      Invalid form data: form field 'userPassword' is required
+      """
+
+  Scenario: POST /forms/pdfengines/encrypt (Routes Disabled)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_DISABLE_ROUTES | true |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files | testdata/page_1.pdf | file |
+    Then the response status code should be 404
+
+  Scenario: POST /forms/pdfengines/encrypt (Gotenberg Trace)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files           | testdata/page_1.pdf      | file   |
+      | userPassword    | foo                      | field  |
+      | Gotenberg-Trace | forms_pdfengines_encrypt | header |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt"
+    Then the Gotenberg container should log the following entries:
+      | "trace":"forms_pdfengines_encrypt" |
+
+  Scenario: POST /forms/pdfengines/encrypt (Download From)
+    Given I have a default Gotenberg container
+    Given I have a static server
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+      | userPassword | foo                                                                                                      | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (Webhook)
+    Given I have a default Gotenberg container
+    Given I have a webhook server
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files                       | testdata/page_1.pdf                          | file   |
+      | userPassword                | foo                                          | field  |
+      | Gotenberg-Webhook-Url       | http://host.docker.internal:%d/webhook       | header |
+      | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+    Then the response status code should be 204
+    When I wait for the asynchronous request to the webhook
+    Then the webhook request header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the webhook request
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/encrypt (Basic Auth)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | API_ENABLE_BASIC_AUTH             | true |
+      | GOTENBERG_API_BASIC_AUTH_USERNAME | foo  |
+      | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar  |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files | testdata/page_1.pdf | file |
+    Then the response status code should be 401
+
+  Scenario: POST /foo/forms/pdfengines/encrypt (Root Path)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | API_ENABLE_DEBUG_ROUTE | true  |
+      | API_ROOT_PATH          | /foo/ |
+    When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_flatten.feature b/test/integration/features/pdfengines_flatten.feature
index 521a93a..96804db 100644
--- a/test/integration/features/pdfengines_flatten.feature
+++ b/test/integration/features/pdfengines_flatten.feature
@@ -79,6 +79,7 @@ Feature: /forms/pdfengines/flatten
     Then the file request header "X-Foo" should be "bar"
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
+    Then the response PDF(s) should be flatten
 
   Scenario: POST /forms/pdfengines/flatten (Webhook)
     Given I have a default Gotenberg container
diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature
index 248a81a..200e503 100644
--- a/test/integration/features/pdfengines_merge.feature
+++ b/test/integration/features/pdfengines_merge.feature
@@ -1,6 +1,6 @@
 Feature: /forms/pdfengines/merge
 
-  Scenario: POST /forms/pdfengines/merge (default - QPDF)
+  Scenario: POST /forms/pdfengines/merge (default)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
       | files                     | testdata/page_1.pdf | file   |
@@ -21,6 +21,28 @@ Feature: /forms/pdfengines/merge
       Page 2
       """
 
+  Scenario: POST /forms/pdfengines/merge (QPDF)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_MERGE_ENGINES | qpdf |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
+      | files                     | testdata/page_1.pdf | file   |
+      | files                     | testdata/page_2.pdf | file   |
+      | Gotenberg-Output-Filename | foo                 | header |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then there should be the following file(s) in the response:
+      | foo.pdf |
+    Then the "foo.pdf" PDF should have 2 page(s)
+    Then the "foo.pdf" PDF should have the following content at page 1:
+      """
+      Page 1
+      """
+    Then the "foo.pdf" PDF should have the following content at page 2:
+      """
+      Page 2
+      """
+
   Scenario: POST /forms/pdfengines/merge (pdfcpu)
     Given I have a Gotenberg container with the following environment variable(s):
       | PDFENGINES_MERGE_ENGINES | pdfcpu |
@@ -199,6 +221,30 @@ Feature: /forms/pdfengines/merge
       """
     Then the response PDF(s) should be flatten
 
+  Scenario: POST /forms/pdfengines/merge (Encrypt - user password only)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf | file  |
+      | files        | testdata/page_2.pdf | file  |
+      | userPassword | foo                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/merge (Encrypt - both user and owner passwords)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
+      | files         | testdata/page_1.pdf | file  |
+      | files         | testdata/page_2.pdf | file  |
+      | userPassword  | foo                 | field |
+      | ownerPassword | bar                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
   Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature
index d9813bf..387ef4a 100644
--- a/test/integration/features/pdfengines_split.feature
+++ b/test/integration/features/pdfengines_split.feature
@@ -1,6 +1,6 @@
 Feature: /forms/pdfengines/split
 
-  Scenario: POST /forms/pdfengines/split (Intervals - Default - pdfcpu)
+  Scenario: POST /forms/pdfengines/split (Intervals - Default)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
       | files     | testdata/pages_3.pdf | file  |
@@ -27,7 +27,7 @@ Feature: /forms/pdfengines/split
       Page 3
       """
 
-  Scenario: POST /forms/pdfengines/split (Pages - Default - pdfcpu)
+  Scenario: POST /forms/pdfengines/split (Pages - Default)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
       | files     | testdata/pages_3.pdf | file  |
@@ -50,7 +50,7 @@ Feature: /forms/pdfengines/split
       Page 3
       """
 
-  Scenario: POST /forms/pdfengines/split (Pages & Unify - Default - pdfcpu)
+  Scenario: POST /forms/pdfengines/split (Pages & Unify - Default)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
       | files      | testdata/pages_3.pdf | file  |
@@ -72,6 +72,81 @@ Feature: /forms/pdfengines/split
       Page 3
       """
 
+  Scenario: POST /forms/pdfengines/split (Intervals - pdfcpu)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_SPLIT_ENGINES | pdfcpu |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+      | files     | testdata/pages_3.pdf | file  |
+      | splitMode | intervals            | field |
+      | splitSpan | 2                    | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/zip"
+    Then there should be 2 PDF(s) in the response
+    Then there should be the following file(s) in the response:
+      | pages_3_0.pdf |
+      | pages_3_1.pdf |
+    Then the "pages_3_0.pdf" PDF should have 2 page(s)
+    Then the "pages_3_1.pdf" PDF should have 1 page(s)
+    Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+      """
+      Page 1
+      """
+    Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+      """
+      Page 2
+      """
+    Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+      """
+      Page 3
+      """
+
+  Scenario: POST /forms/pdfengines/split (Pages - pdfcpu)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_SPLIT_ENGINES | pdfcpu |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+      | files     | testdata/pages_3.pdf | file  |
+      | splitMode | pages                | field |
+      | splitSpan | 2-                   | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/zip"
+    Then there should be 2 PDF(s) in the response
+    Then there should be the following file(s) in the response:
+      | pages_3_0.pdf |
+      | pages_3_1.pdf |
+    Then the "pages_3_0.pdf" PDF should have 1 page(s)
+    Then the "pages_3_1.pdf" PDF should have 1 page(s)
+    Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+      """
+      Page 2
+      """
+    Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+      """
+      Page 3
+      """
+
+  Scenario: POST /forms/pdfengines/split (Pages & Unify - pdfcpu)
+    Given I have a Gotenberg container with the following environment variable(s):
+      | PDFENGINES_SPLIT_ENGINES | pdfcpu |
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+      | files      | testdata/pages_3.pdf | file  |
+      | splitMode  | pages                | field |
+      | splitSpan  | 2-                   | field |
+      | splitUnify | true                 | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/pdf"
+    Then there should be 1 PDF(s) in the response
+    Then there should be the following file(s) in the response:
+      | pages_3.pdf |
+    Then the "pages_3.pdf" PDF should have 2 page(s)
+    Then the "pages_3.pdf" PDF should have the following content at page 1:
+      """
+      Page 2
+      """
+    Then the "pages_3.pdf" PDF should have the following content at page 2:
+      """
+      Page 3
+      """
+
   Scenario: POST /forms/pdfengines/split (Pages & Unify - QPDF)
     Given I have a Gotenberg container with the following environment variable(s):
       | PDFENGINES_SPLIT_ENGINES | qpdf |
@@ -365,6 +440,32 @@ Feature: /forms/pdfengines/split
       """
     Then the response PDF(s) should be flatten
 
+  Scenario: POST /forms/pdfengines/split (Encrypt - user password only)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+      | files        | testdata/pages_3.pdf | file  |
+      | splitMode    | intervals            | field |
+      | splitSpan    | 2                    | field |
+      | userPassword | foo                  | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/zip"
+    Then there should be 2 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  Scenario: POST /forms/pdfengines/split (Encrypt - both user and owner passwords)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+      | files         | testdata/pages_3.pdf | file  |
+      | splitMode     | intervals            | field |
+      | splitSpan     | 2                    | field |
+      | userPassword  | foo                  | field |
+      | ownerPassword | bar                  | field |
+    Then the response status code should be 200
+    Then the response header "Content-Type" should be "application/zip"
+    Then there should be 2 PDF(s) in the response
+    Then the response PDF(s) should be encrypted
+
+  # FIXME: once decrypt is done, add encrypt and check after the content of the PDFs.
   Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
     Given I have a default Gotenberg container
     When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go
index a8f31a9..4852e38 100644
--- a/test/integration/scenario/scenario.go
+++ b/test/integration/scenario/scenario.go
@@ -580,6 +580,7 @@ func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kin
 		return fmt.Errorf("unknown %q", validate)
 	}
 
+	re := regexp.MustCompile(`failedRules="(\d+)"`)
 	for _, path := range paths {
 		cmd := []string{
 			"verapdf",
@@ -593,9 +594,7 @@ func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kin
 			return fmt.Errorf("exec %q: %w", cmd, err)
 		}
 
-		re := regexp.MustCompile(`failedRules="(\d+)"`)
 		matches := re.FindStringSubmatch(output)
-
 		if len(matches) < 2 {
 			return errors.New("expected failed rules")
 		}
@@ -812,6 +811,8 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
 		return fmt.Errorf("walk %q: %w", s.workdir, err)
 	}
 
+	invert := should == "should NOT"
+
 	for _, path := range paths {
 		cmd := []string{
 			"verapdf",
@@ -826,7 +827,6 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
 			return fmt.Errorf("exec %q: %w", cmd, err)
 		}
 
-		invert := should == "should NOT"
 		if invert && strings.Contains(output, "<featuresReport></featuresReport>") {
 			return fmt.Errorf("PDF %q is flatten", path)
 		}
@@ -839,6 +839,58 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
 	return nil
 }
 
+func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
+	dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
+
+	_, err := os.Stat(dirPath)
+	if os.IsNotExist(err) {
+		return fmt.Errorf("directory %q does not exist", dirPath)
+	}
+
+	var paths []string
+	err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+		if pathErr != nil {
+			return pathErr
+		}
+		if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+			paths = append(paths, path)
+		}
+		return nil
+	})
+	if err != nil {
+		return fmt.Errorf("walk %q: %w", dirPath, err)
+	}
+
+	invert := should == "should NOT"
+	re := regexp.MustCompile(`CommandLineError:Incorrectpassword`)
+
+	for _, path := range paths {
+		cmd := []string{
+			"pdfinfo",
+			filepath.Base(path),
+		}
+
+		output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+		if err != nil {
+			return fmt.Errorf("exec %q: %w", cmd, err)
+		}
+
+		output = strings.ReplaceAll(output, " ", "")
+		output = strings.ReplaceAll(output, "\n", "")
+		matches := re.FindStringSubmatch(output)
+		isEncrypted := len(matches) >= 1 && matches[0] == "CommandLineError:Incorrectpassword"
+
+		if invert && isEncrypted {
+			return fmt.Errorf("PDF %q is encrypted", path)
+		}
+		if !invert && !isEncrypted {
+			return fmt.Errorf("PDF %q is not encrypted: %q", path, output)
+		}
+	}
+
+	return nil
+}
+
 func InitializeScenario(ctx *godog.ScenarioContext) {
 	s := &scenario{}
 	ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
@@ -871,6 +923,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
 	ctx.Then(`^there should be the following file\(s\) in the (response|webhook request):$`, s.thereShouldBeTheFollowingFiles)
 	ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)
 	ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
+	ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
 	ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
 	ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
 	ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)