Encryption, Signing & Protection

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Baseline date: 2026-10-09. This page was written against Apache POI 5.5.1 (the latest release on that date; the 6.0 line is in development and has not been released), Java 21 and later. It covers three topics that are easy to confuse and that protect against three different things. Encryption makes the file unreadable without a password. A digital signature proves who produced the file and that it was not changed afterwards. Protection (a locked sheet, a read-only document) only asks the editing application not to let the user change something; it does not hide anything and it does not stop a program. The examples use the Bookshelf scenario: the large loans-report.xlsx export is encrypted before it is handed out, a protected .xlsx upload is read, loan-letter.docx is signed, and books-inventory.xlsx gets sheet protection.

Every example on this page reads secrets from configuration. The helper used throughout takes the password from the system property bookshelf.office.password and falls back to the environment variable BOOKSHELF_OFFICE_PASSWORD; nothing is hard-coded, and a missing value stops the program instead of using a default.

package bookshelf;

/** Reads secrets from configuration. Nothing is hard-coded. */
final class OfficeSecrets {

    private OfficeSecrets() {
    }

    /** System property first (-Dbookshelf.office.password=...), then the environment. */
    static String password() {
        String value = System.getProperty("bookshelf.office.password");
        if (value == null || value.isBlank()) {
            value = System.getenv("BOOKSHELF_OFFICE_PASSWORD");
        }
        if (value == null || value.isBlank()) {
            throw new IllegalStateException(
                    "Set -Dbookshelf.office.password or BOOKSHELF_OFFICE_PASSWORD");
        }
        return value;
    }
}

In a real service the value would come from a secret store injected into the environment (a Kubernetes secret, a vault agent, Spring’s Environment), not from a file in the repository. Passwords also stay out of logs, exception messages and file names, and a char[] or String that holds one is not kept longer than needed.

Three different mechanisms

The table is the most important part of this page. Read it before choosing an API.

Mechanism What it does What it does not do POI API

Encryption

Encrypts the whole file with a key derived from a password. Without the password the content cannot be read.

Does not prove who wrote the file. Anyone who knows the password can change and re-save it.

EncryptionInfo, Encryptor, Decryptor (.xlsx, .docx, .pptx); Biff8EncryptionKey (.xls, .ppt, .doc)

XML digital signature

Stores a signature over the package parts. Any later change makes verification fail.

Does not hide the content. The file stays readable by everyone. Verification proves integrity; trust in the signer is your decision.

SignatureConfig, SignatureInfo (OOXML only)

Protection (sheet, workbook structure, document)

Sets flags and an optional password hash so that Excel or Word refuse to edit locked parts.

Does not encrypt anything. The data is in the file in clear text and POI itself ignores the flags.

protectSheet, lockStructure, setWorkbookPassword, enforceReadonlyProtection

Choosing between them is a matter of the threat. A file sent to a person who must not be able to read it needs encryption. A file that anyone may read but nobody may alter unnoticed needs a signature. A template where users should only fill in a few cells needs protection, as a convenience, and nothing more.

Encryption modes

Office defines several encryption schemes, specified in the Microsoft Office document cryptography structure (MS-OFFCRYPTO). POI implements them in package org.apache.poi.poifs.crypt (jar poi, with the OOXML parts in poi-ooxml) and in org.apache.poi.hssf.record.crypto for the binary formats. The EncryptionMode enum lists the choices: binaryRC4, cryptoAPI, standard, agile and xor.

Mode Used by Characteristics Recommendation

Agile (ECMA-376)

.xlsx, .docx, .pptx from Office 2010

Password-based AES with a chaining mode and a configurable hash; the file stores a spin count that makes password guessing expensive. POI’s default is AES-128 with CBC chaining and SHA-1 as the hash (measured on 5.5.1); AES-256 with SHA-512 can be requested.

Use this for anything new, and set the algorithms explicitly.

Standard (ECMA-376)

.xlsx, .docx, .pptx from Office 2007

AES with ECB chaining (the default on 5.5.1 reports AES-128 and SHA-1). The POI documentation calls ECB chaining problematic.

Only for compatibility with very old readers.

Binary RC4 and RC4 CryptoAPI

.xls, .doc, .ppt, and RC4 variants of OOXML

RC4, 40-bit key in the plain RC4 variant (measured). The POI encryption page says RC4 is not really secure. cryptoAPI is used internally; its direct use for OOXML produces an incomplete file.

Read old files. Do not write new ones unless a legacy consumer forces it.

XOR obfuscation

.xls only

A weak scheme. The POI page states that it is flawed and works only for very small files.

Treat the result as obfuscation, not encryption.

