Document Properties & Metadata

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, Spring Boot 4.x on Spring Framework 7 and Jakarta EE 11. Every Word and Excel file carries a small block of metadata next to its content: title, author, creation and modification dates, the application that wrote it, a company name, keywords and any number of user-defined fields. File explorers, search indexes, document management systems and the "Info" panel of Word and Excel read it, and so do auditors. A server that generates reports therefore has two jobs: write correct metadata (and not leave the default Apache POI creator and a "now" timestamp where something else is wanted), and treat the metadata of files it receives as untrusted data. This page covers both, for the two physical formats described in File Formats: OOXML & OLE2: the OOXML API (POIXMLProperties, for .docx and .xlsx) and the OLE2 API (HPSF, for .xls and .doc). The examples use the Bookshelf scenario described in the section landing page: books-inventory.xlsx, loans-report.xlsx and loan-letter.docx exported with consistent metadata.

The components and jars are introduced in Introduction & Architecture and the coordinates in Setup & Dependencies. The HPSF classes live in the core poi jar, POIXMLProperties in poi-ooxml, and HWPFDocument in poi-scratchpad.

Where the metadata lives

The same logical fields are stored in different places in the two families of formats:

Format Storage POI entry point Property groups

OOXML (.xlsx, .docx, .pptx)

Three small XML parts in the zip package: /docProps/core.xml, /docProps/app.xml and /docProps/custom.xml

POIXMLDocument.getProperties(), which returns POIXMLProperties (XSSFWorkbook, XWPFDocument and the SXSSFWorkbook through getXSSFWorkbook())

Core (Dublin Core style fields), extended (application statistics), custom (name and typed value)

OLE2 (.xls, .doc)

Two property-set streams in the POIFS file system: \005SummaryInformation and \005DocumentSummaryInformation

POIDocument.getSummaryInformation() and getDocumentSummaryInformation() (HSSFWorkbook, HWPFDocument), or the HPSF classes directly

Summary information, document summary information, custom properties (a dictionary inside the document summary stream)

The \005 prefix is the control character U+0005 that Microsoft puts in front of these two stream names. Both formats store the same kinds of values (strings, numbers, booleans, dates), but the field names differ (creator in OOXML, author in HPSF) and so do the types available for custom properties. A program that handles both formats needs one small adapter per format, as shown in the stamping section below.

A property is metadata, not content. It does not affect how the document renders, and neither POI nor Word or Excel validates it. Anyone can set any value, which matters when the file comes from a user.

OOXML properties with POIXMLProperties

POIXMLProperties (package org.apache.poi.ooxml) wraps the three property parts. It is created lazily by getProperties() on an XSSFWorkbook, XWPFDocument or XMLSlideShow and gives three views:

View Part Content

CoreProperties

/docProps/core.xml

The OPC core properties: title, subject, creator, keywords, description, category, lastModifiedBy, created, modified, plus contentStatus, contentType, identifier, revision, version, lastPrinted. Typed getters and setters (getTitle, setTitle, getCreator, setCreator). The date setters take Optional<Date> and the getters return Date.

ExtendedProperties

/docProps/app.xml

Application-level information: Application, AppVersion, Company, Manager, Template, TotalTime, and the statistics Word and Excel compute (Pages, Words, Characters, Lines, Paragraphs, Slides). Typed getters and setters exist for each of them (getApplication, setCompany, getPages), and getUnderlyingProperties() returns the XMLBeans object for anything else.

CustomProperties

/docProps/custom.xml

User-defined name and value pairs. addProperty has overloads for String, double, int and boolean; contains(name) and getProperty(name) read them back as XMLBeans CTProperty objects.

The core properties follow the Open Packaging Conventions (ECMA-376 part 2). Their element names come from Dublin Core (dc:creator, dc:title, dcterms:created), which is why the Javadoc, the XML and the Word user interface use slightly different labels for the same field. The extended and custom parts belong to the OOXML "document properties" part of ECMA-376 and are specific to Office applications.

