Hidden Sheets and Metadata Leaking Out of Shared Workbooks
Find hidden sheets, document properties, comments, defined names and pivot caches in a workbook before you share it, with a script that audits the file.

A supplier sends your buyer a quotation as a workbook. The visible sheet shows the price for your product. A hidden sheet called Costs shows the margin, and the buyer finds it by right-clicking a tab. This keeps happening, to companies with security teams and to people with the best intentions, because Excel makes hiding things easy and nothing in the save dialog says what the file still holds. The wider risks are in Excel security problems developers overlook. This article is the audit: where the leaks hide, how to see them and what to do before the file goes out.
Where data hides in an xlsx
A workbook is a zip of XML parts, laid out by ECMA-376 (the xlsx file is a zip of XML). Each place where information can sit that the visible sheet does not show maps to a part you can read.
| What | Where it lives | Visible in the UI? |
|---|---|---|
| Hidden sheet | xl/workbook.xml, sheet state="hidden" | Unhide from the tab menu |
| Very hidden sheet | same, state="veryHidden" | Only through the VBA editor |
| Hidden rows and columns | the sheet XML, hidden="1" | Unhide by selecting the neighbors |
| Author, company, dates | docProps/core.xml, docProps/app.xml | File, Info |
| Custom properties | docProps/custom.xml | File, Info, Properties, Advanced |
| Comments and notes | xl/comments*.xml, threaded comments | Review tab |
| Defined names | xl/workbook.xml, definedNames | Name Manager |
| External links | xl/externalLinks/ | Data, Edit Links |
| Pivot cache | xl/pivotCache/pivotCacheRecords*.xml | Not directly |
| Printer settings, images | xl/printerSettings/, xl/media/ | Partly |
The point of the table is that every row is plain text or an ordinary file inside the zip. A recipient does not need Excel to find them. They need unzip.
Hidden is not removed
Hiding a sheet sets a flag. The sheet's cells, formulas and formatting are all in the file. Very hidden sheets, which cannot be unhidden from the normal menu, are the same with a different flag. Hidden rows and columns are the same idea at a smaller scale: a column of cost figures hidden between two visible columns is the classic leak.
The same holds for filtered rows, which are hidden by a filter and still there, and for data outside the print area or far from the used range, such as a note in cell Z900. The visible sheet is a view, and the file holds everything.
The pivot cache
Pivot tables deserve their own warning. When you build a pivot table, Excel stores a copy of the source data inside the file as the pivot cache, so the table can be refreshed and rearranged without the original. If you later delete the source sheet, or delete rows from it, the cache may still hold the records. A workbook that looks like a one-page summary can carry the entire customer list behind it.
You can see this by opening the zip and looking at the size of xl/pivotCache/pivotCacheRecords1.xml. If a workbook with a small summary has a large cache part, the data is in there. The fix is either to turn off "Save source data with file" in the pivot options and refresh, or, better, to send a workbook that contains values, not a live pivot (pivot tables and their SQL equivalents explains what the cache is for).
Audit the file with a script
A short script lists what is there. It reads parts directly, so it does not depend on Excel's view of the file.
import re
import zipfile
def audit(path):
out = {}
with zipfile.ZipFile(path) as z:
names = z.namelist()
wb = z.read("xl/workbook.xml").decode("utf-8", "replace")
out["sheets"] = re.findall(r'<sheet [^>]*name="([^"]+)"[^>]*?(?:state="(\w+)")?[^>]*/?>', wb)
out["defined_names"] = re.findall(r'<definedName [^>]*name="([^"]+)"', wb)
core = z.read("docProps/core.xml").decode("utf-8", "replace") if "docProps/core.xml" in names else ""
out["creator"] = re.findall(r"<dc:creator>(.*?)</dc:creator>", core)
out["last_modified_by"] = re.findall(r"<cp:lastModifiedBy>(.*?)</cp:lastModifiedBy>", core)
app = z.read("docProps/app.xml").decode("utf-8", "replace") if "docProps/app.xml" in names else ""
out["company"] = re.findall(r"<Company>(.*?)</Company>", app)
out["comment_parts"] = [n for n in names if "comments" in n or "threadedComments" in n]
out["external_links"] = [n for n in names if n.startswith("xl/externalLinks/")]
out["pivot_caches"] = [(n, z.getinfo(n).file_size) for n in names if "pivotCacheRecords" in n]
out["custom_props"] = "docProps/custom.xml" in names
out["media"] = [n for n in names if n.startswith("xl/media/")]
return outA regular expression over XML is a shortcut that works for a quick audit and not for a library; the sheet pattern above is brittle about attribute order. If you need exact results, parse with defusedxml or lxml with entity resolution off. For hidden rows and columns, which live in each sheet part, load with openpyxl and check ws.row_dimensions[i].hidden and ws.column_dimensions[c].hidden.
from openpyxl import load_workbook
wb = load_workbook("quote.xlsx")
for ws in wb.worksheets:
print(ws.title, ws.sheet_state)
hidden_rows = [r for r, d in ws.row_dimensions.items() if d.hidden]
hidden_cols = [c for c, d in ws.column_dimensions.items() if d.hidden]
print(" hidden rows:", hidden_rows, "hidden columns:", hidden_cols)For a quotation with a visible Quote sheet and a hidden Costs sheet, the output reads:
Quote visible
hidden rows: [] hidden columns: []
Costs hidden
hidden rows: [] hidden columns: []Fail the release if any sheet state other than visible appears, or if any property names a person, a company or a path you did not intend.
Properties that name people and places
docProps/core.xml records dc:creator, cp:lastModifiedBy, creation and modification times and sometimes keywords and a description. app.xml records the application, its version and the company name from the Office installation. Together they say who made the file, on what, and when. An agency that wants anonymity for a source, a lawyer sending a draft and a consultant reusing a template from another client all have reasons not to ship them.
Worse, a template copied from one client for the next carries the first client's name in the properties and often in sheet names, defined names and footers. External links keep the original file paths, for instance \\fileserver\clients\Acme\2025\costs.xlsx, which names the client and the internal server in one line.
Fix it by building fresh
You can clean a working file, using Excel's Document Inspector or a script, and you will probably miss something. The more reliable approach is to never ship a working file. Generate the outbound workbook from data into a new workbook:
from openpyxl import Workbook
out = Workbook()
ws = out.active
ws.title = "Quote"
for row in quote_rows:
ws.append(row)
out.properties.creator = "Sales team"
out.properties.lastModifiedBy = ""
out.properties.title = "Quotation"
out.save("quote_clean.xlsx")A new workbook has one sheet, no hidden parts, no cache and no links, and you set its properties explicitly. Copy values, not formulas that point at other sheets. If formulas must stay, make sure each refers only to cells in the outbound file. After saving, run the audit on the result and fail the build if it finds anything unexpected. Removing personal data from an Excel file before sharing goes through the cleaning route for the case where you cannot rebuild.
What the audit will not find
An audit of parts finds structure. It does not find what someone typed into a visible cell, such as an email address in a notes column or a name in a filename. It does not find white text on a white background, which sits in an ordinary cell and shows up only in the cell values. It does not find data in embedded images, such as a screenshot of a bank balance. And it does not judge meaning: a defined name called Margin is suspicious to a human and ordinary to a script. Keep a person in the loop for outbound files that matter, and give them the audit output to review in place of the workbook.
The openpyxl documentation on worksheet properties lists what the library exposes. Related reading includes protecting a generated workbook from accidental edits, which is about stopping changes, not leaks, and the Excel merged cells and hidden rows problem for what hidden rows do to an import.
When auditing a file is the wrong approach
Auditing works when the files are few and the stakes are modest. It is the wrong approach for a pipeline that sends hundreds of workbooks: there you generate from data, as above, and test the generator. It is the wrong approach when the content itself is confidential, since a file that should not leave should not leave in any form; send a summary instead. And it is the wrong approach if the recipient only needs numbers, because a CSV or a PDF has no hidden sheets to find. Pick the simplest output that does the job, and keep the workbook for people who need to work in it.