The same page lists what POI can read and write per format: XOR for HSSF (writing since 3.16), RC4 and RC4 CryptoAPI for HSSF, HSLF and HWPF, and the ECMA-376 Standard, Agile and XML Signature features for XSSF, XSLF and XWPF. Check the current matrix on the encryption page before promising a format to users.

Two practical points apply to every mode. First, the POI documentation warns that viewers have limits in the ciphers and hashes they accept, so test the result in the Excel or Word versions your recipients use. Second, key sizes above 128 bit needed a "Java Cryptography Extension Unlimited Strength" policy on old Java 8 builds; the document still mentions this, but current Java releases ship with the unlimited policy enabled and nothing has to be installed.

Some files marked as "write-protected" are in fact encrypted with the built-in password VelvetSweatshop. POI can open them, and the constant is Decryptor.DEFAULT_PASSWORD. It is a convention of Excel, not a secret.

How an encrypted OOXML file is built

An unencrypted .xlsx is a ZIP package (see File Formats: OOXML & OLE2). An encrypted one is not a ZIP any more. It is an OLE2 container, the same structure as an old .xls, with at least two streams: EncryptionInfo, which describes the scheme, salt and verifier, and EncryptedPackage, which holds the original ZIP bytes encrypted in blocks. This has consequences you will meet in production.

  • The first bytes of the file are the OLE2 signature (D0 CF 11 E0), not PK. A file-type check based on PK rejects every protected upload.

  • Opening it with new XSSFWorkbook(file) or new XWPFDocument(stream) fails. On 5.5.1 this throws an OLE2NotOfficeXmlFileException, which tells you the data is an OLE2 file and not an OOXML package.

  • The file extension is still .xlsx or .docx, so the name tells you nothing. Detect encryption by looking inside the container, as shown below.

The flow in both directions is the following.

flowchart LR subgraph ENC["Encrypt (export)"] direction TB A["Workbook or document written to a plain OOXML package"] --> B["EncryptionInfo with mode agile"] B --> C["Encryptor.confirmPassword"] C --> D["Encryptor.getDataStream on a new POIFSFileSystem"] D --> E["Package bytes copied into the stream, then closed"] E --> F["POIFSFileSystem.writeFilesystem"] F --> G["OLE2 file with EncryptionInfo and EncryptedPackage"] end subgraph DEC["Decrypt (import)"] direction TB H["Upload bytes"] --> I{"OLE2 with an EncryptedPackage entry?"} I -- "no" --> J["Open as a plain OOXML file"] I -- "yes" --> K["EncryptionInfo from the POIFSFileSystem"] K --> L["Decryptor.verifyPassword"] L -- "false" --> M["Reject: wrong password"] L -- "true" --> N["Decryptor.getDataStream"] N --> O["XSSFWorkbook, XWPFDocument or OPCPackage"] end G -. "sent to the user" .-> H

Encrypting an SXSSF export

The loans-report.xlsx export is written with SXSSF, the streaming API described in Streaming Writes with SXSSF. Encryption is a post-processing step: write the plain package, then re-open it and stream it through an Encryptor. This follows the official SavePasswordProtectedXlsx example: SXSSFWorkbookWithCustomZipEntrySource keeps the SXSSF temporary sheet files encrypted, and EncryptedTempData holds the plain package encrypted with a throwaway key, so no plaintext copy of the export is ever written to disk.

package bookshelf;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;

import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.poifs.crypt.EncryptionInfo;
import org.apache.poi.poifs.crypt.EncryptionMode;
import org.apache.poi.poifs.crypt.Encryptor;
import org.apache.poi.poifs.crypt.temp.EncryptedTempData;
import org.apache.poi.poifs.crypt.temp.SXSSFWorkbookWithCustomZipEntrySource;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;

public final class EncryptedLoansExport {

    private EncryptedLoansExport() {
    }

    /** Writes loans-report.xlsx with SXSSF and encrypts it with Agile encryption; no plaintext file reaches the disk. */
    public static void export(Path target, String password)
            throws IOException, GeneralSecurityException, InvalidFormatException {
        // SXSSF subclass that encrypts its own temporary sheet files with a throwaway key
        try (SXSSFWorkbookWithCustomZipEntrySource workbook = new SXSSFWorkbookWithCustomZipEntrySource()) {
            Sheet sheet = workbook.createSheet("Loans");
            for (int i = 0; i < 50_000; i++) {
                Row row = sheet.createRow(i);
                row.createCell(0).setCellValue("Loan " + i);
                row.createCell(1).setCellValue(i % 14);
            }
            EncryptedTempData plain = new EncryptedTempData(); // the plain package, encrypted on disk
            try {
                try (OutputStream out = plain.getOutputStream()) {
                    workbook.write(out);
                }
                try (InputStream in = plain.getInputStream()) {
                    encrypt(in, target, password);
                }
            } finally {
                plain.dispose();
            }
        }
    }

