Repository: gotenberg/gotenberg (go)

Issue:
Possibility to send cookies along with Chromium requests
I am trying to render a SPA (single-page application) output to PDF using Chromium in Gotenberg. This SPA performs an API call, which requires a cookie for authentication. It seems to be impossible to set this cookie in Chromium using the [Gotenberg Chromium API](https://gotenberg.dev/docs/modules/chromium).

I have tried setting the Cookie using the `extraHttpHeaders` field, but that didn't work - I expect it sets the Cookie only on the initial (SPA/frontend) request, and not in any subsequent backend API calls.

Could we add an additional `cookies` form field for Chromium requests, which will be added to the cookie jar in Chromium so it automatically sends them for every (matching) request, including Javascript-initiated API calls?

Pull request description:
feat(chromium): set cookies
Closes #658.

Looking for testers - `gulnap/gotenberg:8.3.0-set-cookies`.

New form field `cookies` available within the Chromium module. It accepts a JSON-formatted array with items accepting the following keys:

* `name` (required)
* `value` (required)
* `domain` (required)
* `path`
* `secure` - boolean
* `httpOnly` - boolean
* `sameSite` - accepted values, beside empty, are: "Strict", "Lax" or "None"

Cookies are set to expire after the conversion.


Patch:
diff --git a/Makefile b/Makefile
index e81a924..b6443ef 100644
--- a/Makefile
+++ b/Makefile
@@ -33,6 +33,9 @@ API_START_TIMEOUT=30s
 API_TIMEOUT=30s
 API_ROOT_PATH=/
 API_TRACE_HEADER=Gotenberg-Trace
+API_ENABLE_BASIC_AUTH=false
+GOTENBERG_API_BASIC_AUTH_USERNAME=
+GOTENBERG_API_BASIC_AUTH_PASSWORD=
 API_DISABLE_HEALTH_CHECK_LOGGING=false
 CHROMIUM_RESTART_AFTER=0
 CHROMIUM_MAX_QUEUE_SIZE=0
@@ -79,6 +82,8 @@ WEBHOOK_DISABLE=false
 run: ## Start a Gotenberg container
 	docker run --rm -it \
 	-p $(API_PORT):$(API_PORT) \
+	-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
+	-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
 	$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
 	gotenberg \
 	--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
@@ -88,6 +93,7 @@ run: ## Start a Gotenberg container
 	--api-timeout=$(API_TIMEOUT) \
 	--api-root-path=$(API_ROOT_PATH) \
 	--api-trace-header=$(API_TRACE_HEADER) \
+	--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
 	--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
 	--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
 	--chromium-auto-start=$(CHROMIUM_AUTO_START) \
diff --git a/go.mod b/go.mod
index fb07437..7357c19 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/alexliesenfeld/health v0.8.0
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/barasher/go-exiftool v1.10.0
-	github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154
+	github.com/chromedp/cdproto v0.0.0-20240328024531-fe04f09ede24
 	github.com/chromedp/chromedp v0.9.5
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/uuid v1.6.0
@@ -25,15 +25,15 @@ require (
 	github.com/prometheus/client_golang v1.19.0
 	github.com/russross/blackfriday/v2 v2.1.0
 	github.com/spf13/pflag v1.0.5
-	github.com/ulikunitz/xz v0.5.11 // indirect
+	github.com/ulikunitz/xz v0.5.12 // indirect
 	go.uber.org/multierr v1.11.0
 	go.uber.org/zap v1.27.0
-	golang.org/x/crypto v0.21.0 // indirect
+	golang.org/x/crypto v0.22.0 // indirect
 	golang.org/x/image v0.15.0 // indirect
-	golang.org/x/net v0.22.0
-	golang.org/x/sync v0.6.0
-	golang.org/x/sys v0.18.0 // indirect
-	golang.org/x/term v0.18.0
+	golang.org/x/net v0.24.0
+	golang.org/x/sync v0.7.0
+	golang.org/x/sys v0.19.0 // indirect
+	golang.org/x/term v0.19.0
 	golang.org/x/text v0.14.0
 )
 
@@ -42,12 +42,13 @@ require github.com/dlclark/regexp2 v1.11.0
 require (
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
-	github.com/cespare/xxhash/v2 v2.2.0 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/chromedp/sysutil v1.0.0 // indirect
 	github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
 	github.com/gobwas/httphead v0.1.0 // indirect
 	github.com/gobwas/pool v0.2.1 // indirect
 	github.com/gobwas/ws v1.3.2 // indirect
+	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/hhrutter/lzw v1.0.0 // indirect
 	github.com/hhrutter/tiff v1.0.1 // indirect
@@ -56,13 +57,14 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
-	github.com/prometheus/client_model v0.6.0 // indirect
-	github.com/prometheus/common v0.51.0 // indirect
+	github.com/prometheus/client_model v0.6.1 // indirect
+	github.com/prometheus/common v0.52.2 // indirect
 	github.com/prometheus/procfs v0.13.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasttemplate v1.2.2 // indirect
 	github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
+	golang.org/x/time v0.5.0 // indirect
 	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 6616604..faa2773 100644
--- a/go.sum
+++ b/go.sum
@@ -9,11 +9,11 @@ github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEX
 github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
-github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
-github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154 h1:jeAmkzyOAQBPRmZMhX+i/CJv0VViLkHk1nF0qx8s0Mk=
-github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
+github.com/chromedp/cdproto v0.0.0-20240328024531-fe04f09ede24 h1:XLG3KlHtG6Wg75ed/daLltJtcj8VXjw7F9mzYenzFL0=
+github.com/chromedp/cdproto v0.0.0-20240328024531-fe04f09ede24/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
 github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
 github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
 github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
@@ -32,6 +32,8 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
 github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
 github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
 github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -101,10 +103,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
 github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
-github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
-github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
-github.com/prometheus/common v0.51.0 h1:vT5R9NAlW4V6k8Wruk7ikrHaHRsrPbduM/cKTOdQM/k=
-github.com/prometheus/common v0.51.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.52.2 h1:LW8Vk7BccEdONfrJBDffQGRtpSzi5CQaRZGtboOO2ck=
+github.com/prometheus/common v0.52.2/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q=
 github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
 github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -125,8 +127,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
 github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
-github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
-github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
+github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -139,23 +141,25 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
+golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
+golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
 golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
-golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
-golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
-golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
+golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
+golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
+golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
 google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
diff --git a/pkg/gotenberg/env.go b/pkg/gotenberg/env.go
new file mode 100644
index 0000000..72415f1
--- /dev/null
+++ b/pkg/gotenberg/env.go
@@ -0,0 +1,35 @@
+package gotenberg
+
+import (
+	"fmt"
+	"os"
+	"strconv"
+)
+
+// StringEnv retrieves the value of the environment variable named by the key.
+// If the variable is present in the environment and not empty, the value is
+// returned.
+func StringEnv(key string) (string, error) {
+	val, ok := os.LookupEnv(key)
+	if !ok {
+		return "", fmt.Errorf("environment variable '%s' does not exist", key)
+	}
+	if val == "" {
+		return "", fmt.Errorf("environment variable '%s' is empty", key)
+	}
+	return val, nil
+}
+
+// IntEnv relies on [StringEnv] and converts the values if it exists and is not
+// empty.
+func IntEnv(key string) (int, error) {
+	val, err := StringEnv(key)
+	if err != nil {
+		return 0, err
+	}
+	intVal, err := strconv.Atoi(val)
+	if err != nil {
+		return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
+	}
+	return intVal, nil
+}
diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go
index 8c4f77e..e7d7c97 100644
--- a/pkg/modules/api/api.go
+++ b/pkg/modules/api/api.go
@@ -5,9 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"os"
 	"sort"
-	"strconv"
 	"strings"
 	"time"
 
@@ -34,6 +32,8 @@ type Api struct {
 	timeout                   time.Duration
 	rootPath                  string
 	traceHeader               string
+	basicAuthUsername         string
+	basicAuthPassword         string
 	disableHealthCheckLogging bool
 
 	routes              []Route
@@ -163,8 +163,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
 			fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
 			fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
 			fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
+			fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
 			fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
-
 			return fs
 		}(),
 		New: func() gotenberg.Module { return new(Api) },
@@ -184,22 +184,26 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
 	// Port from env?
 	portEnvVar := flags.MustString("api-port-from-env")
 	if portEnvVar != "" {
-		val, ok := os.LookupEnv(portEnvVar)
-
-		if !ok {
-			return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
+		port, err := gotenberg.IntEnv(portEnvVar)
+		if err != nil {
+			return fmt.Errorf("get API port from env: %w", err)
 		}
+		a.port = port
+	}
 
-		if val == "" {
-			return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
+	// Enable basic auth?
+	enableBasicAuth := flags.MustBool("api-enable-basic-auth")
+	if enableBasicAuth {
+		basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
+		if err != nil {
+			return fmt.Errorf("get basic auth username from env: %w", err)
 		}
-
-		port, err := strconv.Atoi(val)
+		basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
 		if err != nil {
-			return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
+			return fmt.Errorf("get basic auth password from env: %w", err)
 		}
-
-		a.port = port
+		a.basicAuthUsername = basicAuthUsername
+		a.basicAuthPassword = basicAuthPassword
 	}
 
 	// Get routes from modules.
@@ -394,6 +398,13 @@ func (a *Api) Start() error {
 		loggerMiddleware(a.logger, disableLoggingForPaths),
 	)
 
+	// Basic auth?
+	if a.basicAuthUsername != "" {
+		a.srv.Pre(
+			basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword),
+		)
+	}
+
 	// Add the modules' middlewares in their respective stacks.
 	var externalMultipartMiddlewares []Middleware
 	for _, externalMiddleware := range a.externalMiddlewares {
diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go
index baa7df9..9a1f418 100644
--- a/pkg/modules/api/middlewares.go
+++ b/pkg/modules/api/middlewares.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"context"
+	"crypto/subtle"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
 	"go.uber.org/zap"
 
 	"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -112,7 +114,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(c echo.Context) error {
 			c.Set("rootPath", rootPath)
-
 			// Call the next middleware in the chain.
 			return next(c)
 		}
@@ -217,6 +218,17 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
 	}
 }
 
+// basicAuthMiddleware manages basic authentication.
+func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
+	return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
+		if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
+			subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
+			return true, nil
+		}
+		return false, nil
+	})
+}
+
 // contextMiddleware, a middleware for "multipart/form-data" requests, sets the
 // [Context] and related context.CancelFunc in the [echo.Context] under
 // "context" and "cancel". If the process is synchronous, it also handles the
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index df0516c..060f2ac 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -226,6 +226,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
 		clearCacheActionFunc(logger, b.arguments.clearCache),
 		clearCookiesActionFunc(logger, b.arguments.clearCookies),
 		disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