Writing core, extended and custom properties

The first example creates books-inventory.xlsx with all three groups, writes it and reads the properties back. It was compiled and run against POI 5.5.1.

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Date;
import java.util.Optional;

import org.apache.poi.ooxml.POIXMLProperties;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.openxmlformats.schemas.officeDocument.x2006.extendedProperties.CTProperties;

public class XlsxProps {

    public static void main(String[] args) throws IOException, InvalidFormatException {
        Path file = Path.of("books-inventory.xlsx");
        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
            workbook.createSheet("Books").createRow(0).createCell(0).setCellValue("Title");

            POIXMLProperties props = workbook.getProperties();

            // core properties (docProps/core.xml)
            POIXMLProperties.CoreProperties core = props.getCoreProperties();
            core.setTitle("Bookshelf inventory");
            core.setSubjectProperty("Book catalogue");
            core.setCreator("Bookshelf export service");
            core.setKeywords("books, inventory");
            core.setDescription("Catalogue exported from the Bookshelf application");
            core.setCategory("Reports");
            core.setLastModifiedByUser("bookshelf-batch");
            core.setCreated(Optional.of(new Date()));
            core.setModified(Optional.of(new Date()));

            // extended properties (docProps/app.xml)
            CTProperties app = props.getExtendedProperties().getUnderlyingProperties();
            app.setApplication("Bookshelf Export 2.3");
            app.setCompany("Bookshelf Library");
            app.setManager("Catalogue team");

            // custom properties (docProps/custom.xml)
            POIXMLProperties.CustomProperties custom = props.getCustomProperties();
            custom.addProperty("ExportedBy", "bookshelf-batch");
            custom.addProperty("SchemaVersion", 3);
            custom.addProperty("RowCount", 1250L);
            custom.addProperty("ContainsPersonalData", false);

            try (FileOutputStream out = new FileOutputStream(file.toFile())) {
                workbook.write(out);
            }
        }

        try (XSSFWorkbook workbook = new XSSFWorkbook(file.toFile())) {
            POIXMLProperties props = workbook.getProperties();
            POIXMLProperties.CoreProperties core = props.getCoreProperties();
            System.out.println("title=" + core.getTitle());
            System.out.println("creator=" + core.getCreator());
            System.out.println("app=" + props.getExtendedProperties().getApplication());
            POIXMLProperties.CustomProperties custom = props.getCustomProperties();
            System.out.println("contains ExportedBy=" + custom.contains("ExportedBy"));
            System.out.println("SchemaVersion=" + custom.getProperty("SchemaVersion").getI4());
        }
    }
}

Output:

title=Bookshelf inventory
creator=Bookshelf export service
app=Bookshelf Export 2.3
contains ExportedBy=true
SchemaVersion=3

The resulting parts are small and readable. docProps/core.xml is a single line of XML in which each setter produced one element (dc:title, dc:creator, cp:keywords, cp:lastModifiedBy, dcterms:created and so on), docProps/app.xml holds Application, Company and Manager, and docProps/custom.xml holds one property element per custom field, with a pid (starting at 2), a fixed fmtid GUID, the name, and a typed child (vt:lpwstr, vt:i4, vt:r8 or vt:bool).

What a fresh document contains

A new XSSFWorkbook or XWPFDocument is not empty. Inspecting the package of a workbook created with no property calls shows two parts written by POI: docProps/core.xml with dc:creator set to Apache POI and dcterms:created set to the current UTC time, and docProps/app.xml with Application set to Apache POI. There is no title, no modified date and no custom.xml. Files exported by a service therefore advertise "Apache POI" as their author unless the code overrides it, which is rarely what an organisation wants to publish.