    static void encrypt(InputStream plainPackage, Path target, String password)
            throws IOException, GeneralSecurityException, InvalidFormatException {
        try (POIFSFileSystem fs = new POIFSFileSystem()) {
            EncryptionInfo info = new EncryptionInfo(EncryptionMode.agile);
            Encryptor encryptor = info.getEncryptor();
            encryptor.confirmPassword(password);
            try (OPCPackage pkg = OPCPackage.open(plainPackage);
                 OutputStream encrypted = encryptor.getDataStream(fs)) {
                pkg.save(encrypted);
            }
            try (OutputStream out = Files.newOutputStream(target)) {
                fs.writeFilesystem(out);
            }
        }
    }

    public static void main(String[] args) throws Exception {
        Path target = Path.of(args[0]);
        export(target, OfficeSecrets.password());
        System.out.println("Wrote " + target + " (" + Files.size(target) + " bytes)");
    }
}

Source: SavePasswordProtectedXlsx in poi-examples, and https://poi.apache.org/encryption.html (section "XML-based formats - Encryption").

Compiled and run against POI 5.5.1 with a 50,000-row export, the program prints Wrote out-loans.xlsx (598016 bytes). The result starts with the bytes d0cf 11e0, which confirms that it is an OLE2 container and not a ZIP file.

Several details of this code are deliberate.

  • OPCPackage.open(InputStream) opens the package read-write, which save needs. A package opened from a file with PackageAccess.READ fails in save with InvalidOperationException: Operation not allowed, document open in read only mode! (reproduced on 5.5.1).

  • The OutputStream returned by getDataStream is closed before writeFilesystem is called. Closing it flushes the last cipher block and the padding; the official page repeats this warning. The try-with-resources order above (package first declared, stream second, closed in reverse) does that. The stream of EncryptedTempData is closed before it is read back, for the same reason.

  • confirmPassword must be called before getDataStream. It derives the key from the password and creates the salts.

  • The temporary data is encrypted, but it is still a file: dispose() in finally deletes it. If plaintext on disk is acceptable, a plain SXSSFWorkbook written to a temporary file and reopened with OPCPackage.open(File, PackageAccess.READ_WRITE) works too; then delete that file in finally.

  • Encryption is not streaming end to end. OPCPackage.open(InputStream) reads the plain package into memory, and new POIFSFileSystem() builds the OLE2 container in memory, so the encrypted package is held on the heap until writeFilesystem is called. SXSSF keeps the workbook small, but a very large encrypted export still needs memory proportional to the file size. Measure before promising millions of rows.

  • The default Agile parameters are modest (AES-128, SHA-1 as the hash). To request stronger ones, use the extended constructor, as shown in the next example.

To send the result as an HTTP download, write to a temporary file or a ByteArrayOutputStream and stream that. The download mechanics are the same as for an unencrypted export; the content type stays application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.

Encrypting a Word document with explicit algorithms

The same classes encrypt a .docx. This example writes loan-letter.docx straight into the encrypted stream, without a temporary file, and chooses AES-256 with SHA-512. It then reads the document back.

package bookshelf;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.security.GeneralSecurityException;

import org.apache.poi.poifs.crypt.CipherAlgorithm;
import org.apache.poi.poifs.crypt.Decryptor;
import org.apache.poi.poifs.crypt.EncryptionInfo;
import org.apache.poi.poifs.crypt.EncryptionMode;
import org.apache.poi.poifs.crypt.Encryptor;
import org.apache.poi.poifs.crypt.HashAlgorithm;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.apache.poi.xwpf.usermodel.XWPFDocument;

public final class EncryptedLoanLetter {

    private EncryptedLoanLetter() {
    }

    /** Agile encryption with explicit algorithms; works for any XWPFDocument, XSSFWorkbook or XMLSlideShow. */
    public static void write(File target, String password) throws IOException, GeneralSecurityException {
        try (XWPFDocument document = new XWPFDocument();
             POIFSFileSystem fs = new POIFSFileSystem()) {
            document.createParagraph().createRun().setText("Dear reader, your loan of Dune is due.");

            EncryptionInfo info = new EncryptionInfo(EncryptionMode.agile,
                    CipherAlgorithm.aes256, HashAlgorithm.sha512, -1, -1, null);
            Encryptor encryptor = info.getEncryptor();
            encryptor.confirmPassword(password);
            // The stream must be closed before the file system is written: it flushes the last block.
            try (OutputStream encrypted = encryptor.getDataStream(fs)) {
                document.write(encrypted);
            }
            try (OutputStream out = Files.newOutputStream(target.toPath())) {
                fs.writeFilesystem(out);
            }
        }
    }

