Encrypt a PDF and restrict permissions
At a glance
Section titled “At a glance”This recipe encrypts a document with the Advanced Encryption Standard
(AES)-256 standard security handler. You set a user password (required to
open), an owner password (full access), and a permission bitmask that restricts
operations. Treat those permissions as reader-cooperative: encryption gives
confidentiality, not integrity, and only cooperating software honours the
permission bits. The recipe follows
examples/22-protection.php.
Trust boundary (carry this with every permission claim). PDF encryption protects content confidentiality against parties without the password (ISO 32000-2 §7.6). It does not protect integrity: it does not detect or prevent modification. The permission
Pentry is an unsigned 32-bit set of flags that asks conforming readers to honour restrictions; it is not an access control. A non-conforming tool, or any tool used with the owner password, can perform every “denied” operation. Do not describe an encrypted PDF as “secure”, “tamper-proof”, or “copy-protected”.
Install
Section titled “Install”composer require nextpdf/core:^3Enable the openssl PHP extension. The AES-256 encryptor uses it for the
cipher and key derivation.
Conceptual overview
Section titled “Conceptual overview”The encryption dictionary V/R codes select the standard security handler
(ISO 32000-2 §7.6).
NextPDF’s Aes256Encryptor implements the AESV3 crypt filter at security
handler revision 6 (V=5/R=6). It uses a random 256-bit
file-encryption key, salted iterative-hash key derivation (Algorithm 2.B), and
AES-256-CBC per-object encryption with a random initialization vector. Cipher
Block Chaining (CBC) is a confidentiality mode (NIST SP 800-38A).
Its initialization vectors must be unpredictable.
The initialization vector is fresh for each object and each run, so raw bytes
differ from run to run. The reproducibility profile is therefore structural.
Before it compares two runs, the harness canonicalises the encryption IV,
object order, and trailer /ID. This profile is stricter than the profile for
a recipe that omits encryption.
The permission bitmask sets the P entry. Bit 3 grants printing, and bit 6
grants annotation/form-fill. The value is the documented unsigned-32-bit
quantity.
API surface
Section titled “API surface”NextPDF\Core\Concerns\HasSecurity (mixed into Document):
setEncryption(#[SensitiveParameter] string $userPassword, #[SensitiveParameter] string $ownerPassword = '', int $permissions = -1): static— configures AES-256 standard-handler encryption.permissions = -1grants all permissions. WhenownerPasswordis empty, the user password is reused as the owner password. Call beforeaddPage().getEncryptor(): ?Aes256Encryptor— the configured encryptor, ornull.useAesGcm(?bool $enabled = true): static— opts in to ISO/TS 32003 AES-256-GCM; throws if the host OpenSSL/libsodium does not provide the cipher.
Both password parameters are marked #[SensitiveParameter], so PHP redacts
them from stack traces.
Permission bits (the P entry, low bits 3–6 in common use):
| Bit | Value | Operation |
|---|---|---|
| 3 | 4 | Print the document |
| 4 | 8 | Modify document contents |
| 5 | 16 | Copy / extract text and graphics |
| 6 | 32 | Add or modify annotations and fill form fields |
Code sample — Quick start
Section titled “Code sample — Quick start”<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();$doc->setTitle('Confidential Memo');
// Grant printing only (bit 3 = 4). MUST run before addPage().$doc->setEncryption( userPassword: 'open-me', ownerPassword: 'owner-secret', permissions: 4,);
$doc->addPage();$doc->setFont('helvetica', '', 12);$doc->cell(0, 10, 'Encrypted with AES-256; printing allowed only.', newLine: true);
$doc->save(__DIR__ . '/confidential.pdf');echo "Wrote confidential.pdf\n";Code sample — Production
Section titled “Code sample — Production”The full example below mirrors examples/22-protection.php and writes to
NEXTPDF_COOKBOOK_OUTPUT for the harness.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$userPassword = 'demo';$ownerPassword = 'admin';
// Grant ONLY printing (bit 3 = 4); deny copy/modify/annotate.$permissions = 4;
$doc = Document::createStandalone();$doc->setTitle('Encrypted Document — Restricted Permissions');$doc->setAuthor('NextPDF Example');
// setEncryption() MUST be called before addPage().$doc->setEncryption( userPassword: $userPassword, ownerPassword: $ownerPassword, permissions: $permissions,);
$doc->addPage();$doc->setFont('helvetica', 'B', 20);$doc->cell(0, 14, 'Encrypted PDF Document', newLine: true);$doc->ln(8);
$doc->setFont('helvetica', '', 11);$doc->multiCell(0, 7, 'This document is protected with AES-256 encryption ' . '(standard security handler, revision 6). The user password is required ' . 'to open it; the owner password grants full access. The permission ' . 'bits below are honoured by conforming readers only.');$doc->ln(5);
$permissionTable = [ ['Bit 3 (4)', 'Printing', 'ALLOWED'], ['Bit 4 (8)', 'Content modification', 'DENIED'], ['Bit 5 (16)', 'Text copying / extraction', 'DENIED'], ['Bit 6 (32)', 'Annotations / form fields', 'DENIED'],];$doc->setFont('helvetica', 'B', 10);$doc->cell(30, 7, 'Flag');$doc->cell(60, 7, 'Operation');$doc->cell(0, 7, 'Status', newLine: true);foreach ($permissionTable as [$bit, $operation, $status]) { $doc->setFont('courier', '', 9); $doc->cell(30, 7, $bit); $doc->setFont('helvetica', '', 10); $doc->cell(60, 7, $operation); $doc->setFont('helvetica', 'B', 10); $doc->cell(0, 7, $status, newLine: true);}
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');$doc->save($out !== false ? $out : __DIR__ . '/encrypted.pdf');
echo "Wrote encrypted PDF (AES-256, printing only)\n";Expected output:
Wrote encrypted PDF (AES-256, printing only)When you open the file, the reader prompts for a password. The user password opens it with the restricted permission set. The owner password opens it with full access.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- Call order.
setEncryption()afteraddPage()does not retroactively encrypt earlier content. Configure encryption first; the engine encrypts each object body as it is written. - Owner password default. An empty owner password makes the engine reuse the user password as the owner password. That leaves effectively no privileged role. Set distinct passwords when the two roles must differ.
- Permission semantics are advisory. Only conforming readers honour the bits. They are not cryptographically enforced: a non-conforming tool, or any tool used with the owner password, can perform restricted operations. Treat permissions as a policy signal to cooperating software, never as an access control that withstands a determined party.
- No integrity guarantee. Encryption is confidentiality, not integrity. An attacker without the password cannot read the content, but the format itself does not detect tampering. Integrity protection requires a separate mechanism, such as a digital signature or ISO/TS 32004 document MAC.
- PDF/A conflict. PDF/A prohibits the
Encrypttrailer key. CallingsetEncryption()on a PDF/A document, in either order, throws an incompatibility exception. - AES-256-GCM opt-in.
useAesGcm()selects ISO/TS 32003 GCM bulk encryption when the host OpenSSL or libsodium provides it. Otherwise, it throwsInvalidConfigException. It is incompatible with PDF/A for the same reason. - Public-key encryption is not yet wired.
setPublicKeyEncryption()freezes the API surface, butsave()throws until the writer wiring lands (a known defect). Do not use it in production on Core.
Performance
Section titled “Performance”Key derivation runs Algorithm 2.B’s iterated hash once per document. Per-object AES-256-CBC is linear in the object body size. For typical documents, the cost stays well within the 1500 ms / 64 MB budget. Very large documents incur an AES throughput cost per object. Galois/Counter Mode (GCM) with AES-NI is faster on capable hosts.
Security notes
Section titled “Security notes”- Confidentiality only. Reiterating the trust boundary: encryption keeps content from parties without the password. It does not prove the file is unaltered, and the permission bits are reader-cooperative.
- Password strength is yours. The handler is only as strong as the passwords. Once someone obtains the file, a weak user password can be brute-forced offline; the format cannot rate-limit attempts.
- Owner password is a primary key. Anyone with the owner password bypasses every restriction. Treat it like a root credential; never ship it with the document or log it.
#[SensitiveParameter]is defence in depth. It redacts the passwords from PHP stack traces, but you must still keep them out of your own logs, exception messages, and crash reports.
Data Residency & PII Mitigations
Section titled “Data Residency & PII Mitigations”The library performs encryption in-process. It does not transmit the document or the passwords anywhere. The engine writes no password, key, or document byte to disk except the encrypted output you save. Where the output file resides, and how passwords are held, are deployment concerns the integrator owns. The library makes no residency guarantee. If the plaintext document contains personal data, that data is only as protected as the weakest password and the cooperating-reader caveat above. Encryption is not a substitute for minimising the personally identifiable information (PII) you put in the document.
Safe Telemetry & Log Scrubbing
Section titled “Safe Telemetry & Log Scrubbing”Encryption emits an EncryptionAppliedEvent that carries only the algorithm
name (AES-256) and three booleans summarising whether print/copy/modify are
permitted — no password, key, salt, or IV is ever placed on the event
(src/Event/Security/EncryptionAppliedEvent.php). The OpenTelemetry path
routes span attributes through an allowlist sanitizer
(src/Telemetry/AttributeSanitizer.php) that unconditionally rejects
passwords and file paths; only allowlisted keys with scalar values survive.
Do not add password or key material to spans, logs, or exception messages in
your own integration code. The #[SensitiveParameter] markers protect stack
traces, but not strings you build yourself.
Threat model
Section titled “Threat model”In scope: an adversary who obtains the encrypted file but not the passwords. They cannot read content, subject to password strength, and the file does not leak plaintext. Out of scope: an adversary who has the user or owner password; a non-conforming reader that ignores permission bits; offline brute force of a weak password; tampering detection (encryption provides confidentiality, not integrity); side-channels in the host OpenSSL build; and key custody, which is entirely the integrator’s responsibility. Documenting these threats does not assert the absence of vulnerabilities.
FIPS-mode behavior
Section titled “FIPS-mode behavior”The host OpenSSL build provides the cryptographic primitives, so FIPS posture
is a host property, not a library setting. CryptoCapabilities::detectFipsMode()
returns a three-state FipsModeDetection
(src/Security/FipsModeDetection.php): FIPS_ACTIVE, FIPS_ABSENT, or
INDETERMINATE. The PHP openssl extension exposes no binding for the
OpenSSL 3 provider model, so probing is best-effort. INDETERMINATE is
treated as “FIPS not proven” (fail-closed), distinguishable in
operator-actionable telemetry. NextPDF does not claim FIPS 140 validation;
running on a FIPS-validated OpenSSL is the operator’s responsibility, and the
detection result is advisory.
Conformance
Section titled “Conformance”| Statement | Spec | Clause | reference_id |
|---|---|---|---|
The encryption dictionary V code selects the encryption algorithm. | ISO 32000-2 | §7.6 | |
The AESV3 crypt-filter method is named by the CFM entry. | ISO 32000-2 | §7.6 | |
The P entry is an unsigned 32-bit access-permission quantity. | ISO 32000-2 | §7.6 | |
| Permission bit 3 controls printing. | ISO 32000-2 | §7.6 | |
| Permission bit 6 controls annotation / form-fill. | ISO 32000-2 | §7.6 | |
| Encryption protects contents from unauthorised access (confidentiality). | ISO 32000-2 | §7.6 | |
| Revision-6 key derivation uses salted iterative hashing (Algorithm 2.B). | ISO 32000-2 | §7.6 | |
| CBC is a confidentiality mode (not an integrity mode). | NIST SP 800-38A | §6.2 | |
| CBC initialization vectors must be unpredictable. | NIST SP 800-38A | App. C |
NextPDF implements the cited clauses. It does not assert blanket ISO 32000-2 conformance, FIPS 140 validation, or any legal or contractual guarantee of confidentiality. “Support for the standard security handler” is not a certification of security in your deployment. That depends on password custody and verifier policy outside the library.