Pitfalls of the OOXML property API

  • Date setters take Optional<Date>. setCreated(Optional<Date>) and setModified(Optional<Date>) take an optional (a String overload also exists and throws InvalidFormatException for a bad value), while getCreated() and getModified() return a plain Date, null when absent. Pass Optional.empty() to remove the field; the value is written in UTC as a W3CDTF string with second resolution.

  • addProperty never replaces. Adding a name twice throws IllegalArgumentException ("A property with this name already exists in the custom properties"). Check contains(name) first, as the stamping helper below does. There is no removeProperty; to remove or change a custom property, edit the XMLBeans object returned by getUnderlyingProperties().

  • The numeric overloads are int and double. addProperty("RowCount", 1250L) compiles because the long is widened to double, and it is written as vt:r8 (1250.0), not as an integer. Use int for small counters. Excel and Word show the value as 1250; a program reading it back must use getR8(), not getI8().

  • No automatic update on save. POI never sets modified, lastModifiedBy or revision when it writes a document. A file opened, edited and saved by POI keeps the old values, unlike a save from Word or Excel. Set them explicitly when the information matters.

  • Property names are case sensitive and are shown as-is in the Word and Excel "Advanced properties" dialog.

  • commit() is called for you. write(OutputStream) serialises the three parts. Call POIXMLProperties.commit() only if you write the underlying OPCPackage yourself.

  • Thumbnails. POIXMLProperties.setThumbnail(String, InputStream) and getThumbnailImage() read and write the package thumbnail (the preview shown by some file managers). It is a separate part and not a property.

Reading properties without building the document

Opening an XSSFWorkbook or XWPFDocument only to read the author or title parses every sheet or the whole body. The OPC layer reads the core properties without that cost: OPCPackage.getPackageProperties() returns the PackageProperties interface (getTitleProperty, getCreatorProperty, getCreatedProperty and so on, each an Optional). This is the right tool for an upload endpoint or an indexer that only needs the metadata. Opening the package read-only also avoids any write-back (see the pitfall on File constructors below). The second example, in the stamping section, ends with this read.

OLE2 properties with HPSF

HPSF ("Horrible Property Set Format") is the POI component for Microsoft’s property-set serialisation, used by the binary Office formats and by every other OLE2 file. A property set is a typed list of numbered properties stored in one stream. Word, Excel and PowerPoint 97-2003 files carry two of them:

Stream POI class Content

\005SummaryInformation

SummaryInformation

Title, subject, author, keywords, comments, template, last author, revision number, total editing time, last printed, created, last saved, page, word and character counts, thumbnail, application name, security level.

\005DocumentSummaryInformation

DocumentSummaryInformation

Category, presentation target, manager, company, byte, line, paragraph, slide, note, hidden-slide and multimedia-clip counts, document parts, heading pairs, language and a link to the custom property dictionary.

Custom properties

CustomProperties (reached with DocumentSummaryInformation.getCustomProperties())

A name to value map stored in a second section of the document summary stream. Values can be strings, integers, longs, doubles, booleans and dates.

HSSFWorkbook and HWPFDocument extend POIDocument, so both expose getSummaryInformation(), getDocumentSummaryInformation() and, for new files, createInformationProperties(). Both getters return null when the stream is missing, which is common for files produced by other libraries, so code must check for it. For a new HSSFWorkbook the streams do not exist until createInformationProperties() is called; without that call the file is written without them, and without an author, a title or a creation date.

Writing HPSF properties on an .xls file

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Date;

import org.apache.poi.hpsf.CustomProperties;
import org.apache.poi.hpsf.DocumentSummaryInformation;
import org.apache.poi.hpsf.SummaryInformation;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;

public class XlsProps {