    public static String read(File source, String password) throws IOException, GeneralSecurityException {
        try (POIFSFileSystem fs = new POIFSFileSystem(source, true)) {
            EncryptionInfo info = new EncryptionInfo(fs);
            Decryptor decryptor = Decryptor.getInstance(info);
            if (!decryptor.verifyPassword(password)) {
                throw new IllegalArgumentException("Wrong password");
            }
            try (InputStream data = decryptor.getDataStream(fs);
                 XWPFDocument document = new XWPFDocument(data)) {
                return document.getParagraphs().get(0).getText();
            }
        }
    }
}

Run on POI 5.5.1, read returns Dear reader, your loan of Dune is due., the header of the file reports agile aes256 sha512, and opening the same file with new XWPFDocument(inputStream) and no password fails with OLE2NotOfficeXmlFileException. The six-argument EncryptionInfo constructor takes the mode, the cipher, the hash, the key size, the block size and the chaining mode; -1 and null mean "use the default for this cipher".

Because the encrypted bytes are produced by document.write(encrypted), the same pattern works for an XSSFWorkbook, and for a XMLSlideShow. Only the Workbook, XWPFDocument or slide show changes.

Reading protected uploads

An upload may be plain, may be password protected, or may be something else entirely. Decide which one before parsing. Three questions have to be answered: is it encrypted, can the configured password open it, and how should it be read.

package bookshelf;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;

import org.apache.poi.EncryptedDocumentException;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.poifs.crypt.Decryptor;
import org.apache.poi.poifs.crypt.EncryptionInfo;
import org.apache.poi.poifs.filesystem.NotOLE2FileException;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;

public final class ProtectedUploadReader {

    private ProtectedUploadReader() {
    }

    /** Tells a password-protected .xlsx/.docx (an OLE2 file with an EncryptedPackage stream) from a plain one. */
    public static boolean isEncryptedOoxml(File file) throws IOException {
        try (POIFSFileSystem fs = new POIFSFileSystem(file, true)) {
            return fs.getRoot().hasEntry(Decryptor.DEFAULT_POIFS_ENTRY);
        } catch (NotOLE2FileException notOle2) {
            return false;
        }
    }

    /** Usermodel read: WorkbookFactory decrypts transparently when given the password. */
    public static int countRowsWithFactory(File upload, String password) throws IOException {
        try (Workbook workbook = WorkbookFactory.create(upload, password, true)) {
            Sheet sheet = workbook.getSheetAt(0);
            return sheet.getPhysicalNumberOfRows();
        } catch (EncryptedDocumentException wrongPasswordOrUnsupported) {
            throw new IllegalArgumentException("Cannot open the file with the configured password",
                    wrongPasswordOrUnsupported);
        }
    }

    /** Streaming read: decrypt to an OPCPackage and hand it to XSSFReader (see the SAX page). */
    public static OPCPackage openDecrypted(File upload, String password)
            throws IOException, GeneralSecurityException, InvalidFormatException {
        POIFSFileSystem fs = new POIFSFileSystem(upload, true);
        try {
            EncryptionInfo info = new EncryptionInfo(fs);
            Decryptor decryptor = Decryptor.getInstance(info);
            if (!decryptor.verifyPassword(password)) {
                throw new IllegalArgumentException("Wrong password");
            }
            InputStream data = decryptor.getDataStream(fs);
            return OPCPackage.open(data);
        } finally {
            // OPCPackage.open(InputStream) has copied what it needs; the OLE2 container can go.
            fs.close();
        }
    }
}

Run against the encrypted loans-report.xlsx produced above, with the right password in the environment, the three methods report:

encrypted OOXML: true
rows via WorkbookFactory: 50000
parts via streaming route: 8
wrong password -> IllegalArgumentException (cause: EncryptedDocumentException)

The two reading strategies map to the two APIs of the Excel pages.

  • WorkbookFactory.create(File, String, boolean) is the shortest route to the usermodel API (Spreadsheet Usermodel Basics). It detects the format, decrypts when needed and returns a Workbook. It works for .xls too, by setting Biff8EncryptionKey internally. A wrong password raises EncryptedDocumentException, which the example wraps in an IllegalArgumentException, as the last line of the output shows.

  • The Decryptor route returns a plain OPCPackage, which is what XSSFReader needs for the event API of Streaming Reads: Event & SAX APIs. That keeps a big protected upload out of the object model.