+		setCookiesActionFunc(logger, options.Cookies),
 		extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
 		navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
 		hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
@@ -248,6 +249,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
 		clearCacheActionFunc(logger, b.arguments.clearCache),
 		clearCookiesActionFunc(logger, b.arguments.clearCookies),
 		disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
+		setCookiesActionFunc(logger, options.Cookies),
 		extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
 		navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
 		hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index 0ab4ad3..1b6fd79 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/alexliesenfeld/health"
+	"github.com/chromedp/cdproto/network"
 	flag "github.com/spf13/pflag"
 	"go.uber.org/zap"
 
@@ -103,6 +104,10 @@ type Options struct {
 	// Optional.
 	WaitForExpression string
 
+	// Cookies are the cookies to put in the Chromium cookies' jar.
+	// Optional
+	Cookies []Cookie
+
 	// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
 	// the HTML document.
 	// Optional.
@@ -128,6 +133,7 @@ func DefaultOptions() Options {
 		WaitDelay:               0,
 		WaitWindowStatus:        "",
 		WaitForExpression:       "",
+		Cookies:                 nil,
 		ExtraHttpHeaders:        nil,
 		EmulatedMediaType:       "",
 		OmitBackground:          false,
@@ -258,6 +264,38 @@ func DefaultScreenshotOptions() ScreenshotOptions {
 	}
 }
 
+// Cookie gathers the available entries for setting a cookie in the Chromium
+// cookies' jar.
+type Cookie struct {
+	// Name is the cookie name.
+	// Required.
+	Name string `json:"name"`
+
+	// Value is the cookie value.
+	// Required.
+	Value string `json:"value"`
+
+	// Domain is the cookie domain.
+	// Required.
+	Domain string `json:"domain"`
+
+	// Path is the cookie path.
+	// Optional.
+	Path string `json:"path,omitempty"`
+
+	// Secure sets the cookie secure if true.
+	// Optional.
+	Secure bool `json:"secure,omitempty"`
+
+	// HttpOnly sets the cookie as HTTP-only if true.
+	// Optional.
+	HttpOnly bool `json:"httpOnly,omitempty"`
+
+	// SameSite is cookie 'Same-Site' status.
+	// Optional.
+	SameSite network.CookieSameSite `json:"sameSite,omitempty"`
+}
+
 // Api helps to interact with Chromium for converting HTML documents to PDF.
 type Api interface {
 	Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index 51409e9..d45203e 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -34,6 +34,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
 		waitDelay               time.Duration
 		waitWindowStatus        string
 		waitForExpression       string
+		cookies                 []Cookie
 		extraHttpHeaders        map[string]string
 		emulatedMediaType       string
 		omitBackground          bool
@@ -58,6 +59,25 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
 		Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
 		String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
 		String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
+		Custom("cookies", func(value string) error {
+			if value == "" {
+				cookies = defaultOptions.Cookies
+				return nil
+			}
+
+			err := json.Unmarshal([]byte(value), &cookies)
+			if err != nil {
+				return fmt.Errorf("unmarshal cookies: %w", err)
+			}
+
+			for i, cookie := range cookies {
+				if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
+					err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
+				}
+			}
+
+			return err
+		}).
 		Custom("extraHttpHeaders", func(value string) error {
 			if value == "" {
 				extraHttpHeaders = defaultOptions.ExtraHttpHeaders
@@ -94,6 +114,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
 		WaitDelay:               waitDelay,
 		WaitWindowStatus:        waitWindowStatus,
 		WaitForExpression:       waitForExpression,
+		Cookies:                 cookies,
 		ExtraHttpHeaders:        extraHttpHeaders,
 		EmulatedMediaType:       emulatedMediaType,
 		OmitBackground:          omitBackground,
diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go
index d1d4b8f..4047dde 100644
--- a/pkg/modules/chromium/tasks.go
+++ b/pkg/modules/chromium/tasks.go
@@ -3,6 +3,7 @@ package chromium
 import (
 	"bufio"
 	"context"
+	"errors"
 	"fmt"
 	"os"
 	"time"
@@ -210,6 +211,55 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
 	}
 }
 
+func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
+	return func(ctx context.Context) error {
+		if len(cookies) == 0 {
+			logger.Debug("no cookies to set")
+			return nil
+		}
+
+		deadline, ok := ctx.Deadline()
+		if !ok {
+			return errors.New("context has no deadline, cannot set cookies")
+		}
+		epochTime := cdp.TimeSinceEpoch(deadline)
+
+		cookiePretty := func(c *network.SetCookieParams) string {
+			return fmt.Sprintf(
+				"Name: '%s', Value: '%s', Domain: '%s', Path: '%s', Secure: %t, HTTPOnly: %t, SameSite: '%s', Expires: %s",
+				c.Name,
+				c.Value,
+				c.Domain,
+				c.Path,
+				c.Secure,
+				c.HTTPOnly,
+				c.SameSite.String(),
+				c.Expires.Time().String(),
+			)
+		}
+
+		for _, cookie := range cookies {
+			cookieParams := network.
+				SetCookie(cookie.Name, cookie.Value).
+				WithDomain(cookie.Domain).
+				WithPath(cookie.Path).
+				WithSecure(cookie.Secure).
+				WithHTTPOnly(cookie.HttpOnly).
+				WithSameSite(cookie.SameSite).
+				WithExpires(&epochTime)
+
+			err := cookieParams.Do(ctx)
+			if err != nil {
+				return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
+			}
+
+			logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
+		}
+
+		return nil
+	}
+}
+
 func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
 	return func(ctx context.Context) error {
 		if len(extraHttpHeaders) == 0 {


Test patch:
diff --git a/pkg/gotenberg/env_test.go b/pkg/gotenberg/env_test.go
new file mode 100644
index 0000000..4c14060
--- /dev/null
+++ b/pkg/gotenberg/env_test.go
@@ -0,0 +1,134 @@
+package gotenberg
+
+import (
+	"os"
+	"testing"
+)
+
+func TestStringEnv(t *testing.T) {
+	for _, tc := range []struct {
+		scenario    string
+		key         string
+		setEnv      func()
+		expectVal   string
+		expectError bool
+	}{
+		{
+			scenario:    "non-existing environment variable",
+			key:         "NON_EXISTING",
+			expectVal:   "",
+			expectError: true,
+		},
+		{
+			scenario: "empty environment variable",
+			key:      "EMPTY_STRING",
+			setEnv: func() {
+				err := os.Setenv("EMPTY_STRING", "")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+			},
+			expectVal:   "",
+			expectError: true,
+		},
+		{
+			scenario: "success",
+			key:      "EXISTING_STRING_VALUE",
+			setEnv: func() {
+				err := os.Setenv("EXISTING_STRING_VALUE", "foo")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+			},
+			expectVal:   "foo",
+			expectError: false,
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			if tc.setEnv != nil {
+				tc.setEnv()
+			}
+
+			val, err := StringEnv(tc.key)
+
+			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")
+			}
+
+			if tc.expectVal != val {
+				t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
+			}
+		})
+	}
+}
+
+func TestIntEnv(t *testing.T) {
+	for _, tc := range []struct {
+		scenario    string
+		key         string
+		setEnv      func()
+		expectVal   int
+		expectError bool
+	}{
+		{
+			scenario: "empty environment variable",
+			key:      "EMPTY_INT",
+			setEnv: func() {
+				err := os.Setenv("EMPTY_INT", "")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+			},
+			expectVal:   0,
+			expectError: true,
+		},
+		{
+			scenario: "non-integer value",
+			key:      "NON_INTEGER",
+			setEnv: func() {
+				err := os.Setenv("NON_INTEGER", "foo")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+			},
+			expectVal:   0,
+			expectError: true,
+		},
+		{
+			scenario: "success",
+			key:      "EXISTING_INT_VALUE",
+			setEnv: func() {
+				err := os.Setenv("EXISTING_INT_VALUE", "123")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+			},
+			expectVal:   123,
+			expectError: false,
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			if tc.setEnv != nil {
+				tc.setEnv()
+			}
+
+			val, err := IntEnv(tc.key)
+
+			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")
+			}
+
+			if tc.expectVal != val {
+				t.Errorf("expected value %d but got %d", tc.expectVal, val)
+			}
+		})
+	}
+}
diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go
index 2005764..a5e5b0f 100644
--- a/pkg/modules/api/api_test.go
+++ b/pkg/modules/api/api_test.go
@@ -58,10 +58,28 @@ func TestApi_Provision(t *testing.T) {
 			expectError: true,
 		},
 		{
-			scenario: "port from env: empty environment variable",
+			scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable",
 			ctx: func() *gotenberg.Context {
 				fs := new(Api).Descriptor().FlagSet
-				err := fs.Parse([]string{"--api-port-from-env=PORT"})
+				err := fs.Parse([]string{"--api-enable-basic-auth=true"})
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+
+				return gotenberg.NewContext(
+					gotenberg.ParsedFlags{
+						FlagSet: fs,
+					},
+					nil,
+				)
+			}(),
+			expectError: true,
+		},
+		{
+			scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_PASSWORD environment variable",
+			ctx: func() *gotenberg.Context {
+				fs := new(Api).Descriptor().FlagSet
+				err := fs.Parse([]string{"--api-enable-basic-auth=true"})
 				if err != nil {
 					t.Fatalf("expected no error but got: %v", err)
 				}
@@ -74,7 +92,7 @@ func TestApi_Provision(t *testing.T) {
 				)
 			}(),
 			setEnv: func() {
-				err := os.Setenv("PORT", "")
+				err := os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
 				if err != nil {
 					t.Fatalf("expected no error but got: %v", err)
 				}
@@ -361,7 +379,7 @@ func TestApi_Provision(t *testing.T) {
 				}
 
 				fs := new(Api).Descriptor().FlagSet
-				err := fs.Parse([]string{"--api-port-from-env=PORT"})
+				err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"})
 				if err != nil {
 					t.Fatalf("expected no error but got: %v", err)
 				}
@@ -383,6 +401,14 @@ func TestApi_Provision(t *testing.T) {
 				if err != nil {
 					t.Fatalf("expected no error but got: %v", err)
 				}
+				err = os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+				err = os.Setenv("GOTENBERG_API_BASIC_AUTH_PASSWORD", "bar")
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
 			},
 			expectPort: 1337,
 			expectMiddlewares: []Middleware{
@@ -671,6 +697,8 @@ func TestApi_Start(t *testing.T) {
 			mod.port = 3000
 			mod.startTimeout = time.Duration(30) * time.Second
 			mod.rootPath = "/"
+			mod.basicAuthUsername = "foo"
+			mod.basicAuthPassword = "bar"
 			mod.disableHealthCheckLogging = true
 			mod.routes = []Route{
 				{
@@ -755,6 +783,7 @@ func TestApi_Start(t *testing.T) {
 			// health request.
 			recorder := httptest.NewRecorder()
 			healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
+			healthRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
 
 			mod.srv.ServeHTTP(recorder, healthRequest)
 			if recorder.Code != http.StatusOK {
@@ -791,6 +820,7 @@ func TestApi_Start(t *testing.T) {
 
 				req := httptest.NewRequest(http.MethodPost, url, body)
 				req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
+				req.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
 
 				return req
 			}
diff --git a/pkg/modules/api/middlewares_test.go b/pkg/modules/api/middlewares_test.go
index 4490abc..b25baf7 100644
--- a/pkg/modules/api/middlewares_test.go
+++ b/pkg/modules/api/middlewares_test.go
@@ -236,6 +236,56 @@ func TestTraceMiddleware(t *testing.T) {
 	}
 }
 
+func TestBasicAuthMiddleware(t *testing.T) {
+	for _, tc := range []struct {
+		scenario    string
+		request     *http.Request
+		username    string
+		password    string
+		expectError bool
+	}{
+		{
+			scenario: "invalid basic auth",
+			request: func() *http.Request {
+				req := httptest.NewRequest(http.MethodGet, "/", nil)
+				req.SetBasicAuth("invalid", "invalid")
+				return req
+			}(),
+			username:    "foo",
+			password:    "bar",
+			expectError: true,
+		},
+		{
+			scenario: "valid basic auth",
+			request: func() *http.Request {
+				req := httptest.NewRequest(http.MethodGet, "/", nil)
+				req.SetBasicAuth("foo", "bar")
+				return req
+			}(),
+			username:    "foo",
+			password:    "bar",
+			expectError: false,
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			recorder := httptest.NewRecorder()
+			srv := echo.New()
+			srv.HideBanner = true
+			srv.HidePort = true
+			c := srv.NewContext(tc.request, recorder)
+			err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error {
+				return nil
+			})(c)
+			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 TestLoggerMiddleware(t *testing.T) {
 	for i, tc := range []struct {
 		request     *http.Request
diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go
index b33b5ae..89d8a93 100644
--- a/pkg/modules/chromium/browser_test.go
+++ b/pkg/modules/chromium/browser_test.go
@@ -579,6 +579,41 @@ func TestChromiumBrowser_pdf(t *testing.T) {
 				"JavaScript disabled, skipping wait expression",
 			},
 		},
+		{
+			scenario: "set cookies",
+			browser: newChromiumBrowser(
+				browserArguments{
+					binPath:          os.Getenv("CHROMIUM_BIN_PATH"),
+					wsUrlReadTimeout: 5 * time.Second,
+					allowList:        regexp2.MustCompile("", 0),
+					denyList:         regexp2.MustCompile("", 0),
+				},
+			),
+			fs: func() *gotenberg.FileSystem {
+				fs := gotenberg.NewFileSystem()
+
+				err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
+				if err != nil {
+					t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+				}
+
+				err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Set cookies</h1>"), 0o755)
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+
+				return fs
+			}(),
+			options: PdfOptions{
+				Options: Options{Cookies: []Cookie{{Name: "foo", Value: "bar", Domain: ".foo.bar"}}},
+			},
+			noDeadline:  false,
+			start:       true,
+			expectError: false,
+			expectedLogEntries: []string{
+				"set cookie",
+			},
+		},
 		{
 			scenario: "extra HTTP headers",
 			browser: newChromiumBrowser(
@@ -1125,6 +1160,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
 				"cache not cleared",
 				"cookies not cleared",
 				"JavaScript not disabled",
+				"no cookies to set",
 				"no extra HTTP headers",
 				"navigate to",
 				"default white background not hidden",
@@ -1549,6 +1585,41 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
 				"JavaScript disabled, skipping wait expression",
 			},
 		},
+		{
+			scenario: "set cookies",
+			browser: newChromiumBrowser(
+				browserArguments{
+					binPath:          os.Getenv("CHROMIUM_BIN_PATH"),
+					wsUrlReadTimeout: 5 * time.Second,
+					allowList:        regexp2.MustCompile("", 0),
+					denyList:         regexp2.MustCompile("", 0),
+				},
+			),
+			fs: func() *gotenberg.FileSystem {
+				fs := gotenberg.NewFileSystem()
+
+				err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
+				if err != nil {
+					t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+				}
+
+				err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Set cookies</h1>"), 0o755)
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+
+				return fs
+			}(),
+			options: ScreenshotOptions{
+				Options: Options{Cookies: []Cookie{{Name: "fpp", Value: "bar", Domain: ".foo.bar"}}},
+			},
+			noDeadline:  false,
+			start:       true,
+			expectError: false,
+			expectedLogEntries: []string{
+				"set cookie",
+			},
+		},
 		{
 			scenario: "extra HTTP headers",
 			browser: newChromiumBrowser(
@@ -1964,6 +2035,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
 				"cache not cleared",
 				"cookies not cleared",
 				"JavaScript not disabled",
+				"no cookies to set",
 				"no extra HTTP headers",
 				"navigate to",
 				"default white background not hidden",
diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go
index 56e1a69..af0d488 100644
--- a/pkg/modules/chromium/routes_test.go
+++ b/pkg/modules/chromium/routes_test.go
@@ -62,6 +62,59 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				return options
 			}(),
 		},
+		{
+			scenario: "invalid cookies form field",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"cookies": {
+						"foo",
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions: DefaultOptions(),
+		},
+		{
+			scenario: "invalid cookies form field (missing required values)",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"cookies": {
+						"[{}]",
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions: func() Options {
+				options := DefaultOptions()
+				// No validation in this method, so it still instantiates
+				// an empty item.
+				options.Cookies = []Cookie{{}}
+				return options
+			}(),
+		},
+		{
+			scenario: "valid cookies form field",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"cookies": {
+						`[{"name":"foo","value":"bar","domain":".foo.bar"}]`,
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions: func() Options {
+				options := DefaultOptions()
+				options.Cookies = []Cookie{{
+					Name:   "foo",
+					Value:  "bar",
+					Domain: ".foo.bar",
+				}}
+				return options
+			}(),
+		},
 		{
 			scenario: "invalid extraHttpHeaders form field",
 			ctx: func() *api.ContextMock {