    public static void main(String[] args) throws IOException {
        Path file = Path.of("books-inventory.xls");
        try (HSSFWorkbook workbook = new HSSFWorkbook()) {
            workbook.createSheet("Books").createRow(0).createCell(0).setCellValue("Title");

            // creates the \005SummaryInformation and \005DocumentSummaryInformation streams
            workbook.createInformationProperties();

            SummaryInformation summary = workbook.getSummaryInformation();
            summary.setTitle("Bookshelf inventory");
            summary.setSubject("Book catalogue");
            summary.setAuthor("Bookshelf export service");
            summary.setKeywords("books, inventory");
            summary.setComments("Catalogue exported from the Bookshelf application");
            summary.setApplicationName("Bookshelf Export 2.3");
            summary.setCreateDateTime(new Date());
            summary.setLastSaveDateTime(new Date());

            DocumentSummaryInformation docSummary = workbook.getDocumentSummaryInformation();
            docSummary.setCompany("Bookshelf Library");
            docSummary.setManager("Catalogue team");
            docSummary.setCategory("Reports");

            CustomProperties custom = docSummary.getCustomProperties();
            if (custom == null) {
                custom = new CustomProperties();
            }
            custom.put("ExportedBy", "bookshelf-batch");
            custom.put("SchemaVersion", 3);
            custom.put("RowCount", 1250L);
            custom.put("ContainsPersonalData", Boolean.FALSE);
            docSummary.setCustomProperties(custom);

            try (FileOutputStream out = new FileOutputStream(file.toFile())) {
                workbook.write(out);
            }
        }

        try (POIFSFileSystem fs = new POIFSFileSystem(file.toFile());
             HSSFWorkbook workbook = new HSSFWorkbook(fs)) {
            SummaryInformation summary = workbook.getSummaryInformation();
            DocumentSummaryInformation docSummary = workbook.getDocumentSummaryInformation();
            if (summary == null || docSummary == null) {
                System.out.println("no property sets in this file");
                return;
            }
            System.out.println("title=" + summary.getTitle());
            System.out.println("author=" + summary.getAuthor());
            System.out.println("company=" + docSummary.getCompany());
            CustomProperties custom = docSummary.getCustomProperties();
            System.out.println("SchemaVersion=" + custom.get("SchemaVersion"));
            System.out.println("RowCount=" + custom.get("RowCount") + " " + custom.get("RowCount").getClass().getSimpleName());
        }
    }
}

Output (compiled and run against POI 5.5.1):

title=Bookshelf inventory
author=Bookshelf export service
company=Bookshelf Library
SchemaVersion=3
RowCount=1250 Long

The same calls work for a .doc: open it with new HWPFDocument(fs.getRoot()) (the poi-scratchpad jar) and call getSummaryInformation(). HWPFDocument reads the property sets of an existing file; it cannot create a new .doc from scratch, so the "create" half of this section applies to .xls only. See Legacy .xls with HSSF and Legacy .doc with HWPF for the rest of the two APIs.

HPSF types and custom properties

CustomProperties implements Map<String, Object>. The value type chosen on put decides the stored variant type, and the type is preserved on a round trip: a String stays a String, an Integer an Integer, a Long a Long, a Double a Double, a Boolean a Boolean and a java.util.Date a Date. This is a difference from OOXML, where long becomes double. (When a program imports both org.apache.poi.hpsf.* and java.util.*, Date is ambiguous because HPSF has its own Date type; qualify one of them.) Property names are unique, and put with an existing name replaces the value. CustomProperties.setCodepage(int) controls the encoding of string values; the default is Unicode.

Java value OOXML custom property HPSF custom property

String

vt:lpwstr

string (Unicode)

int / Integer

vt:i4

32-bit integer

long / Long

vt:r8 (widened to double)

64-bit integer

double / Double

vt:r8

double

boolean / Boolean

vt:bool

boolean

java.util.Date

no overload in CustomProperties (use the XMLBeans CTProperty)

date (stored as a FILETIME)

Reading a property set without HSSF or HWPF

An upload pipeline sometimes needs the metadata of any OLE2 file without parsing the workbook. The POIFS file system exposes the property-set streams directly, and PropertySetFactory.create(InputStream) returns the matching SummaryInformation or DocumentSummaryInformation:

import java.io.File;
import java.io.IOException;

import org.apache.poi.hpsf.PropertySet;
import org.apache.poi.hpsf.PropertySetFactory;
import org.apache.poi.hpsf.SummaryInformation;
import org.apache.poi.poifs.filesystem.DirectoryNode;
import org.apache.poi.poifs.filesystem.DocumentInputStream;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;