For an upload service, the usual rules apply on top. Keep the password policy outside the code (who may supply a password, per user or per tenant), limit the number of attempts per upload, and do not echo the password in an error message. Key derivation repeats the hash many times by design, so each verifyPassword call is intentionally slow, which is also the reason to rate-limit uploads that carry a password. A user-supplied password is a per-request secret and must be dropped after the call. Treat the decrypted package like any other untrusted upload, with the limits described in the security guidance of the Apache POI site (https://poi.apache.org/security.html).

Legacy binary formats and the Biff8EncryptionKey ThreadLocal

For .xls (and, according to the POI feature matrix, .doc and .ppt) encryption lives in the format itself and POI has no Encryptor object to configure. Instead, the password is handed to the reader or writer through a static, thread-local setter: Biff8EncryptionKey.setCurrentUserPassword(String). The official page gives three rules.

  1. Set the password before opening the file or, for writing, before saving it.

  2. Setting null before saving removes the password protection.

  3. The value lives in a ThreadLocal; reset it to null after the work, otherwise it stays attached to the thread.

The third rule is the one that bites in servers. A web container reuses its worker threads, so a password left behind by one request is still active when the same thread handles an unrelated request, which may then silently write an encrypted file or fail to open a plain one. Put the reset in a finally block, every time.

package bookshelf;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;

import org.apache.poi.hssf.record.crypto.Biff8EncryptionKey;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.apache.poi.ss.usermodel.Workbook;

public final class LegacyXlsPassword {

    private LegacyXlsPassword() {
    }

    /** Writes an RC4 CryptoAPI protected .xls. The password is a ThreadLocal: always reset it. */
    public static void write(File target, String password) throws IOException {
        Biff8EncryptionKey.setCurrentUserPassword(password);
        try (Workbook workbook = new HSSFWorkbook();
             OutputStream out = Files.newOutputStream(target.toPath())) {
            workbook.createSheet("Books").createRow(0).createCell(0).setCellValue("Dune");
            workbook.write(out);
        } finally {
            Biff8EncryptionKey.setCurrentUserPassword(null);
        }
    }

    /** Reads a protected .xls; wrong passwords raise EncryptedDocumentException. */
    public static String readFirstCell(File source, String password) throws IOException {
        Biff8EncryptionKey.setCurrentUserPassword(password);
        try (POIFSFileSystem fs = new POIFSFileSystem(source, true);
             HSSFWorkbook workbook = new HSSFWorkbook(fs.getRoot(), true)) {
            return workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue();
        } finally {
            Biff8EncryptionKey.setCurrentUserPassword(null);
        }
    }
}

Source: https://poi.apache.org/encryption.html (section "Binary formats") and the Biff8EncryptionKey Javadoc.

Run on POI 5.5.1, the first cell reads back as Dune, a wrong password raises EncryptedDocumentException, and Biff8EncryptionKey.getCurrentUserPassword() returns null afterwards. The class belongs to the HSSF code, so the legacy format is described in Legacy .xls with HSSF and .doc files in Legacy .doc with HWPF. Writing with a password produces the RC4 CryptoAPI scheme here; as said above, that is an old scheme, so prefer .xlsx with Agile encryption when you control both ends.

If your code runs asynchronously (a job on an executor, a reactive pipeline), the password must be set and reset on the thread that actually calls POI, not on the thread that received the request.

XML digital signatures

A digital signature protects integrity and authenticity. It is available for OOXML files only (.xlsx, .docx, .pptx) and is stored inside the package as _xmlsignatures/sig1.xml, with an origin.sigs marker part and its relationships. A signed file is still an ordinary, readable ZIP.

The signing code lives in org.apache.poi.poifs.crypt.dsig (jar poi-ooxml). It is based on the eID applet code and uses Apache Santuario (xmlsec) for the XML signature itself and BouncyCastle for certificates and (optionally) the higher XAdES levels. Both are optional dependencies of poi-ooxml, which means they are not on the class path unless you add them. The official page lists the libraries it was tested against.

Dependency Purpose Version used for the example on this page

org.apache.santuario:xmlsec

XML signature creation and verification

3.0.6 (the version declared by poi-ooxml 5.5.1; the POI page says it was tested against 3.0.5)

org.bouncycastle:bcpkix-jdk18on, bcutil-jdk18on and bcprov-jdk18on

Certificate handling, time stamp and revocation helpers

1.84 (the POI page says it was tested against 1.84; poi-ooxml 5.5.1 declares 1.83)

org.slf4j:slf4j-api

Logging of Santuario, which does not use Log4j

2.0.17, plus a binding of your choice

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.5.1</version>
</dependency>
<dependency>
    <groupId>org.apache.santuario</groupId>
    <artifactId>xmlsec</artifactId>
    <version>3.0.6</version>
</dependency>
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcpkix-jdk18on</artifactId>
    <version>1.84</version>
</dependency>
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcutil-jdk18on</artifactId>
    <version>1.84</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.17</version>
</dependency>

In Gradle Kotlin DSL the same coordinates are:

dependencies {
    implementation("org.apache.poi:poi-ooxml:5.5.1")
    implementation("org.apache.santuario:xmlsec:3.0.6")
    implementation("org.bouncycastle:bcpkix-jdk18on:1.84")
    implementation("org.bouncycastle:bcutil-jdk18on:1.84")
    implementation("org.slf4j:slf4j-api:2.0.17")
}

The example below compiled and ran with the poi-ooxml-lite schema jar that poi-ooxml brings by default; the code on this page did not need poi-ooxml-full (see Setup & Dependencies for the difference). If a signature configuration you add later triggers a NoClassDefFoundError for a CT* class, swap in poi-ooxml-full. If you use a SHA-512 digest, Santuario’s Base64 encoder inserts line breaks that Office rejects, and the official page tells you to start the JVM with -Dorg.apache.xml.security.ignoreLineBreaks=true.

Signing and verifying a document

The signing key and its certificate come from a PKCS#12 keystore. Its location and password are configuration values, read through the same helper as the other examples. The program does not create keys; in production the keystore is provisioned by your PKI.

package bookshelf;

import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.List;

import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.poifs.crypt.HashAlgorithm;
import org.apache.poi.poifs.crypt.dsig.SignatureConfig;
import org.apache.poi.poifs.crypt.dsig.SignatureInfo;

/** Adds an XML digital signature to an existing .docx / .xlsx and verifies it. */
public final class SignedLoanLetter {

    private SignedLoanLetter() {
    }

    /** Keystore location and password come from configuration, never from the source code. */
    public static void sign(Path document, Path keystoreFile, char[] keystorePassword, String alias)
            throws Exception {
        KeyStore keystore = KeyStore.getInstance("PKCS12");
        try (InputStream in = Files.newInputStream(keystoreFile)) {
            keystore.load(in, keystorePassword);
        }
        Key key = keystore.getKey(alias, keystorePassword);
        X509Certificate certificate = (X509Certificate) keystore.getCertificate(alias);

        SignatureConfig config = new SignatureConfig();
        config.setKey((PrivateKey) key);
        config.setSigningCertificateChain(List.of(certificate));
        config.setDigestAlgo(HashAlgorithm.sha256);

        try (OPCPackage pkg = OPCPackage.open(document.toFile(), PackageAccess.READ_WRITE)) {
            SignatureInfo info = new SignatureInfo();
            info.setOpcPackage(pkg);
            info.setSignatureConfig(config);
            info.confirmSignature();
            if (!info.verifySignature()) {
                throw new IllegalStateException("Signature does not verify");
            }
        } // close() writes the package back, including the new signature part
    }

    /** Verifies the signatures of a package. This proves integrity, not that you trust the signer. */
    public static boolean verify(Path document) throws Exception {
        try (OPCPackage pkg = OPCPackage.open(document.toFile(), PackageAccess.READ)) {
            SignatureConfig config = new SignatureConfig();
            SignatureInfo info = new SignatureInfo();
            info.setOpcPackage(pkg);
            info.setSignatureConfig(config);
            return info.verifySignature();
        }
    }

    public static void main(String[] args) throws Exception {
        Path document = Path.of(args[0]);
        Path keystore = Path.of(System.getProperty("bookshelf.sign.keystore",
                System.getenv("BOOKSHELF_SIGN_KEYSTORE")));
        char[] password = OfficeSecrets.password().toCharArray();
        sign(document, keystore, password, System.getProperty("bookshelf.sign.alias", "bookshelf"));
        System.out.println("verified after signing: " + verify(document));
    }
}

Source: https://poi.apache.org/encryption.html (sections "Validating a signed office document" and "Signing an office document"), SignatureInfo Javadoc and SignatureConfig Javadoc.

The example was compiled and run on POI 5.5.1 with a throwaway self-signed key pair generated by keytool in a PKCS#12 keystore (a test fixture, not part of the code). verified after signing: true was printed, and the package then contained the three signature parts.

        0  _xmlsignatures/origin.sigs
      294  _xmlsignatures/_rels/origin.sigs.rels
     6487  _xmlsignatures/sig1.xml

Three further checks confirm what a signature does and does not do. A signed copy verifies as true. An unsigned .docx verifies as false. After changing one word inside word/document.xml of the signed copy and re-zipping it, verify returns false. A signature therefore detects any later change of the signed parts, but it does not hide them.

Points to keep in mind.

  • Sign last. POI creates missing relationships when it saves a document, so signing an OPCPackage and then saving the workbook or document again invalidates the signature. The official page recommends saving the document into a byte array first, opening an OPCPackage on those bytes, signing, and then saving the package (the "signing a stream" recipe).

  • verifySignature() checks the cryptographic integrity of the signature against the certificate embedded in it. It does not decide whether that certificate is trusted, not revoked and not expired. Validate the certificate chain against your own trust store if the signer’s identity matters.

  • The SignatureConfig selects the digest algorithm and the XAdES facets. The default produces a basic XML-DSig style signature; higher XAdES levels (time stamp, revocation) depend on external services and the official page says they are not well tested.

  • Use a certificate whose key usage allows digitalSignature, as the official example comment states, and protect the keystore like any private key: file permissions, a password that comes from configuration, and no copy in the repository.

  • The POI signing API is documented for OOXML packages only. It does not sign .xls or .doc files or PDFs; PDF signing is a topic of the PDF generation libraries.

Sheet, workbook and document protection

Protection is a convenience for people using Excel or Word. It is not a security boundary, and the clearest way to see that is to read the XML POI writes.

package bookshelf;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;

import org.apache.poi.poifs.crypt.HashAlgorithm;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xwpf.usermodel.XWPFDocument;

/** Protection is a "please do not edit" flag, not encryption. */
public final class ProtectionDemo {

    private ProtectionDemo() {
    }

    public static void protectInventory(File target, String password) throws IOException {
        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
            XSSFSheet sheet = workbook.createSheet("Books");
            CellStyle editable = workbook.createCellStyle();
            editable.setLocked(false); // cells stay editable while the sheet is protected

            Row row = sheet.createRow(0);
            row.createCell(0).setCellValue("Dune");                  // locked (default)
            row.createCell(1).setCellValue(3);
            row.getCell(1).setCellStyle(editable);                   // unlocked: copies in stock

            sheet.protectSheet(password);                            // sheetProtection with a legacy hash
            sheet.lockFormatColumns(false);                          // but allow resizing columns
            sheet.lockSort(false);

            workbook.lockStructure();                                // no add/delete/rename sheets
            workbook.setWorkbookPassword(password, HashAlgorithm.sha512);

            try (OutputStream out = Files.newOutputStream(target.toPath())) {
                workbook.write(out);
            }
        }
    }

    public static void readOnlyLetter(File target, String password) throws IOException {
        try (XWPFDocument document = new XWPFDocument();
             OutputStream out = Files.newOutputStream(target.toPath())) {
            document.createParagraph().createRun().setText("Loan letter");
            document.enforceReadonlyProtection(password, HashAlgorithm.sha512);
            document.write(out);
        }
    }

    public static void formOnlyForm(File target) throws IOException {
        try (XWPFDocument document = new XWPFDocument();
             OutputStream out = Files.newOutputStream(target.toPath())) {
            document.createParagraph().createRun().setText("Book request form");
            document.enforceFillingFormsProtection(); // only form fields / content controls are editable
            document.write(out);
        }
    }
}

