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. |
|
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. |
|
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. |
|
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) |
|
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) |
|
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 |
|
RC4, 40-bit key in the plain RC4 variant (measured). The POI encryption page says RC4 is not really secure. |
Read old files. Do not write new ones unless a legacy consumer forces it. |
XOR obfuscation |
|
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), notPK. A file-type check based onPKrejects every protected upload. -
Opening it with
new XSSFWorkbook(file)ornew XWPFDocument(stream)fails. On 5.5.1 this throws anOLE2NotOfficeXmlFileException, which tells you the data is an OLE2 file and not an OOXML package. -
The file extension is still
.xlsxor.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.
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, whichsaveneeds. A package opened from a file withPackageAccess.READfails insavewithInvalidOperationException: Operation not allowed, document open in read only mode!(reproduced on 5.5.1). -
The
OutputStreamreturned bygetDataStreamis closed beforewriteFilesystemis called. Closing it flushes the last cipher block and the padding; the official page repeats this warning. Thetry-with-resourcesorder above (package first declared, stream second, closed in reverse) does that. The stream ofEncryptedTempDatais closed before it is read back, for the same reason. -
confirmPasswordmust be called beforegetDataStream. It derives the key from the password and creates the salts. -
The temporary data is encrypted, but it is still a file:
dispose()infinallydeletes it. If plaintext on disk is acceptable, a plainSXSSFWorkbookwritten to a temporary file and reopened withOPCPackage.open(File, PackageAccess.READ_WRITE)works too; then delete that file infinally. -
Encryption is not streaming end to end.
OPCPackage.open(InputStream)reads the plain package into memory, andnew POIFSFileSystem()builds the OLE2 container in memory, so the encrypted package is held on the heap untilwriteFilesystemis 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();
}
}
}
}
Source: https://poi.apache.org/encryption.html, and the Javadoc of EncryptionInfo.
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();
}
}
}
Source: LoadPasswordProtectedXlsx
and LoadPasswordProtectedXlsxStreaming
in poi-examples; WorkbookFactory Javadoc.
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 aWorkbook. It works for.xlstoo, by settingBiff8EncryptionKeyinternally. A wrong password raisesEncryptedDocumentException, which the example wraps in anIllegalArgumentException, as the last line of the output shows. -
The
Decryptorroute returns a plainOPCPackage, which is whatXSSFReaderneeds 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.
-
Set the password before opening the file or, for writing, before saving it.
-
Setting
nullbefore saving removes the password protection. -
The value lives in a
ThreadLocal; reset it tonullafter 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 |
|---|---|---|
|
XML signature creation and verification |
3.0.6 (the version declared by |
|
Certificate handling, time stamp and revocation helpers |
1.84 (the POI page says it was tested against 1.84; |
|
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
OPCPackageand then saving the workbook or document again invalidates the signature. The official page recommends saving the document into a byte array first, opening anOPCPackageon 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
SignatureConfigselects 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
.xlsor.docfiles 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);
}
}
}
Source: https://poi.apache.org/components/spreadsheet/quick-guide.html (cell locking and sheet protection), XSSFSheet Javadoc, XSSFWorkbook Javadoc and XWPFDocument Javadoc.
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 asXXXX). 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
workbookProtectionanddocumentProtectionlines 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
sheetProtectionelement 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 |
|---|---|
|
Locks the sheet (with a password hash). Passing |
|
Marks cells that stay editable ( |
|
Fine-grained permissions. |
|
Workbook protection: no adding, deleting, renaming, hiding or moving of sheets (structure), fixed window position (windows), and revision tracking. |
|
Stores a salted, iterated hash for the workbook protection. |
|
The |
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.getDataStreamgives a decrypting stream. Passing it tonew 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
@Deprecatedmarkers before upgrading.
Version notes
| Version | Notes |
|---|---|
4.x |
Encryption classes in |
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. |
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
PKbytes only: encrypted OOXML starts with the OLE2 signature. -
Closing the
Encryptordata stream afterwriteFilesystem, which leaves the last block unwritten and the file corrupt. -
Forgetting to reset
Biff8EncryptionKeyin a thread pool. -
Opening the temporary plain export with
PackageAccess.READand then callingsave. -
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
-
Apache POI, Encryption support: https://poi.apache.org/encryption.html
-
Apache POI, Secure processing: https://poi.apache.org/security.html
-
Apache POI, POIFS (OLE2 container): https://poi.apache.org/components/poifs/
-
Apache POI, Spreadsheet Quick Guide: https://poi.apache.org/components/spreadsheet/quick-guide.html
-
Apache POI, Spreadsheet How-To (SXSSF): https://poi.apache.org/components/spreadsheet/how-to.html#sxssf
-
Javadoc,
EncryptionInfo: https://poi.apache.org/apidocs/dev/org/apache/poi/poifs/crypt/EncryptionInfo.html -
Javadoc,
Decryptor: https://poi.apache.org/apidocs/dev/org/apache/poi/poifs/crypt/Decryptor.html -
Javadoc,
Encryptor: https://poi.apache.org/apidocs/dev/org/apache/poi/poifs/crypt/Encryptor.html -
Javadoc,
Biff8EncryptionKey: https://poi.apache.org/apidocs/dev/org/apache/poi/hssf/record/crypto/Biff8EncryptionKey.html -
Javadoc,
SignatureInfo: https://poi.apache.org/apidocs/dev/org/apache/poi/poifs/crypt/dsig/SignatureInfo.html -
Javadoc,
SignatureConfig: https://poi.apache.org/apidocs/dev/org/apache/poi/poifs/crypt/dsig/SignatureConfig.html -
Javadoc,
WorkbookFactory: https://poi.apache.org/apidocs/dev/org/apache/poi/ss/usermodel/WorkbookFactory.html -
Javadoc,
XSSFSheet: https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/usermodel/XSSFSheet.html -
Javadoc,
XSSFWorkbook: https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/usermodel/XSSFWorkbook.html -
Javadoc,
XWPFDocument: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFDocument.html -
Example, LoadPasswordProtectedXlsx: https://github.com/apache/poi/blob/trunk/poi-examples/src/main/java/org/apache/poi/examples/xssf/usermodel/LoadPasswordProtectedXlsx.java
-
Example, LoadPasswordProtectedXlsxStreaming: https://github.com/apache/poi/blob/trunk/poi-examples/src/main/java/org/apache/poi/examples/xssf/eventusermodel/LoadPasswordProtectedXlsxStreaming.java
-
Microsoft, Office file format cryptography (MS-OFFCRYPTO): https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/
-
Apache Santuario (XML Security for Java): https://santuario.apache.org/
-
Bouncy Castle: https://www.bouncycastle.org/
-
Oracle,
java.security.KeyStore: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/security/KeyStore.html