public class OleSummary {

    public static void main(String[] args) throws Exception {
        try (POIFSFileSystem fs = new POIFSFileSystem(new File("books-inventory.xls"))) {
            DirectoryNode root = fs.getRoot();
            if (root.hasEntry(SummaryInformation.DEFAULT_STREAM_NAME)) {
                try (DocumentInputStream in = root.createDocumentInputStream(SummaryInformation.DEFAULT_STREAM_NAME)) {
                    PropertySet ps = PropertySetFactory.create(in);
                    if (ps instanceof SummaryInformation si) {
                        System.out.println("author=" + si.getAuthor() + " app=" + si.getApplicationName());
                    }
                }
            }
        }
    }
}

Output: author=Bookshelf export service app=Bookshelf Export 2.3.

Stamping export metadata

For the exports of the Bookshelf application, every generated file should say who produced it, with which software, when, and under which export identifier, so that a support engineer can correlate a file found in a mailbox with a log entry. Four fields are enough:

Information OOXML field OLE2 (HPSF) field

Author / creating principal (a service name, not a person)

core.creator and core.lastModifiedBy

SummaryInformation.author and lastAuthor

Generator and version

app.Application (and AppVersion)

SummaryInformation.applicationName

Export timestamp

core.created and core.modified

createDateTime and lastSaveDateTime

Export identifier, schema version

custom properties ExportId, SchemaVersion

custom properties

Title

core.title

SummaryInformation.title

The helper below stamps any OOXML document. POIXMLDocument is the common base class of XSSFWorkbook, XWPFDocument and XSLFSlideShow, so one method serves the whole section. It takes a java.time.Clock instead of calling new Date(), which makes the output testable and, as shown in the next section, reproducible.

import java.time.Clock;
import java.util.Date;
import java.util.Optional;

import org.apache.poi.ooxml.POIXMLDocument;
import org.apache.poi.ooxml.POIXMLProperties;

public final class ExportMetadata {

    private final Clock clock;
    private final String generator;
    private final String exportedBy;

    public ExportMetadata(Clock clock, String generator, String exportedBy) {
        this.clock = clock;
        this.generator = generator;
        this.exportedBy = exportedBy;
    }

    public void stamp(POIXMLDocument document, String title, String exportId) {
        POIXMLProperties props = document.getProperties();
        Date now = Date.from(clock.instant());

        POIXMLProperties.CoreProperties core = props.getCoreProperties();
        core.setTitle(title);
        core.setCreator(exportedBy);
        core.setLastModifiedByUser(exportedBy);
        core.setCreated(Optional.of(now));
        core.setModified(Optional.of(now));

        props.getExtendedProperties().getUnderlyingProperties().setApplication(generator);

        POIXMLProperties.CustomProperties custom = props.getCustomProperties();
        if (!custom.contains("ExportId")) {
            custom.addProperty("ExportId", exportId);
        }
    }
}

It is used for the three Bookshelf exports. The streaming workbook needs one extra step: SXSSFWorkbook is not a POIXMLDocument, but getXSSFWorkbook() returns the underlying workbook whose properties are written when the SXSSF workbook is written (see Streaming Writes with SXSSF). The last lines read the metadata back through the cheap OPCPackage route:

import java.io.FileOutputStream;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.openxml4j.opc.PackageProperties;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xwpf.usermodel.XWPFDocument;

public class Stamp {