Run on POI 5.5.1 (the file is written with the first two methods, reopened, and inspected), the output and the XML look like this.

sheet protected: true
password matches: true
password wrong: false
structure locked: true
workbook password valid: true
edit through POI: edited without any password
docx read-only enforced: true
form protection: true
<sheetProtection password="XXXX" sheet="true" scenarios="true" objects="true" formatColumns="false" sort="false"/>
<workbookProtection lockStructure="true" workbookAlgorithmName="SHA-512" workbookHashValue="..." workbookSaltValue="..." workbookSpinCount="100000"/>
<w:documentProtection w:enforcement="on" w:edit="readOnly" w:salt="..." w:hash="..." w:cryptSpinCount="100000" .../>

The values of the salts and hashes are shortened in the listing. Four conclusions follow.

  • The sheet password is a 16-bit hash. protectSheet(password) writes the old four-hex-digit hash (shown here as XXXX). With only 65,536 possible values, any password can be replaced by another that has the same hash. It keeps a casual user out, nothing more. sheet.validateSheetPassword(...) compares against it.

  • Workbook and document protection can use SHA-512 and a spin count, as the workbookProtection and documentProtection lines show. That is stronger against guessing, but it still protects the flag, not the data.

  • POI ignores protection. The last-but-three line shows POI changing a cell on a protected sheet without a password. The flags are read and written, never enforced. Any program can also remove the sheetProtection element from the XML.

  • Nothing is hidden. The cell values of a protected sheet and the paragraphs of a read-only document are plain XML in a plain ZIP. Anyone can unzip the file and read them.

