Skip to content

Encrypt a PDF and restrict permissions

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 P entry 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”.

Terminal window
composer require nextpdf/core:^3

Enable the openssl PHP extension. The AES-256 encryptor uses it for the cipher and key derivation.

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.

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 = -1 grants all permissions. When ownerPassword is empty, the user password is reused as the owner password. Call before addPage().
  • getEncryptor(): ?Aes256Encryptor — the configured encryptor, or null.
  • 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):

BitValueOperation
34Print the document
48Modify document contents
516Copy / extract text and graphics
632Add or modify annotations and fill form fields
<?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";

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.

  • Call order. setEncryption() after addPage() 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 Encrypt trailer key. Calling setEncryption() 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 throws InvalidConfigException. It is incompatible with PDF/A for the same reason.
  • Public-key encryption is not yet wired. setPublicKeyEncryption() freezes the API surface, but save() throws until the writer wiring lands (a known defect). Do not use it in production on Core.

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.

  • 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.

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.

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.

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.

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.

StatementSpecClausereference_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-38AApp. 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.