    public static void main(String[] args) throws Exception {
        Clock clock = Clock.fixed(Instant.parse("2026-10-09T08:00:00Z"), ZoneOffset.UTC);
        ExportMetadata metadata = new ExportMetadata(clock, "Bookshelf Export 2.3", "bookshelf-batch");

        try (XSSFWorkbook workbook = new XSSFWorkbook();
             FileOutputStream out = new FileOutputStream("books-inventory.xlsx")) {
            workbook.createSheet("Books");
            metadata.stamp(workbook, "Bookshelf inventory", "exp-0001");
            workbook.write(out);
        }

        SXSSFWorkbook streaming = new SXSSFWorkbook(100);
        try (streaming; FileOutputStream out = new FileOutputStream("loans-report.xlsx")) {
            streaming.createSheet("Loans");
            metadata.stamp(streaming.getXSSFWorkbook(), "Bookshelf loans", "exp-0002");
            streaming.write(out);
        } finally {
            streaming.dispose();
        }

        try (XWPFDocument document = new XWPFDocument();
             FileOutputStream out = new FileOutputStream("loan-letter.docx")) {
            document.createParagraph().createRun().setText("Dear reader");
            metadata.stamp(document, "Loan letter", "exp-0003");
            document.write(out);
        }

        // cheap read without building the whole document model
        try (OPCPackage pkg = OPCPackage.open("loan-letter.docx", PackageAccess.READ)) {
            PackageProperties core = pkg.getPackageProperties();
            System.out.println(core.getTitleProperty().orElse("-") + " | " + core.getCreatorProperty().orElse("-"));
        }
    }
}

Output (compiled and run against POI 5.5.1): Loan letter | bookshelf-batch. In the generated loans-report.xlsx, app.xml contains Application set to Bookshelf Export 2.3 and custom.xml contains the ExportId property exp-0002.

Guidance for the values themselves:

  • Use a service name as the author, not a user’s name, unless the user is the legitimate author of the document. An export is created by the system on behalf of a user, and a personal name in a file that the user emails elsewhere is a privacy leak. Put the requesting user’s id in a custom property only if it is needed for audit.

  • Do not put secrets, internal host names, SQL or file-system paths in properties. They are plain text in the zip and survive copy and paste of the file. This is the usual way internal details leak from generated reports.

  • Keep the generator string stable and versioned (Bookshelf Export 2.3). It tells later readers which code produced a malformed file.

  • Set the same stamps in every format. A user who downloads the same report as .xlsx and as .xls expects the same author; use one adapter per format and a shared value object for the fields.

Reproducible output and SOURCE_DATE_EPOCH

A reproducible export produces byte-identical files for identical input, which allows content-based caching of generated reports, deduplication in object storage, and tests that compare checksums. By default a POI export is not reproducible, for two reasons: the core properties receive the current time, and the zip entries of the package are stamped with the modification time of the moment they were written. Apache POI 5.4.0 (2025-01-08) added support for the SOURCE_DATE_EPOCH convention of the Reproducible Builds project: the changelog entry reads "Add support for SOURCE_DATE_EPOCH to allow to create reproducible binary files without creation/modification-timestamp being set".

What the support does, as observed with POI 5.5.1:

  • POI checks only whether the environment variable SOURCE_DATE_EPOCH is defined. The value is not parsed; any value (a Unix timestamp such as the one the convention prescribes) switches the behaviour on. The check runs once, when the class is first loaded (Reproducibility.isSourceDateEpoch()), so the variable has to be set before the JVM starts. In code, Reproducibility.runWithSourceDateEpoch(IORunnable) also switches it on while the given block runs; its Javadoc says it is mostly used in tests, and it changes a static flag for the whole JVM.

  • With it set, the zip entries in the OOXML package are written without a modification time (their time field is zero), so they no longer depend on when the export ran.

  • POI does not substitute the variable’s value into the document. The dcterms:created element of core.xml still receives the current time, so the program must set created (and modified) to a fixed value itself, for example the timestamp of the source data or of the request, or remove them with Optional.empty().

The third example demonstrates it. It writes the same workbook twice, two seconds apart, with fixed core properties, and prints a short SHA-256 prefix of each result, then does the same for an .xls:

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Date;
import java.util.HexFormat;
import java.util.Optional;

import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

public class Repro {