The Excel calls and what they set:

Call Effect

XSSFSheet.protectSheet(String), HSSFSheet.protectSheet(String), SXSSFSheet.protectSheet(String)

Locks the sheet (with a password hash). Passing null removes the protection. enableLocking / disableLocking set the flag without a password.

CellStyle.setLocked(boolean)

Marks cells that stay editable (false) or locked (true, the default) once the sheet is protected. Locking only has an effect on a protected sheet.

XSSFSheet.lockFormatCells, lockFormatColumns, lockFormatRows, lockInsertRows, lockInsertColumns, lockDeleteRows, lockDeleteColumns, lockSort, lockAutoFilter, lockPivotTables, lockInsertHyperlinks, lockObjects, lockScenarios, lockSelectLockedCells, lockSelectUnlockedCells

Fine-grained permissions. lockX(true) forbids the action and lockX(false) allows it on a protected sheet, so lockFormatColumns(false) above lets users resize columns.

XSSFWorkbook.lockStructure(), lockWindows(), lockRevision()

Workbook protection: no adding, deleting, renaming, hiding or moving of sheets (structure), fixed window position (windows), and revision tracking.

XSSFWorkbook.setWorkbookPassword(String, HashAlgorithm), validateWorkbookPassword(String)

Stores a salted, iterated hash for the workbook protection. setRevisionsPassword does the same for revisions.

