You are told the application is safe from injection because all user input is escaped as it comes in. What is wrong with that?
Escaping is meaningless without a destination, and at the point input arrives you do not know which parser will consume it. Injection is a property of the sink, so the defence belongs at each sink — bound parameters, an argument vector rather than a shell string, encoding chosen by output context — not at the edge.
What the interviewer is scoring
- Whether injection is framed as a property of the consuming parser rather than of the input string
- Does the candidate separate validation from escaping from parameterisation instead of using the words interchangeably
- That they can name a position a bound parameter cannot occupy and say what they would do there
- Whether they explain how escaping at the edge corrupts stored data and enables second-order injection
- Can they name a sink that does not look like an interpreter
Answer
Escaping without a destination is not a defence
"Escaped" is an incomplete sentence. Escaped for what? A backslash before a quote is correct for one SQL dialect's string literal and wrong inside an HTML attribute. Replacing angle brackets with entities protects HTML element text and does nothing inside a javascript: URL. At the moment a request arrives you do not know which parser the value is destined for, and often it is destined for several, so any single transformation at the edge is correct for at most one.
That is the first half. The second half is the concept that reframes the whole topic: injection is a property of the sink, not of the input. A string is only dangerous when a parser reads it and decides that part of it is instructions rather than data. The same bytes are inert in one destination and executable in another. So the question to ask of any value is never "has this been sanitised" but "which parsers will see this, and how does each one tell code from data".
One value, several correct answers
Take the surname O'Brien and a comment containing <b>. The correct rendering differs by destination, and no edge filter can produce all of them:
Stored in the database O'Brien (unmodified; the driver handles quoting)
Rendered in HTML element text O'Brien or left alone, since a quote is inert there
Rendered in an HTML attribute O'Brien (a bare quote would close the attribute)
Inside a JSON string O'Brien with \" and \\ escaped, not entities
Passed to an external command no escaping (pass as an argv element, never a shell string)
Notice the direction of travel. Escaping is an output concern, applied by whichever component is about to hand the value to a parser, using that parser's rules. The edge's job is narrower: validate that the value is the kind of thing you expected — a length, a numeric range, a date, a member of an enumerated set — and reject it if not. That reduces the attack surface but substitutes for nothing, because plenty of legitimate values contain characters that are dangerous somewhere.
Separate code from data, rather than filtering data
The strongest defences at each sink work by never mixing the two in the first place, so there is nothing to escape.
For SQL, a bound parameter is sent to the server separately from a statement that has already been parsed. The value cannot alter the statement's structure no matter what it contains, because the parse happened before the value existed in that context. That is categorically different from escaping a value into a concatenated string, where you are relying on your rules matching the server's parsing rules exactly, across every character set and dialect quirk.
For external commands, pass an argument vector to the process rather than a command line for a shell to interpret; if no shell parses the string, there is no metacharacter to abuse. For HTML, use a template engine that escapes by default and understands context, and treat every bypass as a reviewable decision. For an interpreter you do not control — an LDAP filter, an XPath expression, a header — use the library's construction API rather than string building.
The positions a parameter cannot occupy
A bound parameter can stand where the grammar expects a value, and nowhere else. You cannot bind a table name, a column name, or the direction of an ORDER BY, because those are structural. This is where injection survives in codebases that otherwise use parameters correctly, since a sort column from a query string has to reach the SQL somehow.
The only sound answer there is an allow-list: map the client's requested sort field through a fixed dictionary of permitted values to the identifier you control, and reject anything absent from it. Do not attempt to sanitise an identifier — you are back to matching the parser's rules by hand, on the one input position where a mistake gives away the whole statement.
What escaping at the edge does to your data
Beyond not working, edge escaping causes harm, and saying so distinguishes a considered answer. Values arrive in the database pre-mangled, so the surname is stored as O'Brien and every consumer — an export, a mail merge, a partner API, a search index — either shows the entity to a human or has to guess whether to decode it. Some paths decode twice and some not at all, so the data develops inconsistent encodings nobody can clean up reliably.
It also creates the conditions for second-order injection. A stored value is later read back and concatenated into a query or a template by code that assumes anything already in the database is trusted. The dangerous flow no longer starts at a request handler, so it is invisible to a review of inbound parameters.
The sink nobody had classified
In December 2021, Log4Shell made any application that logged an attacker-controlled string a candidate for remote code execution, because Log4j performed JNDI lookups on the message it was formatting. The principle it demonstrates is that untrusted data has no inherently safe destination: a log line had not been treated as somewhere input could execute, so nobody had drawn a trust boundary there. When you reason about input validation, reason about every sink the data reaches rather than only the ones that obviously interpret it.
That is also the instruction for reviewing a real system. Enumerate the sinks rather than the sources: every place a string is handed to something that parses it, including the ones that do not look like interpreters — loggers, email templating, filenames and paths, spreadsheet cells a desktop application will evaluate as formulae, metric labels, deserialisers. For each, name the mechanism keeping data out of the instruction position.
Injection lives at the sink, so ask which parser will read the value and how that parser separates code from data. Bind parameters, pass argument vectors instead of shell strings, encode at output by context, allow-list the structural positions a parameter cannot occupy — and store user data exactly as it was given to you.
Likely follow-ups
- How would you enumerate every sink in an existing codebase, and which ones would you expect to be missed?
- Why does a bound parameter remain safe when the value contains a quote and a semicolon?
- Where do you enforce an allow-list for a sort column, and what does getting it wrong look like?
- What must normalisation do before validation runs, and what breaks if the order is reversed?
Related questions
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumSame kind of round: concept5 min
- A promise rejects and nothing is awaiting it. What does Node do?hardSame kind of round: concept4 min
- A more complex model beats your simple one by a small margin offline. How do you decide whether it is worth shipping?hardSame kind of round: concept4 min
- Give me a subclass that the compiler accepts but that still breaks the Liskov substitution principle. What rule does it break?mediumSame kind of round: concept4 min
- When would you reach for an abstract base class rather than a Protocol, and what does isinstance check in each case?hardSame kind of round: concept5 min
- You kick off background work from a controller action and it throws once the response has gone out. What is happening?mediumSame kind of round: concept5 min
- Two transactions each check a rule, then both commit changes that break it, and neither overwrote the other's row. What happened?hardSame kind of round: concept5 min
- A customer bought cover on your site last night and this morning the card payment failed. Are they on risk, and what does your system do next?hardSame kind of round: concept5 min