    static byte[] xlsx(Instant fixed) throws IOException {
        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            wb.createSheet("Books").createRow(0).createCell(0).setCellValue("Title");
            var core = wb.getProperties().getCoreProperties();
            core.setCreated(Optional.of(Date.from(fixed)));
            core.setModified(Optional.of(Date.from(fixed)));
            wb.write(out);
            return out.toByteArray();
        }
    }

    static byte[] xls() throws IOException {
        try (HSSFWorkbook wb = new HSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            wb.createSheet("Books").createRow(0).createCell(0).setCellValue("Title");
            wb.write(out);
            return out.toByteArray();
        }
    }

    static String sha(byte[] bytes) throws Exception {
        return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)).substring(0, 12);
    }

    public static void main(String[] args) throws Exception {
        Instant fixed = Instant.parse("2026-01-01T00:00:00Z");
        String first = sha(xlsx(fixed));
        Thread.sleep(2100);
        String second = sha(xlsx(fixed));
        System.out.println("xlsx: " + first + " " + second + " equal=" + first.equals(second));
        String x1 = sha(xls());
        Thread.sleep(2100);
        String x2 = sha(xls());
        System.out.println("xls:  " + x1 + " " + x2 + " equal=" + x1.equals(x2));
    }
}
java -cp "$CP" Repro
SOURCE_DATE_EPOCH=1767225600 java -cp "$CP" Repro

Measured output (the hash prefixes differ per machine, the equal values are the point):

Run .xlsx with fixed core dates .xls with no properties

Without SOURCE_DATE_EPOCH

equal=false (zip entry times differ)

equal=true

With SOURCE_DATE_EPOCH set

equal=true

equal=true

The .xls was reproducible in this test in both runs because the HSSF file contained no timestamps: no SummaryInformation stream was created. A file that sets createDateTime or lastSaveDateTime is only reproducible if the program passes fixed values. In short, the environment variable removes the hidden wall-clock input from the zip layer; removing the visible one (the core and summary dates) is the application’s job. To make the rest of the export deterministic, also avoid anything else that depends on time or iteration order (HashMap ordering of rows, random identifiers, LocalDate.now() in cell values), and do not rely on identical bytes across POI versions: a POI upgrade can change the XML serialisation.

Source: https://poi.apache.org/changes.html#5.4.0 (the SOURCE_DATE_EPOCH convention itself is described at https://reproducible-builds.org/docs/source-date-epoch/)

Metadata of uploaded files

The import path (see the section landing page) receives files from users. Their metadata is data from an untrusted source:

  • Treat every string as arbitrary text. An author field can hold markup, very long text, control characters or a formula prefix (equals, plus, minus or at sign). Escape it before it goes into HTML, logs or a CSV or spreadsheet export, and do not use it for authorisation ("the author is an admin").

  • Do not trust the dates. They are set by whichever program wrote the file and can be anything, including the future.

  • Do not trust Application. It is useful to diagnose problems and to decide whether to apply workarounds for a producer, but anyone can claim to be Excel.

  • Read only what you need and with the cheapest tool: OPCPackage.getPackageProperties() for OOXML, the property-set streams for OLE2. The size limits and parsing hardening of the container apply to the metadata parts too (the security page of this section covers the limits).

  • Metadata can leak the uploader’s identity. If the application stores or re-serves the file to other users, remove the creator, last editor, company and custom properties first.

The following program loads an uploaded workbook and writes a copy without the identifying fields. It also demonstrates a trap of the File constructors:

import java.io.File;
import java.io.FileOutputStream;
import java.util.Optional;

import org.apache.poi.ooxml.POIXMLProperties;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.openxmlformats.schemas.officeDocument.x2006.extendedProperties.CTProperties;

public class Scrub {