HSSFWorkbook.writeProtectWorkbook(password, username), isWriteProtected(), unwriteProtectWorkbook()

The .xls "write reservation": the file opens read-only unless the password is given. Again, not encryption.

The Word calls are enforceReadonlyProtection(), enforceFillingFormsProtection() (users edit only form fields and content controls, which matches the book-request-form.docx of this section), enforceCommentsProtection() and enforceTrackedChangesProtection(). Each has an overload that takes a password and a HashAlgorithm, and removeProtectionEnforcement() turns it off. The matching checks are isEnforcedReadonlyProtection(), isEnforcedFillingFormsProtection() and so on. A protected Word form is described together with headers and fields in Word Headers, Footers, Sections & Fields.

The decision rule is short. If the content is confidential, encrypt it. If it must not change unnoticed, sign it. If you only want to keep honest users from overwriting a formula, protect it, and do not describe that as security in the requirements, in the user interface or in an audit.

Memory, performance and operational notes

  • Encryption and decryption are CPU-bound (AES over the whole package plus a deliberately slow key derivation). They add a pass over the data but, apart from the in-memory POIFSFileSystem, no object model.

  • The encrypted container is built in memory when you use new POIFSFileSystem(). For uploads, new POIFSFileSystem(File, true) opens the container read-only from disk. Opening a stream instead of a file copies the bytes into memory.

  • Decryptor.getDataStream gives a decrypting stream. Passing it to new XSSFWorkbook(...) builds the usual in-memory model, so a big protected upload needs the same memory as a big plain one; use the event API (above) to avoid it.

  • Password-protected uploads are a way to hide malicious content from naive scanners: a virus checker or a content filter sees only encrypted bytes. Decide by policy whether to accept encrypted uploads at all, and apply the usual size and structure limits after decrypting.

  • Verify the supported algorithms with every upgrade. The 6.0 line is announced as removing deprecated API; nothing about the encryption classes used here was reported as changing at the time of writing, but check the Javadoc for @Deprecated markers before upgrading.

Version notes

Version Notes

4.x

Encryption classes in org.apache.poi.poifs.crypt exist. The signing code was configured differently before the 5.0.0 change in OPCPackage handling (the official page says its examples were adapted to the 5.0.0 API).

5.1.0

Logging moved to Log4j 2 API. Santuario logs through SLF4J, so signing needs its own logging binding (the official page says the combination was not retested).

5.5.1 (latest on 2026-10-09)

The behaviour documented on this page. poi-ooxml declares xmlsec 3.0.6 and BouncyCastle 1.83 as optional dependencies.

6.0 (in development, not released as of 2026-10-09)

Not available. Expect a Java 17 baseline and the removal of APIs deprecated in 5.x. Re-test encryption and signing code on the first release.

Pitfalls

  • Treating protection as encryption, or a signed file as a secret one.

  • Checking an upload with PK bytes only: encrypted OOXML starts with the OLE2 signature.

  • Closing the Encryptor data stream after writeFilesystem, which leaves the last block unwritten and the file corrupt.

  • Forgetting to reset Biff8EncryptionKey in a thread pool.

  • Opening the temporary plain export with PackageAccess.READ and then calling save.

  • Editing a document after signing it, which invalidates the signature.

  • Hard-coding a password or a keystore path. Use configuration, as in OfficeSecrets.

  • Using RC4 or XOR output for anything confidential.

References