Queries Built by String Concatenation Fail Twice
Queries Built by String Concatenation Fail Twice
The tempting way to make GraphQL dynamic is to build the document with string interpolation:
const query = `query { user(id: "${userId}") { email } }`;
It fails in two independent ways.
Injection. userId is caller-controlled input, and GraphQL parses whatever string arrives. Send:
1") { role admins: admins { email } } #
and the caller has written server-side schema traversal you never intended — the same class of bug SQL hands you when a WHERE clause is concatenated. Field-level authorization may catch it, but you never asked for it.
Cache fragmentation. Every distinct value becomes a distinct document. Ten thousand users produce ten thousand unique query strings in the CDN, the automatic-persisted-query store, and any document cache — all identical except for one literal, so nothing can be shared.
The fix separates shape from data:
const query = 'query User($id: ID!) { user(id: $id) { email } }';
const variables = { id: userId };
The document is now a constant that can be hashed, persisted, and cached once; the value travels as data where it is validated and coerced instead of parsed as syntax. See persisted queries for how that constant document becomes an id on the wire.
A third failure is quieter but real: debugging. With interpolated text, the query a user actually sent exists only in that user's request log, so reproducing a bug means reconstructing the string by hand. With variables, every failing request records the same document plus its inputs — one artifact to reproduce, one to cache, one to review in a pull request.
The pattern is not GraphQL-specific. Anything derived from user input belongs on the value side of a protocol — SQL parameters, template data, header values — never on the structure side. When a framework lets you choose, the choice determines whether input can ever become syntax.
Definition, Usage, Delivery
Definition, Usage, Delivery
A variable appears in three places, each with its own rule:
query GetUser($id: ID!, $withEmail: Boolean! = true, $first: Int = 10) {
user(id: $id) {
name
email @include(if: $withEmail)
posts(first: $first) { title }
}
}
| Piece | Location | Rule |
|---|---|---|
$id: ID! |
operation variable definitions | declared type must fit the position where it is used |
id: $id |
a field argument in the selection | value substituted at execution |
{ "id": "7" } |
the request body's variables key |
JSON object keyed by variable name |
Over HTTP the two halves travel separately:
{
"query": "query GetUser($id: ID!, $withEmail: Boolean! = true, $first: Int = 10) { ... }",
"variables": { "id": "7", "withEmail": false }
}
Execution proceeds in four phases, and each failure mode is tied to one of them:
- Syntax — the document parses, or the request dies immediately with a syntax error.
- Validation — declarations are checked against usage.
$first: Stringused for anInt!argument returnsVariable "$first" used in position expecting type "Int!". Nothing has been read from the database yet. - Coercion — the supplied JSON values are converted to the declared types; a declared default fills an omitted variable. A non-null variable with no value short-circuits here with a request error naming it, rather than executing a half-specified query.
- Execution —
user(id: $id)and@include(if: $withEmail)read the coerced values.
Knowing the phase tells you who must fix the bug: a missing variable is the client's contract violation, a resolver's null is the server's data problem.
Non-Null Contracts, Defaults, and Cache Keys
Non-Null Contracts, Defaults, and Cache Keys
Variable declarations are a published contract with every caller of the operation:
query Feed($first: Int! = 25, $after: String, $orderBy: Order = NEWEST) {
posts(first: $first, after: $after, orderBy: $orderBy) {
id
title
}
}
$first: Int! = 25— callers may omit it and get 25, but the resolver never has to handle null. Remove the default and every client must send a value.$after: String(nullable) — null means "start at the beginning," which is a legitimate value in cursor pagination, so it stays nullable.- Type compatibility is enforced at validation, not execution. A nullable variable cannot satisfy a non-null position; a non-null variable is accepted where a nullable one is expected.
The caching consequence. The document text is now constant, so a cache keyed only on the query hash returns the first caller's data for every later request. The key has to include the variables, or personalized queries must stay out of shared caches entirely:
// wrong: key = sha256(document)
// right: key = sha256(document) + stableStringify(variables)
One coercion rule worth knowing. A variable supplied as JSON null for a nullable variable passes null through to the argument — it does not fall back to the argument's default value, because the argument was supplied. Defaults apply only when the argument is omitted altogether; a non-null argument receiving that null fails with an error instead.
Interview angle: walk the four phases (syntax → validation → coercion → execution), give the error each produces, then explain that constant document text is simultaneously a security property (no input parsed as syntax) and a caching property (one entry per operation).
One caching detail to add: an operation name travels with the document, so a shared cache key should cover document text, operation name, and variables together. In a client that bundles several operations into one file, two different operations can share a single HTTP request body — operationName is what selects which one executes.
Practice Problems
The client below builds query text by concatenation. Rewrite it as a single constant operation with typed variable definitions, show the request body that would go on the wire, and state what an attacker gains from the original version.
Example:
Input: const userId = getUserInput(); const query = 'query { user(id: "' + userId + '") { email role } }'; client.request(query);
Output: const query = 'query User($id: ID!) { user(id: $id) { email role } }'; client.request(query, { id: getUserInput() }); // wire body { "query": "query User($id: ID!) { user(id: $id) { email role } }", "variables": { "id": "7" } }
The document is now constant, so it can be hashed and cached once. The value travels as data where it is type-checked, instead of being parsed as query syntax.
Optimal Solution — O(1) time, O(1) space
Declare the shape once, deliver values separately. The id type matches the argument exactly so validation passes, and the non-null declaration makes omission a caller error.
query User($id: ID!) {
user(id: $id) {
email
role
}
}
// POST /graphql
// { "query": "query User($id: ID!) { user(id: $id) { email role } }",
// "variables": { "id": "7" } } Edge Cases:
- Missing variables object for a non-null variable — request error at coercion, no execution
- variables: { "id": null } — non-null ID! rejects it before execution
- A variable of type Int used where ID! is expected — validation error on type compatibility
Quiz
1. What does a request carrying dynamic values look like?
2. A caller omits a non-null variable that has no default. What does the server do?
3. Why must the document text avoid embedded user data?
4. Where is $first: String used for an Int! argument caught?
Flashcards
Question
Why variables instead of interpolation
Click to reveal answer
Answer
Constant document text avoids injection (input never parsed as syntax) and keeps one cacheable artifact per operation.
Question
Request body shape
Click to reveal answer
Answer
{ query, variables, operationName } — variables is a JSON object keyed by the declared $names.
Question
Four request phases
Click to reveal answer
Answer
Syntax → validation (types, usage) → coercion (defaults, non-null) → execution (resolver values).
Question
Non-null variable omitted
Click to reveal answer
Answer
Request error at coercion naming the variable; execution never starts.
Question
Null vs omitted argument
Click to reveal answer
Answer
A supplied JSON null passes through to a nullable argument without triggering the argument default; defaults apply only when omitted.
Revision Notes
Key Takeaways
- 1. Separate document from data: the query text is a constant, values travel in the variables object
- 2. Syntax, validation, coercion, execution — each phase owns a different class of error
- 3. Non-null declarations and defaults are the callable contract of an operation
- 4. Constant document text is what makes caching and persisted queries possible
Interview Tips
- • Name the four phases in order and give one example error produced at each
- • Explain variable interpolation as an injection bug, not just a style issue, and connect it to cache fragmentation
- • Describe how the cache key changes once the document is constant: hash(document) alone is wrong, variables must join the key
Cheat Sheet
Query Variables Cheat Sheet
query Feed($first: Int! = 25, $after: String, $orderBy: Order = NEWEST) {
posts(first: $first, after: $after, orderBy: $orderBy) { id title }
}
{ "query": "query Feed(...) { ... }",
"variables": { "first": 10, "after": "Y3Vyc29yOjE=" } }
$name: Typedeclared on the operation,name: $nameused on the field- Type mismatch (String vs Int!) → validation error, before execution
- Omitted non-null variable → request error naming it at coercion
- Argument default applies only when the argument is omitted, not when a null arrives
- Cache key = hash(document) + variables; never hash(document) alone
- Same document, many inputs → one persisted query, one cache entry shape