    public static void main(String[] args) throws Exception {
        try (OPCPackage pkg = OPCPackage.open(new File("books-inventory.xlsx"), PackageAccess.READ_WRITE);
             XSSFWorkbook workbook = new XSSFWorkbook(pkg)) {
            POIXMLProperties props = workbook.getProperties();
            POIXMLProperties.CoreProperties core = props.getCoreProperties();
            core.setCreator(null);
            core.setLastModifiedByUser(null);
            core.setModified(Optional.empty());
            CTProperties app = props.getExtendedProperties().getUnderlyingProperties();
            if (app.isSetCompany()) {
                app.unsetCompany();
            }
            if (app.isSetManager()) {
                app.unsetManager();
            }
            try (FileOutputStream out = new FileOutputStream("scrubbed.xlsx")) {
                workbook.write(out);
            }
            pkg.revert();   // discard changes: closing a package opened from a File would save them over the original
        }
    }
}

Source: POIXMLProperties and OPCPackage (open, revert) Javadoc.

The trap: a document opened with new XSSFWorkbook(File) or new XWPFDocument(OPCPackage) over a read-write package saves its in-memory state back into that file when it is closed, even if the program only called write on another stream. In a test with POI 5.5.1, changing the title on a workbook opened from File and writing it to a second file also changed the title of the original file on close(). Opening with an InputStream (which copies the file into memory) or calling OPCPackage.revert() before closing, as above, leaves the original untouched. A package opened with PackageAccess.READ cannot be written at all (write throws InvalidOperationException), which makes it the right choice for read-only inspection. This applies to the upload directory and to any template shared between requests, which a modification of properties must never overwrite.

The unset methods of the XMLBeans type throw IndexOutOfBoundsException when the element is absent, hence the isSet guards. The custom properties in the scrubbed copy are still present; remove them through the XMLBeans view (getUnderlyingProperties()), or rebuild the document from the data. Other places that identify a user (comments and tracked changes in .docx, the author of cell comments in .xlsx, document-level printer settings) are content, not properties, and are not touched.

What POI does not provide

  • No synchronisation between formats: setting the author on an XSSFWorkbook does nothing to an .xls produced from the same data, and no API converts a POIXMLProperties into HPSF property sets.

  • No automatic statistics: Pages, Words, Characters and similar values in app.xml, or PageCount and WordCount in HPSF, are not computed by POI. Word and Excel recompute them on save; POI writes whatever it finds.

  • No change tracking or document history; the revision and version fields are plain values.

  • No validation of values, no limits on length, no check that a custom property name is legal in Office.

  • Custom property removal and a typed Date overload for OOXML custom properties require the XMLBeans view (poi-ooxml-lite may not contain every class; use poi-ooxml-full if a CT* class is missing, as described in Setup & Dependencies).

Version notes

Version Change

4.x and 5.x

POIXMLProperties and HPSF as described on this page; the 5.5.1 API still offers both the Optional<Date> and the String date setters. XSSFWorkbook(File) and friends write back to the file on close().

5.4.0 (2025-01-08)

SOURCE_DATE_EPOCH support for reproducible files, in the same release that made some invalid zip content fail parsing.

5.5.x

The latest release on 2026-10-09 is 5.5.1. The examples on this page were compiled and run against it.

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

The announced changes (JDK 17 baseline, removal of deprecated APIs, hardening) may remove deprecated overloads; the metadata API surface above is expected to keep working, but check the 6.0 migration notes when it ships.

Summary

  • OOXML metadata is three parts (core.xml, app.xml, custom.xml) reached through getProperties(); OLE2 metadata is the \005SummaryInformation and \005DocumentSummaryInformation streams, reached through HPSF.

  • A new POI document carries Apache POI as creator and application and the current creation time. Overwrite them in every export, with a service name, a versioned generator string, a fixed or request-derived timestamp and an export id.

  • addProperty never replaces and long becomes double in OOXML custom properties; HPSF CustomProperties is a Map that preserves types.

  • POI never updates modified, lastModifiedBy or statistics on save.

  • SOURCE_DATE_EPOCH (POI 5.4.0) only removes zip entry timestamps; set the core dates yourself for byte-identical output, and define the variable before the JVM starts.

  • Metadata of uploads is untrusted. Read it with OPCPackage or the raw property-set streams, scrub identifying fields before redistributing, and never let a File-opened package be closed read-write if the original must stay unchanged.

References